From 89748cd2619c54eb1c91eb7e271d6e90c984e152 Mon Sep 17 00:00:00 2001 From: particle-box Date: Sat, 7 Mar 2026 00:00:36 +0530 Subject: [PATCH] multiple fixes & improvements --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- ...kotlin-compiler-2470479978788264575.salive | 0 .../purrfectsnap/bridge/BridgeService.kt | 41 ++- .../purrfectsnap/ui/manager/Navigation.kt | 4 +- .../ui/manager/RememberRouteScrollState.kt | 51 +++ .../ui/manager/pages/TasksRootSection.kt | 5 +- .../pages/features/FeaturesRootSection.kt | 13 +- .../pages/features/ManageRuleFeature.kt | 3 +- .../pages/location/AddCoordinatesDialog.kt | 145 +++++--- .../pages/location/BetterLocationRoot.kt | 36 +- build.gradle.kts | 4 +- changelogs-stable.txt | 9 + common/src/main/assets/lang/en_US.json | 14 + .../downloader/ProfilePictureDownloader.kt | 345 ++++++++++++++++-- .../impl/ui/SpotlightCommentsUsername.kt | 241 ++++++++++-- .../ui/menu/impl/OperaContextActionMenu.kt | 139 ++++++- gradle.properties | 4 +- native/rust/src/common.rs | 4 +- native/rust/src/hook.rs | 2 +- native/rust/src/lib.rs | 32 +- native/rust/src/modules/linker_hook.rs | 6 +- 21 files changed, 928 insertions(+), 172 deletions(-) create mode 100644 .kotlin/sessions/kotlin-compiler-2470479978788264575.salive create mode 100644 app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/RememberRouteScrollState.kt diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b96ff4fa..31965c3e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.kotlin/sessions/kotlin-compiler-2470479978788264575.salive b/.kotlin/sessions/kotlin-compiler-2470479978788264575.salive new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index 1fe9fcb7..84a54472 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -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) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt index 5ff628f3..4060bd06 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt @@ -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) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/RememberRouteScrollState.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/RememberRouteScrollState.kt new file mode 100644 index 00000000..30728510 --- /dev/null +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/RememberRouteScrollState.kt @@ -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() + val lazyListOffsets = mutableMapOf>() +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt index 90f5567e..7634317e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt @@ -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 ) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 762f191e..c54bb04d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -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>, + 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().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, diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt index 8bac3ca2..d3587c3d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt @@ -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) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/AddCoordinatesDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/AddCoordinatesDialog.kt index 7f38a0b2..c7ba2706 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/AddCoordinatesDialog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/AddCoordinatesDialog.kt @@ -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"]) + } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt index 2ae7ae4b..500407fd 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/location/BetterLocationRoot.kt @@ -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"]) } } } diff --git a/build.gradle.kts b/build.gradle.kts index 417207ec..d2e60e15 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("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. diff --git a/changelogs-stable.txt b/changelogs-stable.txt index 7e97469d..efa7b771 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -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 diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index eecec4a0..555db2b3 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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", diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt index 92ef94c8..a44c04ae 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/ProfilePictureDownloader.kt @@ -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() - 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") { } } } -} \ No newline at end of file + + @Composable + private fun ProfilePictureDialog( + title: String, + subtitle: String, + emptyText: String, + downloadHint: String, + closeLabel: String, + choices: List, + 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 + } +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/SpotlightCommentsUsername.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/SpotlightCommentsUsername.kt index 603e9e63..dddfe01c 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/SpotlightCommentsUsername.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/SpotlightCommentsUsername.kt @@ -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(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(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 + ) + } } } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt index 98ffcbec..9b6a5880 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/OperaContextActionMenu.kt @@ -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( diff --git a/gradle.properties b/gradle.properties index ff6ac560..6696c0c0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/native/rust/src/common.rs b/native/rust/src/common.rs index 6e4033c8..e430acc7 100644 --- a/native/rust/src/common.rs +++ b/native/rust/src/common.rs @@ -15,7 +15,7 @@ pub static CLIENT_MODULE: Lazy = 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); -} \ No newline at end of file +} diff --git a/native/rust/src/hook.rs b/native/rust/src/hook.rs index 7527e4bd..6a8043f1 100644 --- a/native/rust/src/hook.rs +++ b/native/rust/src/hook.rs @@ -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); } }; } diff --git a/native/rust/src/lib.rs b/native/rust/src/lib.rs index 0aeccbd6..6e5a0641 100644 --- a/native/rust/src/lib.rs +++ b/native/rust/src/lib.rs @@ -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> = 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()); diff --git a/native/rust/src/modules/linker_hook.rs b/native/rust/src/modules/linker_hook.rs index b73ede33..105e61a5 100644 --- a/native/rust/src/modules/linker_hook.rs +++ b/native/rust/src/modules/linker_hook.rs @@ -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);