From f5da0eea2f49e2542a9a3079b52bf5bbd34b7bdf Mon Sep 17 00:00:00 2001 From: rushiiMachine <33725716+rushiiMachine@users.noreply.github.com> Date: Sun, 1 Jun 2025 01:53:38 -0700 Subject: [PATCH] refactor(OverlayManager): better composable management Previously this created a separate `ComposeView` and attached it to the activity root, which in essence made it detached from the main composition context and introduced a lot of complexity. This changes it store the composable lambdas instead and manually display each one directly from the activity's `setContent`, which is a lot less cursed. --- .../com/aliucord/manager/MainActivity.kt | 4 + .../manager/manager/OverlayManager.kt | 99 +++++++++---------- .../steps/download/DownloadInjectorStep.kt | 8 +- .../steps/download/DownloadPatchesStep.kt | 8 +- .../patcher/steps/install/InstallStep.kt | 4 +- .../components/dialogs/PlayProtectDialog.kt | 2 +- 6 files changed, 60 insertions(+), 65 deletions(-) diff --git a/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt b/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt index e8f6e2a..7859da6 100644 --- a/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt +++ b/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.core.view.WindowCompat import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.navigator.* import cafe.adriel.voyager.transitions.SlideTransition +import com.aliucord.manager.manager.OverlayManager import com.aliucord.manager.manager.PreferencesManager import com.aliucord.manager.ui.components.ManagerTheme import com.aliucord.manager.ui.components.Theme @@ -30,6 +31,7 @@ import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { private val preferences: PreferencesManager by inject() + private val overlays: OverlayManager by inject() override fun onCreate(savedInstanceState: Bundle?) { WindowCompat.setDecorFitsSystemWindows(window, false) @@ -70,6 +72,8 @@ class MainActivity : ComponentActivity() { ) ) } + + overlays.Overlays() } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/manager/OverlayManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/OverlayManager.kt index 9148732..3c13168 100644 --- a/app/src/main/kotlin/com/aliucord/manager/manager/OverlayManager.kt +++ b/app/src/main/kotlin/com/aliucord/manager/manager/OverlayManager.kt @@ -1,15 +1,8 @@ package com.aliucord.manager.manager -import android.app.Activity -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.ComposeView -import com.aliucord.manager.di.ActivityProvider -import com.aliucord.manager.ui.components.ManagerTheme -import com.aliucord.manager.ui.components.Theme +import androidx.compose.runtime.* import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlin.coroutines.resume typealias ResultComposable = @Composable (onResult: (R) -> Unit) -> Unit @@ -17,64 +10,62 @@ typealias ResultComposable = @Composable (onResult: (R) -> Unit) -> Unit /** * This is used to display dialogs on top of the current activity at any point in the code. * The main use case for this is dialogs for which a result is needed during patching steps. - * The only other alternative is binding `Flow`s from the steps back to the patching screen model + * + * The only other alternative to this setup is binding `Flow`s from the steps back to the patching screen model * and then displaying them in the UI, which is too much boilerplate. */ -class OverlayManager( - private val prefs: PreferencesManager, - private val activityProvider: ActivityProvider, -) { +@Stable +class OverlayManager { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + SupervisorJob() + private var overlays = mutableStateListOf>() + private var overlayResults = MutableSharedFlow, Any?>>(extraBufferCapacity = 5) + /** - * Attaches a new [ComposeView] content view to the root of the activity on top of everything else. - * This view will be displayed until the `onResult` callback is called, - * after which this method will finish suspending with the result from the callback invoke. + * Display all the currently queued overlays. */ - suspend fun startComposableForResult(newDialog: ResultComposable): R { - return suspendCancellableCoroutine { continuation -> - val activity = activityProvider.get() - var view: View? = null + @Composable + fun Overlays() { + for (composable in overlays) { + key(System.identityHashCode(composable)) { + val composable by rememberUpdatedState(composable) - val callback = callback@{ value: R -> - val v = view ?: return@callback + composable { result -> + if (!overlayResults.tryEmit(composable to result)) + error("overlayResults flow full!") - CoroutineScope(Dispatchers.Main).launch { - (v.parent as ViewGroup).removeView(v) - view = null + overlays -= composable } - - continuation.resume(value) } + } + } + + /** + * Adds a composable to the overlay stack which will be displayed over the top of any content. + * + * This content will be displayed until the `onResult` callback is called, + * after which this method will finish suspending with the result from the invoked callback. + * + * If the coroutine scope this method was called in gets cancelled, then the overlay will be + * removed and no result will be returned (cancelled). + */ + @Suppress("UNCHECKED_CAST") + suspend fun startComposableForResult(composable: ResultComposable): R { + return suspendCancellableCoroutine { continuation -> + val job = overlayResults + .filter { (c, _) -> c === composable } + .onEach { (_, result) -> continuation.resume(result as R) } + .cancellable() + .launchIn(coroutineScope) continuation.invokeOnCancellation { - CoroutineScope(Dispatchers.Main).launch { - val v = view ?: return@launch - - (v.parent as ViewGroup).removeView(v) - view = null + coroutineScope.launch { + overlays -= composable + job.cancel() } } - view = ComposeView(activity).apply { - setContent { - ManagerTheme( - isDarkTheme = prefs.theme == Theme.DARK || prefs.theme == Theme.SYSTEM && isSystemInDarkTheme(), - isDynamicColor = prefs.dynamicColor - ) { - newDialog(callback) - } - } - } - - CoroutineScope(Dispatchers.Main).launch { - activity.addContentView( - view!!, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - ) - - view?.bringToFront() + coroutineScope.launch { + overlays += composable } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadInjectorStep.kt b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadInjectorStep.kt index 87f83b3..6edd34b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadInjectorStep.kt +++ b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadInjectorStep.kt @@ -44,11 +44,11 @@ class DownloadInjectorStep : DownloadStep(), IDexProvider, KoinComponent { // Prompt to select or manage custom versions instead of downloading if (customVersions.isNotEmpty()) { container.log("Custom versions present, waiting for selection: ${customVersions.joinToString()}") - val selectedVersion = overlays.startComposableForResult { callback -> + val selectedVersion = overlays.startComposableForResult { onResult -> CustomComponentVersionPicker( componentTitle = "Injector", versions = customVersions, - onConfirm = { version -> callback(version) }, + onConfirm = { version -> onResult(version) }, onDelete = { version -> try { paths.cachedInjectorDex(version, custom = true).delete() @@ -56,12 +56,12 @@ class DownloadInjectorStep : DownloadStep(), IDexProvider, KoinComponent { // Dismiss if no custom versions left if (customVersions.isEmpty()) - callback(null) + onResult(null) } catch (t: Throwable) { Log.e(BuildConfig.TAG, "Failed to delete custom component", t) } }, - onCancel = { callback(null) }, + onCancel = { onResult(null) }, ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadPatchesStep.kt b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadPatchesStep.kt index 0478d41..044a1b3 100644 --- a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadPatchesStep.kt +++ b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/download/DownloadPatchesStep.kt @@ -42,11 +42,11 @@ class DownloadPatchesStep : DownloadStep(), KoinComponent { // Prompt to select or manage custom versions instead of downloading if (customVersions.isNotEmpty()) { container.log("Custom versions present, waiting for selection: ${customVersions.joinToString()}") - val selectedVersion = overlays.startComposableForResult { callback -> + val selectedVersion = overlays.startComposableForResult { onResult -> CustomComponentVersionPicker( componentTitle = "Smali Patches", versions = customVersions, - onConfirm = { version -> callback(version) }, + onConfirm = { version -> onResult(version) }, onDelete = { version -> try { paths.cachedSmaliPatches(version, custom = true).delete() @@ -54,12 +54,12 @@ class DownloadPatchesStep : DownloadStep(), KoinComponent { // Dismiss if no custom versions left if (customVersions.isEmpty()) - callback(null) + onResult(null) } catch (t: Throwable) { Log.e(BuildConfig.TAG, "Failed to delete custom component", t) } }, - onCancel = { callback(null) }, + onCancel = { onResult(null) }, ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt index 1cd5dad..03d027a 100644 --- a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt +++ b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/install/InstallStep.kt @@ -60,8 +60,8 @@ class InstallStep(private val options: PatchOptions) : Step(), KoinComponent { && context.isPlayProtectEnabled() == true ) { container.log("Showing play protect warning dialog") - val neverShowAgain = overlays.startComposableForResult { callback -> - PlayProtectDialog(onDismiss = callback) + val neverShowAgain = overlays.startComposableForResult { onResult -> + PlayProtectDialog(onDismiss = onResult) } prefs.showPlayProtectWarning = !neverShowAgain } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/PlayProtectDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/PlayProtectDialog.kt index 6dfcf58..f63cacf 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/PlayProtectDialog.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/PlayProtectDialog.kt @@ -86,7 +86,7 @@ fun PlayProtectDialog( } }, properties = DialogProperties( - dismissOnBackPress = false, + dismissOnClickOutside = false, usePlatformDefaultWidth = false, ), modifier = Modifier