commit 258b10fd728e5d471df8347710e44d062100f857 parent f2e49e93fbd3df5b1a9e70601b33c38b9256445f Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 22 Sep 2023 00:52:56 +0200 feat: features overlay - rename debug section to settings Diffstat:
16 files changed, 803 insertions(+), 187 deletions(-)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <queries> <package android:name="com.snapchat.android" /> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -25,6 +25,7 @@ import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.scripting.RemoteScriptManager +import me.rhunk.snapenhance.ui.overlay.SettingsOverlay import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.data.InstallationSummary import me.rhunk.snapenhance.ui.manager.data.ModInfo @@ -56,6 +57,7 @@ class RemoteSideContext( val streaksReminder = StreaksReminder(this) val log = LogManager(this) val scriptManager = RemoteScriptManager(this) + val settingsOverlay = SettingsOverlay(this) //used to load bitmoji selfies and download previews val imageLoader by lazy { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -187,5 +187,20 @@ class BridgeService : Service() { } override fun getScriptingInterface() = remoteSideContext.scriptManager + override fun openSettingsOverlay() { + runCatching { + remoteSideContext.settingsOverlay.show() + }.onFailure { + remoteSideContext.log.error("Failed to open settings overlay", it) + } + } + + override fun closeSettingsOverlay() { + runCatching { + remoteSideContext.settingsOverlay.close() + }.onFailure { + remoteSideContext.log.error("Failed to close settings overlay", it) + } + } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.ui.manager.sections.features +import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween @@ -65,7 +66,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable @@ -80,12 +80,9 @@ import me.rhunk.snapenhance.core.config.FeatureNotice import me.rhunk.snapenhance.core.config.PropertyKey import me.rhunk.snapenhance.core.config.PropertyPair import me.rhunk.snapenhance.core.config.PropertyValue +import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper -import me.rhunk.snapenhance.ui.util.AlertDialogs -import me.rhunk.snapenhance.ui.util.chooseFolder -import me.rhunk.snapenhance.ui.util.openFile -import me.rhunk.snapenhance.ui.util.saveFile +import me.rhunk.snapenhance.ui.util.* @OptIn(ExperimentalMaterial3Api::class) class FeaturesSection : Section() { @@ -98,7 +95,7 @@ class FeaturesSection : Section() { } - private lateinit var activityLauncherHelper: ActivityLauncherHelper + private var activityLauncherHelper: ActivityLauncherHelper? = null private val featuresRouteName by lazy { context.translation["manager.routes.features"] } private lateinit var rememberScaffoldState: BottomSheetScaffoldState @@ -143,6 +140,16 @@ class FeaturesSection : Section() { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } + private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) { + activityLauncherHelper?.let(block) ?: run { + //open manager if activity launcher is null + val intent = Intent(context.androidContext, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra("route", enumSection.route) + context.androidContext.startActivity(intent) + } + } + override fun build(navGraphBuilder: NavGraphBuilder) { navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) { composable(MAIN_ROUTE) { @@ -194,8 +201,10 @@ class FeaturesSection : Section() { if (property.key.params.flags.contains(ConfigFlag.FOLDER)) { IconButton(onClick = registerClickCallback { - activityLauncherHelper.chooseFolder { uri -> - propertyValue.setAny(uri) + activityLauncher { + chooseFolder { uri -> + propertyValue.setAny(uri) + } } }.let { { it.invoke(true) } }) { Icon(Icons.Filled.FolderOpen, contentDescription = null) @@ -478,24 +487,28 @@ class FeaturesSection : Section() { val actions = remember { mapOf( "Export" to { - activityLauncherHelper.saveFile("config.json", "application/json") { uri -> - context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { - context.config.writeConfig() - context.config.exportToString().byteInputStream().copyTo(it) - context.shortToast("Config exported successfully!") + activityLauncher { + saveFile("config.json", "application/json") { uri -> + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + context.config.writeConfig() + context.config.exportToString().byteInputStream().copyTo(it) + context.shortToast("Config exported successfully!") + } } } }, "Import" to { - activityLauncherHelper.openFile("application/json") { uri -> - context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { - runCatching { - context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8)) - }.onFailure { - context.longToast("Failed to import config ${it.message}") - return@use + activityLauncher { + openFile("application/json") { uri -> + context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { + runCatching { + context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8)) + }.onFailure { + context.longToast("Failed to import config ${it.message}") + return@use + } + context.shortToast("Config successfully loaded!") } - context.shortToast("Config successfully loaded!") } } }, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -4,32 +4,11 @@ import android.content.Intent import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material.icons.filled.ReceiptLong -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,8 +42,8 @@ class HomeSection : Section() { companion object { val cardMargin = 10.dp const val HOME_ROOT = "home_root" - const val DEBUG_SECTION_ROUTE = "home_debug" const val LOGS_SECTION_ROUTE = "home_logs" + const val SETTINGS_SECTION_ROUTE = "home_settings" } private var installationSummary: InstallationSummary? = null @@ -223,12 +202,12 @@ class HomeSection : Section() { IconButton(onClick = { navController.navigate(LOGS_SECTION_ROUTE) }) { - Icon(Icons.Filled.ReceiptLong, contentDescription = null) + Icon(Icons.Filled.BugReport, contentDescription = null) } IconButton(onClick = { - navController.navigate(DEBUG_SECTION_ROUTE) + navController.navigate(SETTINGS_SECTION_ROUTE) }) { - Icon(Icons.Filled.BugReport, contentDescription = null) + Icon(Icons.Filled.Settings, contentDescription = null) } } LOGS_SECTION_ROUTE -> { @@ -290,8 +269,8 @@ class HomeSection : Section() { composable(LOGS_SECTION_ROUTE) { homeSubSection.LogsSection() } - composable(DEBUG_SECTION_ROUTE) { - homeSubSection.DebugSection() + composable(SETTINGS_SECTION_ROUTE) { + SettingsSection().also { it.context = context }.Content() } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt @@ -2,26 +2,14 @@ package me.rhunk.snapenhance.ui.manager.sections.home import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll -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.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Report @@ -29,17 +17,9 @@ import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput @@ -49,73 +29,19 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.logger.LogChannel -import me.rhunk.snapenhance.core.logger.LogLevel import me.rhunk.snapenhance.LogReader import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.action.EnumAction -import me.rhunk.snapenhance.core.bridge.types.BridgeFileType -import me.rhunk.snapenhance.manager.impl.ActionManager -import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.core.logger.LogChannel +import me.rhunk.snapenhance.core.logger.LogLevel class HomeSubSection( private val context: RemoteSideContext ) { - private val dialogs by lazy { AlertDialogs(context.translation) } - private lateinit var logListState: LazyListState @Composable - private fun RowAction(title: String, requireConfirmation: Boolean = false, action: () -> Unit) { - var confirmationDialog by remember { - mutableStateOf(false) - } - - fun takeAction() { - if (requireConfirmation) { - confirmationDialog = true - } else { - action() - } - } - - if (requireConfirmation && confirmationDialog) { - Dialog(onDismissRequest = { confirmationDialog = false }) { - dialogs.ConfirmDialog(title = "Are you sure?", onConfirm = { - action() - confirmationDialog = false - }, onDismiss = { - confirmationDialog = false - }) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .height(65.dp) - .clickable { - takeAction() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = title, modifier = Modifier.padding(start = 26.dp)) - IconButton(onClick = { takeAction() }) { - Icon( - imageVector = Icons.Filled.OpenInNew, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - } - } - - @Composable fun LogsSection() { val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current @@ -236,43 +162,4 @@ class HomeSubSection( } } } - - private fun launchActionIntent(action: EnumAction) { - val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) - intent?.putExtra(ActionManager.ACTION_PARAMETER, action.key) - context.androidContext.startActivity(intent) - } - - @Composable - private fun RowTitle(title: String) { - Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold) - } - - @Composable - fun DebugSection() { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(ScrollState(0)) - ) { - RowTitle(title = "Actions") - EnumAction.values().forEach { enumAction -> - RowAction(title = context.translation["actions.${enumAction.key}"]) { - launchActionIntent(enumAction) - } - } - - RowTitle(title = "Clear Files") - BridgeFileType.values().forEach { fileType -> - RowAction(title = fileType.displayName, requireConfirmation = true) { - runCatching { - fileType.resolve(context.androidContext).delete() - context.longToast("Deleted ${fileType.displayName}!") - }.onFailure { - context.longToast("Failed to delete ${fileType.displayName}!") - } - } - } - } - } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt @@ -0,0 +1,117 @@ +package me.rhunk.snapenhance.ui.manager.sections.home + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.action.EnumAction +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType +import me.rhunk.snapenhance.manager.impl.ActionManager +import me.rhunk.snapenhance.ui.manager.Section +import me.rhunk.snapenhance.ui.util.AlertDialogs + +class SettingsSection : Section() { + private val dialogs by lazy { AlertDialogs(context.translation) } + + @Composable + private fun RowTitle(title: String) { + Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold) + } + + @Composable + private fun RowAction(title: String, requireConfirmation: Boolean = false, action: () -> Unit) { + var confirmationDialog by remember { + mutableStateOf(false) + } + + fun takeAction() { + if (requireConfirmation) { + confirmationDialog = true + } else { + action() + } + } + + if (requireConfirmation && confirmationDialog) { + Dialog(onDismissRequest = { confirmationDialog = false }) { + dialogs.ConfirmDialog(title = "Are you sure?", onConfirm = { + action() + confirmationDialog = false + }, onDismiss = { + confirmationDialog = false + }) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(65.dp) + .clickable { + takeAction() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = title, modifier = Modifier.padding(start = 26.dp)) + IconButton(onClick = { takeAction() }) { + Icon( + imageVector = Icons.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + } + + private fun launchActionIntent(action: EnumAction) { + val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) + intent?.putExtra(ActionManager.ACTION_PARAMETER, action.key) + context.androidContext.startActivity(intent) + } + + @Composable + override fun Content() { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(ScrollState(0)) + ) { + RowTitle(title = "Actions") + EnumAction.values().forEach { enumAction -> + RowAction(title = context.translation["actions.${enumAction.key}"]) { + launchActionIntent(enumAction) + } + } + + RowTitle(title = "Clear Files") + BridgeFileType.values().forEach { fileType -> + RowAction(title = fileType.displayName, requireConfirmation = true) { + runCatching { + fileType.resolve(context.androidContext).delete() + context.longToast("Deleted ${fileType.displayName}!") + }.onFailure { + context.longToast("Failed to delete ${fileType.displayName}!") + } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/ComposeOverlay.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/ComposeOverlay.kt @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.ui.overlay + +import android.content.Context +import android.os.Bundle +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.compose.runtime.Recomposer +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.compositionContext +import androidx.lifecycle.* +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +// https://github.com/tberghuis/FloatingCountdownTimer/blob/master/app/src/main/java/xyz/tberghuis/floatingtimer/service/overlayViewFactory.kt +fun overlayComposeView(service: Context) = ComposeView(service).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val lifecycleOwner = OverlayLifecycleOwner().apply { + performRestore(null) + handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + setViewTreeLifecycleOwner(lifecycleOwner) + setViewTreeSavedStateRegistryOwner(lifecycleOwner) + + val viewModelStore = ViewModelStore() + setViewTreeViewModelStoreOwner(object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore + get() = viewModelStore + }) + + val backPressedDispatcherOwner = OnBackPressedDispatcher() + setViewTreeOnBackPressedDispatcherOwner(object: OnBackPressedDispatcherOwner { + override val lifecycle: Lifecycle + get() = lifecycleOwner.lifecycle + override val onBackPressedDispatcher: OnBackPressedDispatcher + get() = backPressedDispatcherOwner + }) + + val coroutineContext = AndroidUiDispatcher.CurrentThread + val runRecomposeScope = CoroutineScope(coroutineContext) + val recomposer = Recomposer(coroutineContext) + compositionContext = recomposer + runRecomposeScope.launch { + recomposer.runRecomposeAndApplyChanges() + } +} + +private class OverlayLifecycleOwner : SavedStateRegistryOwner { + private var mLifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) + private var mSavedStateRegistryController: SavedStateRegistryController = + SavedStateRegistryController.create(this) + override val lifecycle: Lifecycle + get() = mLifecycleRegistry + override val savedStateRegistry: SavedStateRegistry + get() = mSavedStateRegistryController.savedStateRegistry + fun handleLifecycleEvent(event: Lifecycle.Event) { + mLifecycleRegistry.handleLifecycleEvent(event) + } + fun performRestore(savedState: Bundle?) { + mSavedStateRegistryController.performRestore(savedState) + } + fun performSave(outBundle: Bundle) { + mSavedStateRegistryController.performSave(outBundle) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/SettingsOverlay.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/SettingsOverlay.kt @@ -0,0 +1,133 @@ +package me.rhunk.snapenhance.ui.overlay + +import android.app.Dialog +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.provider.Settings +import android.view.WindowManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.rememberNavController +import com.arthenica.ffmpegkit.Packages.getPackageName +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.ui.AppMaterialTheme +import me.rhunk.snapenhance.ui.manager.EnumSection +import me.rhunk.snapenhance.ui.manager.Navigation +import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection + + +class SettingsOverlay( + private val context: RemoteSideContext +) { + private lateinit var dialog: Dialog + private fun checkForPermissions(): Boolean { + if (!Settings.canDrawOverlays(context.androidContext)) { + val myIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + myIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + myIntent.setData(Uri.parse("package:" + getPackageName())) + context.androidContext.startActivity(myIntent) + return false + } + return true + } + + @Composable + private fun OverlayContent() { + val navHostController = rememberNavController() + + /*navHostController.addOnDestinationChangedListener { _, destination, _ -> + dialog.setCancelable(destination.route == FeaturesSection.MAIN_ROUTE) + }*/ + + val navigation = remember { + Navigation( + context, + mapOf( + EnumSection.FEATURES to FeaturesSection().apply { + enumSection = EnumSection.FEATURES + context = this@SettingsOverlay.context + } + ), + navHostController + ) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { navigation.TopBar() } + ) { innerPadding -> + navigation.NavigationHost( + startDestination = EnumSection.FEATURES, + innerPadding = innerPadding + ) + } + } + + fun close() { + if (!::dialog.isInitialized || !dialog.isShowing) return + context.config.writeConfig() + dialog.dismiss() + } + + fun show() { + if (!checkForPermissions()) { + return + } + + if (::dialog.isInitialized && dialog.isShowing) { + return + } + + context.androidContext.mainExecutor.execute { + dialog = Dialog(context.androidContext, R.style.FullscreenOverlayDialog) + dialog.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + ) + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY) + } + + dialog.setContentView( + overlayComposeView(context.androidContext).apply { + setContent { + Column( + modifier = Modifier + .fillMaxSize() + .padding(10.dp) + .clip(shape = MaterialTheme.shapes.large), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + AppMaterialTheme { + OverlayContent() + } + } + } + } + ) + + dialog.setCancelable(true) + dialog.setOnDismissListener { + close() + } + + dialog.show() + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt @@ -0,0 +1,360 @@ +package me.rhunk.snapenhance.ui.util + + +import android.content.Context +import android.graphics.Outline +import android.os.Build +import android.view.* +import androidx.activity.ComponentDialog +import androidx.activity.addCallback +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.R +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewRootForInspector +import androidx.compose.ui.semantics.dialog +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.core.view.WindowCompat +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import java.util.UUID +import kotlin.math.roundToInt + +class DialogProperties constructor( + val dismissOnBackPress: Boolean = true, + val dismissOnClickOutside: Boolean = true, + val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + val usePlatformDefaultWidth: Boolean = true, + val decorFitsSystemWindows: Boolean = true +) { + + constructor( + dismissOnBackPress: Boolean = true, + dismissOnClickOutside: Boolean = true, + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + ) : this( + dismissOnBackPress = dismissOnBackPress, + dismissOnClickOutside = dismissOnClickOutside, + securePolicy = securePolicy, + usePlatformDefaultWidth = true, + decorFitsSystemWindows = true + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DialogProperties) return false + + if (dismissOnBackPress != other.dismissOnBackPress) return false + if (dismissOnClickOutside != other.dismissOnClickOutside) return false + if (securePolicy != other.securePolicy) return false + if (usePlatformDefaultWidth != other.usePlatformDefaultWidth) return false + if (decorFitsSystemWindows != other.decorFitsSystemWindows) return false + + return true + } + + override fun hashCode(): Int { + var result = dismissOnBackPress.hashCode() + result = 31 * result + dismissOnClickOutside.hashCode() + result = 31 * result + securePolicy.hashCode() + result = 31 * result + usePlatformDefaultWidth.hashCode() + result = 31 * result + decorFitsSystemWindows.hashCode() + return result + } +} + +@Composable +fun Dialog( + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + content: @Composable () -> Unit +) { + val view = LocalView.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val composition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + val dialogId = rememberSaveable { UUID.randomUUID() } + val dialog = remember(view, density) { + DialogWrapper( + onDismissRequest, + properties, + view, + layoutDirection, + density, + dialogId + ).apply { + setContent(composition) { + // TODO(b/159900354): draw a scrim and add margins around the Compose Dialog, and + // consume clicks so they can't pass through to the underlying UI + DialogLayout( + Modifier.semantics { dialog() }, + ) { + currentContent() + } + } + } + } + + DisposableEffect(dialog) { + // Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays + dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY) + dialog.show() + + onDispose { + dialog.dismiss() + dialog.disposeComposition() + } + } + + SideEffect { + dialog.updateParameters( + onDismissRequest = onDismissRequest, + properties = properties, + layoutDirection = layoutDirection + ) + } +} + +interface DialogWindowProvider { + val window: Window +} + +@Suppress("ViewConstructor") +private class DialogLayout( + context: Context, + override val window: Window +) : AbstractComposeView(context), DialogWindowProvider { + + private var content: @Composable () -> Unit by mutableStateOf({}) + + var usePlatformDefaultWidth = false + + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { + setParentCompositionContext(parent) + this.content = content + shouldCreateCompositionOnAttachedToWindow = true + createComposition() + } + + override fun measureChild( + child: View?, + parentWidthMeasureSpec: Int, + parentHeightMeasureSpec: Int + ) { + + super.measureChild(child, parentWidthMeasureSpec, parentHeightMeasureSpec) + } + + private val displayWidth: Int + get() { + val density = context.resources.displayMetrics.density + return (context.resources.configuration.screenWidthDp * density).roundToInt() + } + + private val displayHeight: Int + get() { + val density = context.resources.displayMetrics.density + return (context.resources.configuration.screenHeightDp * density).roundToInt() + } + + @Composable + override fun Content() { + content() + } +} + +private class DialogWrapper( + private var onDismissRequest: () -> Unit, + private var properties: DialogProperties, + private val composeView: View, + layoutDirection: LayoutDirection, + density: Density, + dialogId: UUID +) : ComponentDialog( + ContextThemeWrapper( + composeView.context, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || properties.decorFitsSystemWindows) { + R.style.DialogWindowTheme + } else { + R.style.FloatingDialogWindowTheme + } + ) +), + ViewRootForInspector { + + private val dialogLayout: DialogLayout + + // On systems older than Android S, there is a bug in the surface insets matrix math used by + // elevation, so high values of maxSupportedElevation break accessibility services: b/232788477. + private val maxSupportedElevation = 8.dp + + override val subCompositionView: AbstractComposeView get() = dialogLayout + + private val defaultSoftInputMode: Int + + init { + val window = window ?: error("Dialog has no window") + defaultSoftInputMode = + window.attributes.softInputMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST + window.requestFeature(Window.FEATURE_NO_TITLE) + window.setBackgroundDrawableResource(android.R.color.transparent) + @OptIn(ExperimentalComposeUiApi::class) + WindowCompat.setDecorFitsSystemWindows(window, properties.decorFitsSystemWindows) + dialogLayout = DialogLayout(context, window).apply { + // Set unique id for AbstractComposeView. This allows state restoration for the state + // defined inside the Dialog via rememberSaveable() + setTag(R.id.compose_view_saveable_id_tag, "Dialog:$dialogId") + // Enable children to draw their shadow by not clipping them + clipChildren = false + // Allocate space for elevation + with(density) { elevation = maxSupportedElevation.toPx() } + // Simple outline to force window manager to allocate space for shadow. + // Note that the outline affects clickable area for the dismiss listener. In case of + // shapes like circle the area for dismiss might be to small (rectangular outline + // consuming clicks outside of the circle). + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, result: Outline) { + result.setRect(0, 0, view.width, view.height) + // We set alpha to 0 to hide the view's shadow and let the composable to draw + // its own shadow. This still enables us to get the extra space needed in the + // surface. + result.alpha = 0f + } + } + } + + /** + * Disables clipping for [this] and all its descendant [ViewGroup]s until we reach a + * [DialogLayout] (the [ViewGroup] containing the Compose hierarchy). + */ + fun ViewGroup.disableClipping() { + clipChildren = false + if (this is DialogLayout) return + for (i in 0 until childCount) { + (getChildAt(i) as? ViewGroup)?.disableClipping() + } + } + + // Turn of all clipping so shadows can be drawn outside the window + (window.decorView as? ViewGroup)?.disableClipping() + setContentView(dialogLayout) + dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner()) + dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner()) + dialogLayout.setViewTreeSavedStateRegistryOwner( + composeView.findViewTreeSavedStateRegistryOwner() + ) + + // Initial setup + updateParameters(onDismissRequest, properties, layoutDirection) + + // Due to how the onDismissRequest callback works + // (it enforces a just-in-time decision on whether to update the state to hide the dialog) + // we need to unconditionally add a callback here that is always enabled, + // meaning we'll never get a system UI controlled predictive back animation + // for these dialogs + onBackPressedDispatcher.addCallback(this) { + if (properties.dismissOnBackPress) { + onDismissRequest() + } + } + } + + private fun setLayoutDirection(layoutDirection: LayoutDirection) { + dialogLayout.layoutDirection = when (layoutDirection) { + LayoutDirection.Ltr -> android.util.LayoutDirection.LTR + LayoutDirection.Rtl -> android.util.LayoutDirection.RTL + } + } + + // TODO(b/159900354): Make the Android Dialog full screen and the scrim fully transparent + + fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) { + dialogLayout.setContent(parentComposition, children) + } + + private fun setSecurePolicy(securePolicy: SecureFlagPolicy) { + } + + fun updateParameters( + onDismissRequest: () -> Unit, + properties: DialogProperties, + layoutDirection: LayoutDirection + ) { + this.onDismissRequest = onDismissRequest + this.properties = properties + setSecurePolicy(properties.securePolicy) + setLayoutDirection(layoutDirection) + if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) { + // Undo fixed size in internalOnLayout, which would suppress size changes when + // usePlatformDefaultWidth is true. + window?.setLayout( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT + ) + } + dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + @OptIn(ExperimentalComposeUiApi::class) + if (properties.decorFitsSystemWindows) { + window?.setSoftInputMode(defaultSoftInputMode) + } else { + @Suppress("DEPRECATION") + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + } + } + + fun disposeComposition() { + dialogLayout.disposeComposition() + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val result = super.onTouchEvent(event) + if (result && properties.dismissOnClickOutside) { + onDismissRequest() + } + + return result + } + + override fun cancel() { + // Prevents the dialog from dismissing itself + return + } +} + +@Composable +private fun DialogLayout( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Layout( + content = content, + modifier = modifier + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val width = placeables.maxBy { it.width }.width + val height = placeables.maxBy { it.height }.height + layout(width, height) { + placeables.forEach { it.placeRelative(0, 0) } + } + } +} diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="FullscreenOverlayDialog"> + <item name="android:windowBackground">@android:color/transparent</item> + <item name="android:windowFrame">@null</item> + <item name="android:windowIsFloating">false</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowContentOverlay">@null</item> + <item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item> + <item name="android:windowSoftInputMode">stateUnspecified|adjustPan</item> + <item name="android:windowActionBar">false</item> + <item name="android:windowActionModeOverlay">true</item> + </style> +</resources> diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -93,4 +93,8 @@ interface BridgeInterface { oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends); IScripting getScriptingInterface(); + + void openSettingsOverlay(); + + void closeSettingsOverlay(); } \ No newline at end of file diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json @@ -25,7 +25,7 @@ "downloads": "Downloads", "features": "Features", "home": "Home", - "home_debug": "Debug", + "home_settings": "Settings", "home_logs": "Logs", "social": "Social", "scripts": "Scripts" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt @@ -16,12 +16,20 @@ import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo import me.rhunk.snapenhance.core.util.ktx.getApplicationInfoCompat import me.rhunk.snapenhance.data.SnapClassCache +import me.rhunk.snapenhance.hook.HookAdapter import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.hook import kotlin.time.ExperimentalTime import kotlin.time.measureTime +private fun useMainActivity(hookAdapter: HookAdapter, block: Activity.() -> Unit) { + val activity = hookAdapter.thisObject() as Activity + if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return + block(activity) +} + + class SnapEnhance { companion object { lateinit var classLoader: ClassLoader @@ -68,12 +76,18 @@ class SnapEnhance { } Activity::class.java.hook( "onCreate", HookStage.AFTER, { isBridgeInitialized }) { - val activity = it.thisObject() as Activity - if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook - val isMainActivityNotNull = appContext.mainActivity != null - appContext.mainActivity = activity - if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@hook - onActivityCreate() + useMainActivity(it) { + val isMainActivityNotNull = appContext.mainActivity != null + appContext.mainActivity = this + if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@useMainActivity + onActivityCreate() + } + } + + Activity::class.java.hook( "onPause", HookStage.AFTER, { isBridgeInitialized }) { + useMainActivity(it) { + appContext.bridgeClient.closeSettingsOverlay() + } } var activityWasResumed = false @@ -81,17 +95,16 @@ class SnapEnhance { //we need to reload the config when the app is resumed //FIXME: called twice at first launch Activity::class.java.hook("onResume", HookStage.AFTER, { isBridgeInitialized }) { - val activity = it.thisObject() as Activity - if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook + useMainActivity(it) { + if (!activityWasResumed) { + activityWasResumed = true + return@useMainActivity + } - if (!activityWasResumed) { - activityWasResumed = true - return@hook + appContext.actionManager.onNewIntent(this.intent) + appContext.reloadConfig() + syncRemote() } - - appContext.actionManager.onNewIntent(activity.intent) - appContext.reloadConfig() - syncRemote() } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -140,4 +140,7 @@ class BridgeClient( = service.setRule(targetUuid, type.key, state) fun getScriptingInterface(): IScripting = service.getScriptingInterface() + + fun openSettingsOverlay() = service.openSettingsOverlay() + fun closeSettingsOverlay() = service.closeSettingsOverlay() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt @@ -45,11 +45,12 @@ class SettingsGearInjector : AbstractMenu() { isClickable = true setOnClickListener { - val intent = Intent().apply { + /* val intent = Intent().apply { setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.ui.manager.MainActivity") putExtra("route", "features") } - context.startActivity(intent) + context.startActivity(intent)*/ + this@SettingsGearInjector.context.bridgeClient.openSettingsOverlay() } parent.setOnTouchListener { _, event ->