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.
This commit is contained in:
rushiiMachine 2025-06-01 01:53:38 -07:00
parent 7ec6c16b9b
commit f5da0eea2f
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
6 changed files with 60 additions and 65 deletions

View file

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

View file

@ -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<R> = @Composable (onResult: (R) -> Unit) -> Unit
@ -17,64 +10,62 @@ typealias ResultComposable<R> = @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<ResultComposable<Any?>>()
private var overlayResults = MutableSharedFlow<Pair<ResultComposable<Any?>, 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 <R> startComposableForResult(newDialog: ResultComposable<R>): R {
return suspendCancellableCoroutine { continuation ->
val activity = activityProvider.get<Activity>()
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 <R> startComposableForResult(composable: ResultComposable<R>): 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
}
}
}

View file

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

View file

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

View file

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

View file

@ -86,7 +86,7 @@ fun PlayProtectDialog(
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
modifier = Modifier