multiple fixes & improvements
This commit is contained in:
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -45,7 +45,7 @@ body:
|
||||
attributes:
|
||||
label: "SnapEnhance Version"
|
||||
description: "On which SnapEnhance version is this happening?"
|
||||
placeholder: "ex. 1.2.5"
|
||||
placeholder: "ex. 1.3.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.RemoteException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
@@ -27,10 +28,26 @@ import kotlin.system.measureTimeMillis
|
||||
|
||||
class BridgeService : Service() {
|
||||
private lateinit var remoteSideContext: RemoteSideContext
|
||||
lateinit var syncCallback: SyncCallback
|
||||
private var syncCallback: SyncCallback? = null
|
||||
private var syncCallbackBinder: IBinder? = null
|
||||
private val syncCallbackDeathRecipient = IBinder.DeathRecipient {
|
||||
remoteSideContext.takeIf { ::remoteSideContext.isInitialized }?.log?.warn("Sync callback binder died")
|
||||
clearSyncCallback()
|
||||
}
|
||||
var messagingBridge: MessagingBridge? = null
|
||||
|
||||
private fun clearSyncCallback() {
|
||||
syncCallbackBinder?.let { binder ->
|
||||
runCatching {
|
||||
binder.unlinkToDeath(syncCallbackDeathRecipient, 0)
|
||||
}
|
||||
}
|
||||
syncCallbackBinder = null
|
||||
syncCallback = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
clearSyncCallback()
|
||||
if (::remoteSideContext.isInitialized) {
|
||||
remoteSideContext.bridgeService = null
|
||||
}
|
||||
@@ -47,8 +64,10 @@ class BridgeService : Service() {
|
||||
}
|
||||
|
||||
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
|
||||
val callback = syncCallback ?: return
|
||||
runCatching {
|
||||
if (!syncCallback.asBinder().pingBinder()) {
|
||||
if (!callback.asBinder().pingBinder()) {
|
||||
clearSyncCallback()
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
|
||||
return
|
||||
}
|
||||
@@ -57,11 +76,11 @@ class BridgeService : Service() {
|
||||
val syncedObject = when (scope) {
|
||||
SocialScope.FRIEND -> {
|
||||
if (updateOnly && database.getFriendInfo(id) == null) return
|
||||
syncCallback.syncFriend(id)
|
||||
callback.syncFriend(id)
|
||||
}
|
||||
SocialScope.GROUP -> {
|
||||
if (updateOnly && database.getGroupInfo(id) == null) return
|
||||
syncCallback.syncGroup(id)
|
||||
callback.syncGroup(id)
|
||||
}
|
||||
} ?: run {
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id")
|
||||
@@ -77,6 +96,11 @@ class BridgeService : Service() {
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
if (it is RemoteException) {
|
||||
clearSyncCallback()
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
|
||||
return@onFailure
|
||||
}
|
||||
remoteSideContext.log.error("Failed to sync $scope $id", it)
|
||||
}
|
||||
}
|
||||
@@ -162,7 +186,16 @@ class BridgeService : Service() {
|
||||
}
|
||||
|
||||
override fun sync(callback: SyncCallback) {
|
||||
clearSyncCallback()
|
||||
syncCallback = callback
|
||||
syncCallbackBinder = callback.asBinder().also { binder ->
|
||||
runCatching {
|
||||
binder.linkToDeath(syncCallbackDeathRecipient, 0)
|
||||
}.onFailure {
|
||||
clearSyncCallback()
|
||||
throw it
|
||||
}
|
||||
}
|
||||
measureTimeMillis {
|
||||
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
|
||||
triggerScopeSync(SocialScope.FRIEND, friendId, true)
|
||||
|
||||
@@ -794,7 +794,9 @@ class Navigation(
|
||||
} else {
|
||||
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
|
||||
composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
|
||||
children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
|
||||
children.forEach { child ->
|
||||
composable(child.routeInfo.id) { child.content.invoke(it) }
|
||||
}
|
||||
route.customComposables.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
@Composable
|
||||
fun rememberRouteScrollState(key: String): ScrollState {
|
||||
val scrollState = remember(key) {
|
||||
ScrollState(RouteStateCache.scrollOffsets[key] ?: 0)
|
||||
}
|
||||
LaunchedEffect(key, scrollState.value) {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
DisposableEffect(key, scrollState) {
|
||||
onDispose {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberRouteLazyListState(key: String): LazyListState {
|
||||
val savedState = RouteStateCache.lazyListOffsets[key]
|
||||
val listState = remember(key) {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = savedState?.first ?: 0,
|
||||
firstVisibleItemScrollOffset = savedState?.second ?: 0
|
||||
)
|
||||
}
|
||||
LaunchedEffect(key, listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
DisposableEffect(key, listState) {
|
||||
onDispose {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
return listState
|
||||
}
|
||||
|
||||
private object RouteStateCache {
|
||||
val scrollOffsets = mutableMapOf<String, Int>()
|
||||
val lazyListOffsets = mutableMapOf<String, Pair<Int, Int>>()
|
||||
}
|
||||
@@ -301,12 +301,13 @@ class TasksRootSection : Routes.Route() {
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = context.translation["delete_files_option"],
|
||||
text = context.translation["manager.sections.tasks.delete_files_option"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files",
|
||||
text = context.translation["manager.sections.tasks.delete_files_option_hint"]
|
||||
?: "Also remove downloaded files",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
@@ -91,6 +91,7 @@ import com.google.gson.reflect.TypeToken
|
||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteLazyListState
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.*
|
||||
import org.json.JSONArray
|
||||
@@ -254,7 +255,10 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
Container(context.config.root)
|
||||
Container(
|
||||
configContainer = context.config.root,
|
||||
stateKey = "${routeInfo.id}:root"
|
||||
)
|
||||
}
|
||||
|
||||
override val customComposables: NavGraphBuilder.() -> Unit = {
|
||||
@@ -277,6 +281,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val containerSubtitle = translation[it.key.propertyDescription()]
|
||||
Container(
|
||||
configContainer = it.value.get() as ConfigContainer,
|
||||
stateKey = "${routeInfo.id}:container:$containerName",
|
||||
sectionTitle = containerTitle,
|
||||
sectionSubtitle = containerSubtitle,
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
@@ -297,6 +302,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
|
||||
PropertiesView(
|
||||
properties = properties,
|
||||
stateKey = "${routeInfo.id}:search:$keyword",
|
||||
isSearchResults = true,
|
||||
searchKeyword = keyword,
|
||||
enableGlobalSearch = true,
|
||||
@@ -1568,6 +1574,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
@Composable
|
||||
private fun PropertiesView(
|
||||
properties: List<PropertyPair<*>>,
|
||||
stateKey: String,
|
||||
isSearchResults: Boolean = false,
|
||||
activeSectionTitle: String? = null,
|
||||
activeSectionSubtitle: String? = null,
|
||||
@@ -1577,7 +1584,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var controlsHeight by remember { mutableStateOf(96.dp) }
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberRouteLazyListState(stateKey)
|
||||
val sharedSearchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
|
||||
var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) }
|
||||
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
|
||||
@@ -1682,6 +1689,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
@Composable
|
||||
private fun Container(
|
||||
configContainer: ConfigContainer,
|
||||
stateKey: String,
|
||||
sectionTitle: String? = null,
|
||||
sectionSubtitle: String? = null,
|
||||
searchKeyword: String? = null,
|
||||
@@ -1693,6 +1701,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
||||
}
|
||||
},
|
||||
stateKey = stateKey,
|
||||
activeSectionTitle = sectionTitle,
|
||||
activeSectionSubtitle = sectionSubtitle,
|
||||
searchKeyword = searchKeyword,
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
@@ -241,7 +242,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.location
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -16,6 +23,7 @@ import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
|
||||
|
||||
@@ -38,54 +46,105 @@ fun AddCoordinatesDialog(
|
||||
|
||||
alertDialogs.DefaultDialogCard {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
val fieldColors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedLabelColor = PurrfectPalette.textSecondary,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary,
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp
|
||||
) {
|
||||
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) }
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) }
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.cardOverlayColor.copy(alpha = 0.98f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.94f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank()
|
||||
Text(
|
||||
text = translation["save_coordinates_dialog_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -221,16 +220,21 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemedEditLocationButton(onClick: () -> Unit) {
|
||||
private fun CoordinateActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
description: String,
|
||||
accent: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FilledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(42.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
|
||||
),
|
||||
onClick = onClick
|
||||
containerColor = accent.copy(alpha = 0.22f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_location_button_description"])
|
||||
Icon(icon, contentDescription = description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,15 +678,19 @@ class BetterLocationRoot : Routes.Route() {
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.Edit,
|
||||
description = translation["edit_icon_description"],
|
||||
accent = PurrfectPalette.glowPrimary
|
||||
) {
|
||||
showEditDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_icon_description"])
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
description = translation["delete_icon_description"],
|
||||
accent = PurrfectPalette.glowSecondary
|
||||
) {
|
||||
showDeleteDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_icon_description"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
|
||||
}
|
||||
|
||||
// You can still set these for legacy use by submodules or scripts:
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.5").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("275").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.6").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("276").get().toInt())
|
||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
## v1.3.6
|
||||
- Fix: Crash issues for some devices
|
||||
- Fix: Scroll state
|
||||
- New: Redesign profile picture downloader & spotlight comments username dialog
|
||||
- New: Change the profile picture download button to an icon
|
||||
- New: Redesign playback slider & saved coordinates buttons
|
||||
- Fix: Add missing translations(en_US only for now, all languages to be supported in the next update)
|
||||
- Fix: Deadosobjectexception
|
||||
|
||||
## v1.3.5
|
||||
- New: Added support for Snap versions upto v13.81(server side update, requires v1.3.2 and above)
|
||||
- New: Migration from libxposed API to YukiHook
|
||||
|
||||
@@ -340,6 +340,7 @@
|
||||
"remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
|
||||
"remove_all_tasks_title": "Are you sure you want to remove all tasks?",
|
||||
"delete_files_option": "Also delete files",
|
||||
"delete_files_option_hint": "Permanently remove the original files from storage",
|
||||
"remove_selected_tasks_confirm": "Remove {count} tasks?",
|
||||
"remove_all_tasks_confirm": "Remove all tasks?"
|
||||
},
|
||||
@@ -2994,9 +2995,20 @@
|
||||
"profile_picture_downloader": {
|
||||
"button": "Download Profile Picture",
|
||||
"title": "Profile Picture Downloader",
|
||||
"subtitle": "Choose which profile image to save",
|
||||
"empty_state": "No profile pictures available. Please wait for the profile to load.",
|
||||
"download_hint": "Tap to download",
|
||||
"avatar_option": "Avatar",
|
||||
"background_option": "Background"
|
||||
},
|
||||
"spotlight_comments_username_dialog": {
|
||||
"title": "User Information",
|
||||
"subtitle": "Spotlight comment profile details",
|
||||
"username_label": "Username",
|
||||
"display_name_label": "Display Name",
|
||||
"user_id_label": "User ID",
|
||||
"not_available": "Not available"
|
||||
},
|
||||
"call_start_confirmation": {
|
||||
"dialog_title": "Start Call",
|
||||
"dialog_message": "Are you sure you want to start a call?"
|
||||
@@ -3506,6 +3518,8 @@
|
||||
"cancel": "Cancel",
|
||||
"documentation": "Documentation"
|
||||
},
|
||||
"include_saved_locations": "Include Saved Locations",
|
||||
"include_saved_locations_description": "Export your saved location coordinates",
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.downloader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.Button
|
||||
import android.util.TypedValue
|
||||
import android.widget.Button as AndroidButton
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
|
||||
class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
|
||||
@SuppressLint("SetTextI18n")
|
||||
@@ -32,41 +65,78 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
|
||||
val buttonText = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
|
||||
if ((0 until event.parent.childCount).any {
|
||||
val child = event.parent.getChildAt(it)
|
||||
child is Button && (child as Button).text == buttonText
|
||||
child is AndroidButton && child.contentDescription == buttonText
|
||||
}) return@subscribe
|
||||
|
||||
event.parent.addView(Button(event.parent.context).apply {
|
||||
text = buttonText
|
||||
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, 200, 0, 0)
|
||||
event.parent.addView(AndroidButton(event.parent.context).apply {
|
||||
text = ""
|
||||
contentDescription = buttonText
|
||||
val density = resources.displayMetrics.density
|
||||
val buttonHeight = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48f, resources.displayMetrics).toInt()
|
||||
minWidth = 0
|
||||
minimumWidth = 0
|
||||
minHeight = buttonHeight
|
||||
minimumHeight = buttonHeight
|
||||
setPadding(
|
||||
(6 * density).toInt(),
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
setCompoundDrawablesWithIntrinsicBounds(android.R.drawable.stat_sys_download, 0, 0, 0)
|
||||
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, buttonHeight).apply {
|
||||
setMargins((8 * density).toInt(), 200, 0, 0)
|
||||
}
|
||||
setOnClickListener {
|
||||
ViewAppearanceHelper.newAlertDialogBuilder(
|
||||
this@ProfilePictureDownloader.context.mainActivity!!
|
||||
).apply {
|
||||
setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"])
|
||||
val choices = mutableMapOf<String, String>()
|
||||
backgroundUrl?.let { choices["background_option"] = it }
|
||||
avatarUrl?.let { choices["avatar_option"] = it }
|
||||
|
||||
if (choices.isEmpty()) {
|
||||
setMessage("No profile pictures available. Please wait for the profile to load.")
|
||||
setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
|
||||
} else {
|
||||
setItems(choices.keys.map {
|
||||
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"]
|
||||
}.toTypedArray()) { _, which ->
|
||||
runCatching {
|
||||
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
|
||||
choices.values.elementAt(which),
|
||||
friendUsername ?: "unknown"
|
||||
)
|
||||
}.onFailure {
|
||||
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
|
||||
}
|
||||
}
|
||||
val choices = buildList {
|
||||
backgroundUrl?.let {
|
||||
add(
|
||||
ProfilePictureChoice(
|
||||
key = "background_option",
|
||||
url = it,
|
||||
iconType = ProfilePictureChoiceIcon.BACKGROUND
|
||||
)
|
||||
)
|
||||
}
|
||||
}.show()
|
||||
avatarUrl?.let {
|
||||
add(
|
||||
ProfilePictureChoice(
|
||||
key = "avatar_option",
|
||||
url = it,
|
||||
iconType = ProfilePictureChoiceIcon.AVATAR
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
createComposeAlertDialog(
|
||||
this@ProfilePictureDownloader.context.mainActivity!!,
|
||||
content = { alertDialog ->
|
||||
ProfilePictureDialog(
|
||||
title = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"],
|
||||
subtitle = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.subtitle"],
|
||||
emptyText = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.empty_state"],
|
||||
downloadHint = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.download_hint"],
|
||||
closeLabel = this@ProfilePictureDownloader.context.translation["common.close"],
|
||||
choices = choices,
|
||||
optionLabel = { key ->
|
||||
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$key"]
|
||||
},
|
||||
onDownload = { selectedUrl ->
|
||||
runCatching {
|
||||
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
|
||||
selectedUrl,
|
||||
friendUsername ?: "unknown"
|
||||
)
|
||||
}.onFailure {
|
||||
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
|
||||
}
|
||||
alertDialog.dismiss()
|
||||
},
|
||||
onDismiss = { alertDialog.dismiss() }
|
||||
)
|
||||
}
|
||||
).show()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -99,4 +169,213 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfilePictureDialog(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
emptyText: String,
|
||||
downloadHint: String,
|
||||
closeLabel: String,
|
||||
choices: List<ProfilePictureChoice>,
|
||||
optionLabel: (String) -> String,
|
||||
onDownload: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val shape = remember { RoundedCornerShape(24.dp) }
|
||||
val overlayBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF2A2452).copy(alpha = 0.95f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.92f)
|
||||
)
|
||||
)
|
||||
}
|
||||
val accentBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF8C7BFF).copy(alpha = 0.42f),
|
||||
Color(0xFF5FD8FF).copy(alpha = 0.34f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
shape = shape,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2452).copy(alpha = 0.95f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(overlayBrush, shape)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(62.dp)
|
||||
.background(accentBrush, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFFD9D3FF),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
if (choices.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.06f),
|
||||
RoundedCornerShape(18.dp)
|
||||
)
|
||||
.padding(horizontal = 18.dp, vertical = 20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = emptyText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFFD9D3FF),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
choices.forEach { choice ->
|
||||
ProfilePictureOptionCard(
|
||||
title = optionLabel(choice.key),
|
||||
iconType = choice.iconType,
|
||||
downloadHint = downloadHint,
|
||||
onClick = { onDownload(choice.url) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF8C7BFF).copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = closeLabel,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfilePictureOptionCard(
|
||||
title: String,
|
||||
iconType: ProfilePictureChoiceIcon,
|
||||
downloadHint: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val icon = when (iconType) {
|
||||
ProfilePictureChoiceIcon.AVATAR -> Icons.Default.Person
|
||||
ProfilePictureChoiceIcon.BACKGROUND -> Icons.Default.Image
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF8C7BFF).copy(alpha = 0.18f),
|
||||
Color(0xFF5FD8FF).copy(alpha = 0.1f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(18.dp)
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.background(Color.White.copy(alpha = 0.1f), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = downloadHint,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFFD9D3FF)
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF5FD8FF)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProfilePictureChoice(
|
||||
val key: String,
|
||||
val url: String,
|
||||
val iconType: ProfilePictureChoiceIcon
|
||||
)
|
||||
|
||||
private enum class ProfilePictureChoiceIcon {
|
||||
AVATAR,
|
||||
BACKGROUND
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,59 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableString
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
import me.eternal.purrfectsnap.core.ui.children
|
||||
import me.eternal.purrfectsnap.core.util.EvictingMap
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Locale
|
||||
|
||||
class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") {
|
||||
private val usernameCache = EvictingMap<String, String>(150)
|
||||
private val dialogTranslation by lazy { context.translation.getCategory("spotlight_comments_username_dialog") }
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun init() {
|
||||
@@ -96,39 +126,182 @@ class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") {
|
||||
}.getOrNull()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
|
||||
builder.setTitle("User Information")
|
||||
|
||||
val userInfoText = buildString {
|
||||
append("Username: $username\n")
|
||||
userInfo?.let { info ->
|
||||
append("User ID: ${userId}\n")
|
||||
append("Display Name: ${info.displayName ?: "Not available"}\n")
|
||||
} ?: append("Unable to retrieve additional information")
|
||||
}
|
||||
|
||||
builder.setMessage(userInfoText)
|
||||
builder.setPositiveButton("OK") { dialog: DialogInterface, _: Int ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
|
||||
// Make text selectable
|
||||
dialog.findViewById<TextView>(android.R.id.message)?.let { messageView ->
|
||||
messageView.setTextIsSelectable(true)
|
||||
messageView.typeface = Typeface.MONOSPACE
|
||||
createComposeAlertDialog(
|
||||
context.mainActivity!!,
|
||||
content = { alertDialog ->
|
||||
UserInfoDialog(
|
||||
username = username,
|
||||
userId = userId,
|
||||
displayName = userInfo?.displayName,
|
||||
title = dialogTranslation["title"],
|
||||
subtitle = dialogTranslation["subtitle"],
|
||||
usernameLabel = dialogTranslation["username_label"],
|
||||
displayNameLabel = dialogTranslation["display_name_label"],
|
||||
userIdLabel = dialogTranslation["user_id_label"],
|
||||
unavailableLabel = dialogTranslation["not_available"],
|
||||
closeLabel = context.translation["common.close"],
|
||||
onDismiss = { alertDialog.dismiss() }
|
||||
)
|
||||
}
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInfoDialog(
|
||||
username: String,
|
||||
userId: String,
|
||||
displayName: String?,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
usernameLabel: String,
|
||||
displayNameLabel: String,
|
||||
userIdLabel: String,
|
||||
unavailableLabel: String,
|
||||
closeLabel: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val shape = remember { RoundedCornerShape(24.dp) }
|
||||
val overlayBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
ComposeColor(0xFF2A2452).copy(alpha = 0.95f),
|
||||
ComposeColor(0xFF1A143A).copy(alpha = 0.92f)
|
||||
)
|
||||
)
|
||||
}
|
||||
val accentBrush = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
ComposeColor(0xFF8C7BFF).copy(alpha = 0.42f),
|
||||
ComposeColor(0xFF5FD8FF).copy(alpha = 0.34f)
|
||||
)
|
||||
)
|
||||
}
|
||||
val rows = remember(username, userId, displayName) {
|
||||
listOf(
|
||||
usernameLabel to username,
|
||||
displayNameLabel to (displayName?.takeIf { it.isNotBlank() } ?: unavailableLabel),
|
||||
userIdLabel to userId
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
shape = shape,
|
||||
border = BorderStroke(1.dp, ComposeColor.White.copy(alpha = 0.12f)),
|
||||
colors = CardDefaults.cardColors(containerColor = ComposeColor(0xFF2A2452).copy(alpha = 0.95f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(overlayBrush, shape)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(62.dp)
|
||||
.background(accentBrush, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
tint = ComposeColor.White,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = ComposeColor.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ComposeColor(0xFFD9D3FF),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
ComposeColor.White.copy(alpha = 0.06f),
|
||||
RoundedCornerShape(18.dp)
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
rows.forEachIndexed { index, (label, value) ->
|
||||
UserInfoRow(label = label, value = value)
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
color = ComposeColor.White.copy(alpha = 0.08f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = ComposeColor(0xFF8C7BFF).copy(alpha = 0.34f),
|
||||
contentColor = ComposeColor.White
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = closeLabel,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
return if (timestamp > 0) {
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(timestamp))
|
||||
} else {
|
||||
"Not available"
|
||||
|
||||
@Composable
|
||||
private fun UserInfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = ComposeColor(0xFFD9D3FF),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.width(96.dp)
|
||||
)
|
||||
SelectionContainer(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
color = ComposeColor.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,34 @@ import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SlowMotionVideo
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.res.use
|
||||
@@ -137,30 +154,116 @@ class OperaContextActionMenu : AbstractMenu() {
|
||||
val operaViewerParamsOverride = context.feature(OperaViewerParamsOverride::class)
|
||||
|
||||
linearLayout.addView(createComposeView(view.context) {
|
||||
val glowPrimary = Color(0xFF8C7BFF)
|
||||
val glowSecondary = Color(0xFF5FD8FF)
|
||||
val cardShape = RoundedCornerShape(22.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)
|
||||
) {
|
||||
var value by remember { mutableFloatStateOf(operaViewerParamsOverride.currentPlaybackRate) }
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
operaViewerParamsOverride.currentPlaybackRate = it
|
||||
},
|
||||
valueRange = 0.1F..4.0F,
|
||||
steps = 0,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "x" + value.toString().take(4),
|
||||
color = remember {
|
||||
Color(context.userInterface.colorPrimary)
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Card(
|
||||
shape = cardShape,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
glowPrimary.copy(alpha = 0.45f),
|
||||
glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFF2A2452).copy(alpha = 0.94f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF2A2452).copy(alpha = 0.95f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.92f)
|
||||
)
|
||||
),
|
||||
cardShape
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
glowPrimary.copy(alpha = 0.35f),
|
||||
glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SlowMotionVideo,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.align(androidx.compose.ui.Alignment.Center)
|
||||
.size(22.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Playback Rate",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
Text(
|
||||
text = "x" + value.toString().take(4),
|
||||
color = Color(0xFFD9D3FF),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
operaViewerParamsOverride.currentPlaybackRate = it
|
||||
},
|
||||
valueRange = 0.1F..4.0F,
|
||||
steps = 0,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = glowSecondary,
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.16f),
|
||||
activeTickColor = glowPrimary,
|
||||
inactiveTickColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "0.1x",
|
||||
color = Color(0xFFD9D3FF),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "4.0x",
|
||||
color = Color(0xFFD9D3FF),
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.3.5
|
||||
APP_VERSION_CODE=275
|
||||
APP_VERSION_NAME=1.3.6
|
||||
APP_VERSION_CODE=276
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
@@ -15,7 +15,7 @@ pub static CLIENT_MODULE: Lazy<MappedLib> = Lazy::new(|| {
|
||||
client_module = MappedLib::new("split_config.arm".into());
|
||||
|
||||
if let Err(error) = client_module.search() {
|
||||
panic!("Unable to find split_config.arm: {}", error);
|
||||
error!("Unable to find split_config.arm: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,4 @@ pub fn attach_jni_env(block: impl FnOnce(&mut jni::JNIEnv)) {
|
||||
let mut env: jni::AttachGuard = jvm.attach_current_thread().expect("Failed to attach to current thread");
|
||||
|
||||
block(&mut env);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ macro_rules! dobby_hook_sym {
|
||||
crate::dobby_hook!(hook_symbol, $hook);
|
||||
debug!("hooked symbol: {}", $sym);
|
||||
} else {
|
||||
panic!("Failed to resolve symbol: {}", $sym);
|
||||
error!("Failed to resolve symbol: {}", $sym);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,9 +155,15 @@ fn setChecksums(mut env: JNIEnv, _class: JClass, checksums_json: JString) {
|
||||
|
||||
fn pre_init(_env: JNIEnv, _class: JObject) {
|
||||
debug!("Pre init");
|
||||
linker_hook::init();
|
||||
custom_font_hook::init();
|
||||
fstat_hook::init();
|
||||
for (name, init) in [
|
||||
("linker_hook", linker_hook::init as fn()),
|
||||
("custom_font_hook", custom_font_hook::init as fn()),
|
||||
("fstat_hook", fstat_hook::init as fn()),
|
||||
] {
|
||||
if let Err(error) = std::panic::catch_unwind(init) {
|
||||
error!("{} init failed: {:?}", name, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init(mut env: JNIEnv, _class: JObject, signature_cache: JString) -> jstring {
|
||||
@@ -186,23 +192,29 @@ fn init(mut env: JNIEnv, _class: JObject, signature_cache: JString) -> jstring {
|
||||
let mut threads: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
|
||||
macro_rules! async_init {
|
||||
($($f:expr),*) => {
|
||||
($(($name:expr, $f:expr)),* $(,)?) => {
|
||||
$(
|
||||
threads.push(std::thread::spawn(move || {
|
||||
$f;
|
||||
if let Err(error) = std::panic::catch_unwind(|| { $f; }) {
|
||||
error!("{} init failed: {:?}", $name, error);
|
||||
}
|
||||
}));
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
async_init!(
|
||||
duplex_hook::init(),
|
||||
unary_call_hook::init(),
|
||||
valdi_hook::init(),
|
||||
sqlite_hook::init()
|
||||
("duplex_hook", duplex_hook::init()),
|
||||
("unary_call_hook", unary_call_hook::init()),
|
||||
("valdi_hook", valdi_hook::init()),
|
||||
("sqlite_hook", sqlite_hook::init())
|
||||
);
|
||||
|
||||
threads.into_iter().for_each(|t| t.join().unwrap());
|
||||
threads.into_iter().for_each(|t| {
|
||||
if let Err(error) = t.join() {
|
||||
error!("native init worker panicked: {:?}", error);
|
||||
}
|
||||
});
|
||||
|
||||
info!("native init took {:?}", start_time.elapsed());
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ def_hook!(
|
||||
let content = content.into_boxed_slice();
|
||||
|
||||
if libc::write(memfd, content.as_ptr() as *const c_void, content.len() as libc::size_t) == -1 {
|
||||
panic!("failed to write to memfd");
|
||||
error!("failed to write to memfd");
|
||||
return linker_openat_original.unwrap()(dir_fd, pathname, flags, mode);
|
||||
}
|
||||
|
||||
if libc::lseek(memfd, 0, libc::SEEK_SET) == -1 {
|
||||
panic!("failed to seek memfd");
|
||||
error!("failed to seek memfd");
|
||||
return linker_openat_original.unwrap()(dir_fd, pathname, flags, mode);
|
||||
}
|
||||
|
||||
std::mem::forget(content);
|
||||
|
||||
Reference in New Issue
Block a user