redo launch logic

This commit is contained in:
qimiko committed 2024-06-14 05:00:21 -07:00
1 parent f8fcd81057
commit 1c463da5c7
4 files changed
+382 -144

No files matched your search

@@ -1,6 +1,7 @@
package com.geode.launcher
import android.os.Bundle
import android.text.format.Formatter
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -15,19 +16,18 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -35,6 +35,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.Modifier
@@ -48,11 +49,10 @@ import com.geode.launcher.main.*
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
class AltMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -84,6 +84,12 @@ class AltMainActivity : ComponentActivity() {
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
if (loadFailureInfo != null) {
launchViewModel.loadFailure = loadFailureInfo
}
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
Surface(
@@ -91,7 +97,7 @@ class AltMainActivity : ComponentActivity() {
color = Color.Transparent
) {
WithWave {
AltMainScreen(gdInstalled, geodeInstalled, loadFailureInfo)
AltMainScreen(launchViewModel)
}
}
}
@@ -108,17 +114,140 @@ class AltMainActivity : ComponentActivity() {
}
}
@Composable
fun LaunchProgressBody(title: String, details: String, modifier: Modifier = Modifier) {
data class LaunchStatusInfo(
val title: String,
val details: String? = null,
val progress: (() -> Float)? = null
)
@Composable
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason) {
when (cancelReason) {
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(Icons.Default.Warning, contentDescription = null)
else -> Icon(Icons.Default.Info, contentDescription = null)
}
}
@Composable
fun LaunchProgressCard(modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column {
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
return when (cancelReason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash)
)
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
title = stringResource(R.string.geode_download_title)
)
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.game_not_found)
)
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
)
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
)
}
}
@Composable
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState): LaunchStatusInfo {
return when (state) {
is LaunchViewModel.LaunchUIState.Initial,
is LaunchViewModel.LaunchUIState.Working,
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
title = stringResource(R.string.launcher_starting_game)
)
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
title = stringResource(R.string.release_fetch_in_progress)
)
is LaunchViewModel.LaunchUIState.Updating -> {
val context = LocalContext.current
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
Formatter.formatShortFileSize(context, state.outOf)
}
LaunchStatusInfo(
title = stringResource(R.string.launcher_downloading_update),
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf),
progress = {
val progress = state.downloaded / state.outOf.toDouble()
progress.toFloat()
}
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
}
}
}
@Composable
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean) {
Row {
icon()
Text(statusInfo.title)
}
if (inProgress) {
LinearProgressIndicator()
}
if (statusInfo.details != null) {
Text(statusInfo.details)
}
}
@Composable
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
Column {
Text(statusInfo.title)
if (statusInfo.progress != null) {
LinearProgressIndicator(statusInfo.progress)
} else {
LinearProgressIndicator()
}
if (statusInfo.details != null) {
Text(statusInfo.details)
}
}
}
@Composable
fun LaunchProgressCard(uiState: LaunchViewModel.LaunchUIState, onCancel: () -> Unit, onResume: () -> Unit, modifier: Modifier = Modifier) {
val status = mapLaunchStatusToInfo(uiState)
Card(modifier = modifier) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
icon = { LaunchCancelledIcon(uiState.reason) },
inProgress = uiState.inProgress
)
if (uiState.reason.allowsRetry()) {
TextButton(onClick = onResume) {
Text(stringResource(R.string.launcher_cancelled_restart))
}
}
} else {
LaunchProgressBody(statusInfo = status)
TextButton(onClick = onCancel) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
}
}
}
@@ -144,151 +273,73 @@ fun GeodeLogo(modifier: Modifier = Modifier) {
@Composable
fun AltMainScreen(
gdInstalled: Boolean = true,
geodePreinstalled: Boolean = true,
loadFailureInfo: LoadFailureInfo? = null,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
) {
val context = LocalContext.current
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
val autoUpdateState by releaseViewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
?.hasUpdated ?: false
val geodeInstalled = geodePreinstalled || geodeJustInstalled
var beginLaunch by remember { mutableStateOf(false) }
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember { mutableStateOf(false) }
LaunchedEffect(shouldUpdate) {
if (shouldUpdate && !releaseViewModel.hasPerformedCheck && gdInstalled) {
releaseViewModel.runReleaseCheck()
} else {
releaseViewModel.useGlobalCheckState()
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
GeodeLogo(modifier = Modifier.offset(y = (-90).dp))
val snackbarHostState = remember { SnackbarHostState() }
// automatically show load failed dialog (but not crash)
var showErrorInfo by remember {
mutableStateOf(
loadFailureInfo != null && loadFailureInfo.title != LaunchUtils.LauncherError.CRASHED
LaunchProgressCard(
launchUIState,
onCancel = {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
},
onResume = {
launchViewModel.loadFailure = null
coroutineScope.launch {
launchViewModel.beginLaunchFlow()
}
},
modifier = Modifier.offset(y = 90.dp)
)
}
val hasError = loadFailureInfo != null
LaunchedEffect(hasError, showErrorInfo) {
if (!showErrorInfo && loadFailureInfo != null) {
showFailureSnackbar(context, loadFailureInfo, snackbarHostState) {
showErrorInfo = true
}
}
}
if (showErrorInfo && loadFailureInfo != null) {
ErrorInfoSheet(loadFailureInfo, onDismiss = { showErrorInfo = false })
}
Scaffold(
containerColor = Color.Transparent,
contentColor = Color.White,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { innerPadding ->
Box(
Row(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
contentAlignment = Alignment.Center
.fillMaxSize()
.padding(16.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.Bottom
) {
GeodeLogo(modifier = Modifier.offset(y = (-90).dp))
when {
gdInstalled && geodeInstalled -> {
val gdVersion = remember {
GamePackageUtils.getGameVersionCode(context.packageManager)
Button(
onClick = {
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
if (gdVersion < Constants.SUPPORTED_VERSION_CODE) {
val versionName = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
}
LaunchBlockedLabel(stringResource(R.string.game_outdated, versionName))
} else {
val waitForUpdate = (shouldUpdate && !releaseViewModel.hasPerformedCheck) || releaseViewModel.isInUpdate
LaunchedEffect(waitForUpdate) {
if (!waitForUpdate) {
// beginLaunch = true
}
}
}
}
gdInstalled -> {
Text(
context.getString(R.string.geode_download_title),
modifier = Modifier.padding(12.dp)
)
Row {
Button(
onClick = { releaseViewModel.runReleaseCheck(true) },
enabled = !releaseViewModel.isInUpdate
) {
Icon(
painterResource(R.drawable.icon_download),
contentDescription = context.getString(R.string.launcher_download_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(context.getString(R.string.launcher_download))
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon(
Icons.Filled.Settings,
contentDescription = context.getString(R.string.launcher_settings_icon_alt)
)
}
}
}
else -> LaunchBlockedLabel(stringResource(R.string.game_not_found))
}
OutlinedCard(
modifier = Modifier.offset(y = 70.dp)
onSettings(context)
},
) {
UpdateCard(
releaseViewModel,
modifier = Modifier.padding(16.dp)
Icon(
Icons.Filled.Settings,
contentDescription = null
)
}
Row(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.Bottom
) {
Button(
onClick = { onSettings(context) },
) {
Icon(
Icons.Filled.Settings,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(context.getString(R.string.launcher_settings))
}
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(context.getString(R.string.launcher_settings))
}
}
}
if (beginLaunch) {
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
UpdateWarning(launchInSafeMode) {
beginLaunch = false
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
}
}
}
@@ -16,11 +16,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.min
import androidx.compose.ui.zIndex
import kotlin.math.sin
import kotlin.random.Random
@@ -45,9 +42,6 @@ fun WithWave(modifier: Modifier = Modifier, contents: @Composable () -> Unit) {
) {
contents()
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
WaveBackground(
color = WAVE_COLOR_1,
period = 500.dp,
@@ -0,0 +1,179 @@
package com.geode.launcher.main
import android.app.Application
import android.os.CountDownTimer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
class LaunchViewModel(private val application: Application): ViewModel() {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
LaunchViewModel(
application = application
)
}
}
}
private var readyTimerPassed = false
private var readyTimer: CountDownTimer? = null
private fun initReadyTimer() {
readyTimer = object : CountDownTimer(1000, 1000) {
override fun onTick(millisUntilFinished: Long) {
// no tick necessary
}
override fun onFinish() {
readyTimerPassed = true
if (_uiState.value is LaunchUIState.Working) {
viewModelScope.launch {
preReadyCheck()
}
}
}
}.start()
}
enum class LaunchCancelReason {
MANUAL, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
fun allowsRetry() = when (this) {
MANUAL,
LAST_LAUNCH_CRASHED,
GEODE_NOT_FOUND -> true
GAME_MISSING,
GAME_OUTDATED -> false
}
}
sealed class LaunchUIState {
data object Initial : LaunchUIState()
data object UpdateCheck : LaunchUIState()
data class Updating(val downloaded: Long, val outOf: Long) : LaunchUIState()
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
data object Working : LaunchUIState()
data object Ready : LaunchUIState()
}
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
val uiState = _uiState.asStateFlow()
var loadFailure: LoadFailureInfo? = null
private var hasCheckedForUpdates = false
private var isCancelling = false
private suspend fun determineUpdateStatus() {
ReleaseManager.get(application).uiState.takeWhile {
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
}.map { when (it) {
is ReleaseManager.ReleaseManagerState.InUpdateCheck -> LaunchUIState.UpdateCheck
is ReleaseManager.ReleaseManagerState.InDownload -> LaunchUIState.Updating(it.downloaded, it.outOf)
else -> LaunchUIState.UpdateCheck
}
}.collect {
_uiState.emit(it)
}
// flow terminates once update check is finished
hasCheckedForUpdates = true
preReadyCheck()
}
private suspend fun preReadyCheck() {
if (isCancelling) {
// don't let the game start if cancelled
return
}
val packageManager = application.packageManager
if (!GamePackageUtils.isGameInstalled(packageManager)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
}
if (ReleaseManager.get(application).isInUpdate) {
viewModelScope.launch {
determineUpdateStatus()
}
return
}
if (loadFailure != null) {
// last launch crashed, so cancel
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
}
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
}
if (!LaunchUtils.isGeodeInstalled(application)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
}
if (!readyTimerPassed) {
return _uiState.emit(LaunchUIState.Working)
}
_uiState.emit(LaunchUIState.Ready)
}
suspend fun beginLaunchFlow() {
if (_uiState.value !is LaunchUIState.Initial && _uiState.value !is LaunchUIState.Cancelled) {
return
}
isCancelling = false
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
}
if (readyTimer == null) {
initReadyTimer()
}
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
if (shouldUpdate && !hasCheckedForUpdates) {
ReleaseManager.get(application).checkForUpdates(false)
}
preReadyCheck()
}
suspend fun cancelLaunch() {
isCancelling = true
_uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.MANUAL, true))
val releaseManager = ReleaseManager.get(application)
if (releaseManager.isInUpdate) {
releaseManager.cancelUpdate()
}
_uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.MANUAL, false))
}
override fun onCleared() {
super.onCleared()
readyTimer?.cancel()
}
}
+15 -1
View File
@@ -46,7 +46,20 @@
<string name="launcher_error_export_crash">Share crash</string>
<string name="launcher_error_export_missing">No crash dump was found!</string>
<string name="launcher_error_device_info">Android %2$s, %1$s</string>
<string name="launcher_settings_notification_body">Launcher Settings</string>
<string name="launcher_notification_settings">Launcher Settings</string>
<string name="launcher_notification_update_failed">Failed to check for updates.</string>
<string name="launcher_notification_update_cancelled">Update cancelled.</string>
<string name="launcher_notification_update_success">Geode updated.</string>
<string name="launcher_starting_game">Starting Geometry Dash…</string>
<string name="launcher_downloading_update">Downloading update…</string>
<string name="launcher_downloading_update_details">(%1$s/%2$s)</string>
<string name="launcher_cancelled_manual">Launch paused.</string>
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
<string name="launcher_cancelled_error">Could not start Geode.</string>
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
<string name="launcher_cancelled_restart">Restart</string>
<!-- Updater strings -->
<string name="release_fetch_failed">Update failed.\n%1$s</string>
@@ -146,4 +159,5 @@
<string name="application_logs_default_filename" translatable="false">launcher_logcat.txt</string>
<string name="application_logs_crash_only">Show Crash Buffer</string>
<string name="application_logs_crash_only_enabled_alt">Enabled</string>
<string name="title_activity_alt_main">AltMainActivity</string>
</resources>