feat: features overlay

- rename debug section to settings
This commit is contained in:
rhunk
2023-09-22 00:52:56 +02:00
parent f2e49e93fb
commit 258b10fd72
16 changed files with 800 additions and 187 deletions

View File

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<queries> <queries>
<package android:name="com.snapchat.android" /> <package android:name="com.snapchat.android" />

View File

@ -25,6 +25,7 @@ import me.rhunk.snapenhance.download.DownloadTaskManager
import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.ModDatabase
import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.messaging.StreaksReminder
import me.rhunk.snapenhance.scripting.RemoteScriptManager 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.MainActivity
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.ModInfo import me.rhunk.snapenhance.ui.manager.data.ModInfo
@ -56,6 +57,7 @@ class RemoteSideContext(
val streaksReminder = StreaksReminder(this) val streaksReminder = StreaksReminder(this)
val log = LogManager(this) val log = LogManager(this)
val scriptManager = RemoteScriptManager(this) val scriptManager = RemoteScriptManager(this)
val settingsOverlay = SettingsOverlay(this)
//used to load bitmoji selfies and download previews //used to load bitmoji selfies and download previews
val imageLoader by lazy { val imageLoader by lazy {

View File

@ -187,5 +187,20 @@ class BridgeService : Service() {
} }
override fun getScriptingInterface() = remoteSideContext.scriptManager 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)
}
}
} }
} }

View File

@ -1,5 +1,6 @@
package me.rhunk.snapenhance.ui.manager.sections.features package me.rhunk.snapenhance.ui.manager.sections.features
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable 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.PropertyKey
import me.rhunk.snapenhance.core.config.PropertyPair import me.rhunk.snapenhance.core.config.PropertyPair
import me.rhunk.snapenhance.core.config.PropertyValue 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.manager.Section
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.*
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
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
class FeaturesSection : Section() { 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 val featuresRouteName by lazy { context.translation["manager.routes.features"] }
private lateinit var rememberScaffoldState: BottomSheetScaffoldState private lateinit var rememberScaffoldState: BottomSheetScaffoldState
@ -143,6 +140,16 @@ class FeaturesSection : Section() {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) 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) { override fun build(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) { navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) {
composable(MAIN_ROUTE) { composable(MAIN_ROUTE) {
@ -194,8 +201,10 @@ class FeaturesSection : Section() {
if (property.key.params.flags.contains(ConfigFlag.FOLDER)) { if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
IconButton(onClick = registerClickCallback { IconButton(onClick = registerClickCallback {
activityLauncherHelper.chooseFolder { uri -> activityLauncher {
propertyValue.setAny(uri) chooseFolder { uri ->
propertyValue.setAny(uri)
}
} }
}.let { { it.invoke(true) } }) { }.let { { it.invoke(true) } }) {
Icon(Icons.Filled.FolderOpen, contentDescription = null) Icon(Icons.Filled.FolderOpen, contentDescription = null)
@ -478,24 +487,28 @@ class FeaturesSection : Section() {
val actions = remember { val actions = remember {
mapOf( mapOf(
"Export" to { "Export" to {
activityLauncherHelper.saveFile("config.json", "application/json") { uri -> activityLauncher {
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { saveFile("config.json", "application/json") { uri ->
context.config.writeConfig() context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
context.config.exportToString().byteInputStream().copyTo(it) context.config.writeConfig()
context.shortToast("Config exported successfully!") context.config.exportToString().byteInputStream().copyTo(it)
context.shortToast("Config exported successfully!")
}
} }
} }
}, },
"Import" to { "Import" to {
activityLauncherHelper.openFile("application/json") { uri -> activityLauncher {
context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { openFile("application/json") { uri ->
runCatching { context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use {
context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8)) runCatching {
}.onFailure { context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8))
context.longToast("Failed to import config ${it.message}") }.onFailure {
return@use context.longToast("Failed to import config ${it.message}")
return@use
}
context.shortToast("Config successfully loaded!")
} }
context.shortToast("Config successfully loaded!")
} }
} }
}, },

View File

@ -4,32 +4,11 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
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.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Language import androidx.compose.material3.*
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -63,8 +42,8 @@ class HomeSection : Section() {
companion object { companion object {
val cardMargin = 10.dp val cardMargin = 10.dp
const val HOME_ROOT = "home_root" const val HOME_ROOT = "home_root"
const val DEBUG_SECTION_ROUTE = "home_debug"
const val LOGS_SECTION_ROUTE = "home_logs" const val LOGS_SECTION_ROUTE = "home_logs"
const val SETTINGS_SECTION_ROUTE = "home_settings"
} }
private var installationSummary: InstallationSummary? = null private var installationSummary: InstallationSummary? = null
@ -223,12 +202,12 @@ class HomeSection : Section() {
IconButton(onClick = { IconButton(onClick = {
navController.navigate(LOGS_SECTION_ROUTE) navController.navigate(LOGS_SECTION_ROUTE)
}) { }) {
Icon(Icons.Filled.ReceiptLong, contentDescription = null) Icon(Icons.Filled.BugReport, contentDescription = null)
} }
IconButton(onClick = { 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 -> { LOGS_SECTION_ROUTE -> {
@ -290,8 +269,8 @@ class HomeSection : Section() {
composable(LOGS_SECTION_ROUTE) { composable(LOGS_SECTION_ROUTE) {
homeSubSection.LogsSection() homeSubSection.LogsSection()
} }
composable(DEBUG_SECTION_ROUTE) { composable(SETTINGS_SECTION_ROUTE) {
homeSubSection.DebugSection() SettingsSection().also { it.context = context }.Content()
} }
} }
} }

View File

@ -2,26 +2,14 @@ package me.rhunk.snapenhance.ui.manager.sections.home
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp 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.BugReport
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Report 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.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@ -49,72 +29,18 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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.LogReader
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.action.EnumAction import me.rhunk.snapenhance.core.logger.LogChannel
import me.rhunk.snapenhance.core.bridge.types.BridgeFileType import me.rhunk.snapenhance.core.logger.LogLevel
import me.rhunk.snapenhance.manager.impl.ActionManager
import me.rhunk.snapenhance.ui.util.AlertDialogs
class HomeSubSection( class HomeSubSection(
private val context: RemoteSideContext private val context: RemoteSideContext
) { ) {
private val dialogs by lazy { AlertDialogs(context.translation) }
private lateinit var logListState: LazyListState 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 @Composable
fun LogsSection() { fun LogsSection() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -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}!")
}
}
}
}
}
} }

View File

@ -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}!")
}
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View File

@ -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) }
}
}
}

View File

@ -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>

View File

@ -93,4 +93,8 @@ interface BridgeInterface {
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends); oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends);
IScripting getScriptingInterface(); IScripting getScriptingInterface();
void openSettingsOverlay();
void closeSettingsOverlay();
} }

View File

@ -25,7 +25,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"features": "Features", "features": "Features",
"home": "Home", "home": "Home",
"home_debug": "Debug", "home_settings": "Settings",
"home_logs": "Logs", "home_logs": "Logs",
"social": "Social", "social": "Social",
"scripts": "Scripts" "scripts": "Scripts"

View File

@ -16,12 +16,20 @@ import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.core.util.ktx.getApplicationInfoCompat import me.rhunk.snapenhance.core.util.ktx.getApplicationInfoCompat
import me.rhunk.snapenhance.data.SnapClassCache import me.rhunk.snapenhance.data.SnapClassCache
import me.rhunk.snapenhance.hook.HookAdapter
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hook
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTime 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 { class SnapEnhance {
companion object { companion object {
lateinit var classLoader: ClassLoader lateinit var classLoader: ClassLoader
@ -68,12 +76,18 @@ class SnapEnhance {
} }
Activity::class.java.hook( "onCreate", HookStage.AFTER, { isBridgeInitialized }) { Activity::class.java.hook( "onCreate", HookStage.AFTER, { isBridgeInitialized }) {
val activity = it.thisObject() as Activity useMainActivity(it) {
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook val isMainActivityNotNull = appContext.mainActivity != null
val isMainActivityNotNull = appContext.mainActivity != null appContext.mainActivity = this
appContext.mainActivity = activity if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@useMainActivity
if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@hook onActivityCreate()
onActivityCreate() }
}
Activity::class.java.hook( "onPause", HookStage.AFTER, { isBridgeInitialized }) {
useMainActivity(it) {
appContext.bridgeClient.closeSettingsOverlay()
}
} }
var activityWasResumed = false var activityWasResumed = false
@ -81,17 +95,16 @@ class SnapEnhance {
//we need to reload the config when the app is resumed //we need to reload the config when the app is resumed
//FIXME: called twice at first launch //FIXME: called twice at first launch
Activity::class.java.hook("onResume", HookStage.AFTER, { isBridgeInitialized }) { Activity::class.java.hook("onResume", HookStage.AFTER, { isBridgeInitialized }) {
val activity = it.thisObject() as Activity useMainActivity(it) {
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook if (!activityWasResumed) {
activityWasResumed = true
return@useMainActivity
}
if (!activityWasResumed) { appContext.actionManager.onNewIntent(this.intent)
activityWasResumed = true appContext.reloadConfig()
return@hook syncRemote()
} }
appContext.actionManager.onNewIntent(activity.intent)
appContext.reloadConfig()
syncRemote()
} }
} }

View File

@ -140,4 +140,7 @@ class BridgeClient(
= service.setRule(targetUuid, type.key, state) = service.setRule(targetUuid, type.key, state)
fun getScriptingInterface(): IScripting = service.getScriptingInterface() fun getScriptingInterface(): IScripting = service.getScriptingInterface()
fun openSettingsOverlay() = service.openSettingsOverlay()
fun closeSettingsOverlay() = service.closeSettingsOverlay()
} }

View File

@ -45,11 +45,12 @@ class SettingsGearInjector : AbstractMenu() {
isClickable = true isClickable = true
setOnClickListener { setOnClickListener {
val intent = Intent().apply { /* val intent = Intent().apply {
setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.ui.manager.MainActivity") setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.ui.manager.MainActivity")
putExtra("route", "features") putExtra("route", "features")
} }
context.startActivity(intent) context.startActivity(intent)*/
this@SettingsGearInjector.context.bridgeClient.openSettingsOverlay()
} }
parent.setOnTouchListener { _, event -> parent.setOnTouchListener { _, event ->