the switchover

This commit is contained in:
Chloe committed 2025-12-19 05:26:21 -07:00
1 parent f763534a72
commit 24b0e39658
9 files changed
+772 -746

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@
android:theme="@style/Theme.GeodeLauncher"
tools:targetApi="35">
<activity
android:name=".AltMainActivity"
android:name=".LegacyMainActivity"
android:exported="false"
android:theme="@style/Theme.GeodeLauncher" />
<activity
@@ -1,593 +0,0 @@
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
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
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
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
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.theme.launcherTitleStyle
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.CancellationException
import kotlinx.coroutines.launch
import java.net.ConnectException
import java.net.UnknownHostException
class AltMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
clearDownloadedApks(this)
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@Suppress("DEPRECATION") // the new api is android 13 only (why)
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
as? LaunchUtils.LauncherError
val loadFailureInfo = if (returnError != null && returnMessage != null) {
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
} else if (LaunchUtils.lastSessionCrashed(this)) {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null }
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
if (loadFailureInfo != null) {
launchViewModel.loadFailure = loadFailureInfo
}
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize()
) {
AltMainScreen(launchViewModel)
}
}
}
}
if (gdInstalled && geodeInstalled) {
intent.getBooleanExtra("restarted", false).let {
if (it) {
onLaunch(this)
}
}
}
}
}
data class LaunchStatusInfo(
val title: String?,
val details: String? = null,
val progress: (() -> Float)? = null
)
@Composable
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
when (cancelReason) {
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
}
}
@Composable
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)
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
title = null
)
/*
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
)
*/
}
}
@Composable
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
return when (state) {
is LaunchViewModel.LaunchUIState.Initial,
is LaunchViewModel.LaunchUIState.Working,
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
title = stringResource(R.string.launcher_starting_game),
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
)
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) {
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
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()
}.takeIf { state.outOf != null }
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
}
}
}
@Composable
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
return
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
icon()
Spacer(Modifier.size(8.dp))
Text(statusInfo.title)
}
}
}
if (inProgress) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(
statusInfo.details,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
}
if (statusInfo.progress != null) {
LinearProgressIndicator(
statusInfo.progress,
modifier = Modifier.fillMaxWidth()
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
when (reason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
Icon(
painterResource(R.drawable.icon_resume),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
painterResource(R.drawable.icon_play_arrow),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
Icon(
painterResource(R.drawable.icon_refresh),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_retry))
}
// these shouldn't be allowing retries anyways
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LaunchProgressCard(
uiState: LaunchViewModel.LaunchUIState,
crashInfo: LoadFailureInfo?,
onCancel: () -> Unit,
onResume: (safeMode: Boolean) -> Unit,
onMore: () -> Unit,
extraOptions: @Composable () -> Unit,
safeModeEnabled: Boolean,
modifier: Modifier = Modifier
) {
var showSafeModeDialog by remember { mutableStateOf(false) }
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
Box(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
inProgress = uiState.inProgress
)
if (uiState.reason.isGameInstallIssue()) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
if (showDownload) {
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
}
}
if (uiState.reason.allowsRetry()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
LongPressButton(onClick = { onResume(false) }, onLongPress = {
showSafeModeDialog = true
}) {
RetryButtonContents(uiState.reason)
}
if (crashInfo != null) {
FilledTonalButton(onClick = onMore) {
Icon(
painterResource(R.drawable.icon_question_mark),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_help_alt))
}
}
extraOptions()
}
}
} else {
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
Spacer(Modifier.weight(1.0f))
extraOptions()
}
}
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
showSafeModeDialog = false
},
onLaunch = {
onResume(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun ExtraOptions(onSettings: () -> Unit) {
IconButton(
onClick = onSettings,
) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
@Composable
fun GeodeLogo(modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Icon(
painter = painterResource(id = R.drawable.geode_monochrome),
contentDescription = null,
modifier = Modifier
.size(64.dp, 64.dp),
)
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
fontSize = 64.sp,
modifier = Modifier
.padding(12.dp)
)
}
}
@Composable
fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Unit) {
val context = LocalContext.current
val resources = LocalResources.current
val releaseState = ReleaseManager.get(context).uiState.value
if (releaseState !is ReleaseManager.ReleaseManagerState.Failure) {
return
}
val message = when (releaseState.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet_short)
is ReleaseManager.UpdateException -> {
when (releaseState.exception.reason) {
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_external_in_use_short
)
else -> stringResource(R.string.release_fetch_generic_short)
}
}
is CancellationException -> null
else -> stringResource(R.string.release_fetch_generic_short)
}
if (message == null) {
return
}
LaunchedEffect(message) {
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = resources.getString(R.string.release_fetch_button_retry),
duration = SnackbarDuration.Indefinite,
withDismissAction = true
)
when (result) {
SnackbarResult.ActionPerformed -> {
onRetry()
}
else -> {}
}
}
}
@Composable
fun AltMainScreen(
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember { mutableStateOf(false) }
var showErrorInfo by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
GeodeLogo()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LaunchProgressCard(
launchUIState,
crashInfo = launchViewModel.currentCrashInfo(),
onCancel = {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
},
onResume = { safeMode ->
launchInSafeMode = safeMode
launchViewModel.clearCrashInfo()
coroutineScope.launch {
launchViewModel.beginLaunchFlow(true)
}
},
onMore = {
showErrorInfo = true
},
extraOptions = {
ExtraOptions(
onSettings = {
coroutineScope.launch {
launchViewModel.cancelLaunch(true)
}
onSettings(context)
}
)
},
safeModeEnabled = launchInSafeMode
)
// only show launcher update in a case where a user won't see it ingame
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
if (nextLauncherUpdate != null) {
LauncherUpdateIndicator()
}
GeodeUpdateIndicator(snackbarHostState) {
launchViewModel.retryUpdate()
}
}
}
}
}
}
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
UpdateWarning(launchInSafeMode) {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
}
}
val loadFailure = launchViewModel.currentCrashInfo()
if (showErrorInfo && loadFailure != null) {
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
}
}
@@ -415,8 +415,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
this.mGLSurfaceView = glSurfaceView
frameLayout.addView(this.mGLSurfaceView)
val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
if (showNotification) {
val disableNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DISABLE_REDESIGN)
if (!disableNotification) {
val hasCards = determineDisplayedCards(this)
if (hasCards.isNotEmpty()) {
val notificationView = ComposeView(this)
@@ -0,0 +1,261 @@
package com.geode.launcher
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.main.*
import com.geode.launcher.updater.ReleaseViewModel
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.ui.theme.launcherTitleStyle
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.GamePackageUtils
class LegacyMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
clearDownloadedApks(this)
super.onCreate(savedInstanceState)
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@Suppress("DEPRECATION") // the new api is android 13 only (why)
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
as? LaunchUtils.LauncherError
val loadFailureInfo = if (returnError != null && returnMessage != null) {
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
} else if (LaunchUtils.lastSessionCrashed(this)) {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null }
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainScreen(gdInstalled, geodeInstalled, loadFailureInfo)
}
}
}
}
if (gdInstalled && geodeInstalled) {
intent.getBooleanExtra("restarted", false).let {
if (it) {
onLaunch(this)
}
}
}
}
}
@Composable
fun MainScreen(
gdInstalled: Boolean = true,
geodePreinstalled: Boolean = true,
loadFailureInfo: LoadFailureInfo? = null,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val context = LocalContext.current
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
val autoUpdateState by releaseViewModel.uiState.collectAsState()
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
?.hasUpdated == true
val geodeInstalled = geodePreinstalled || geodeJustInstalled
var beginLaunch by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
LaunchedEffect(shouldUpdate) {
if (shouldUpdate && !releaseViewModel.hasPerformedCheck && gdInstalled) {
releaseViewModel.runReleaseCheck()
} else {
releaseViewModel.useGlobalCheckState()
}
}
val snackbarHostState = remember { SnackbarHostState() }
// automatically show load failed dialog (but not crash)
var showErrorInfo by remember { mutableStateOf(
loadFailureInfo != null && loadFailureInfo.title != LaunchUtils.LauncherError.CRASHED
)}
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(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.geode_logo),
contentDescription = stringResource(R.string.launcher_logo_alt),
modifier = Modifier.size(100.dp, 100.dp)
)
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
modifier = Modifier.padding(12.dp)
)
when {
gdInstalled && geodeInstalled -> {
val gdVersion = remember {
GamePackageUtils.getGameVersionCode(context.packageManager)
}
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN) {
val versionName = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
}
LaunchBlockedLabel(stringResource(R.string.game_outdated, versionName))
} else {
val stopLaunch = releaseViewModel.isInUpdate || hasError
PlayButton(
stopAutomaticLaunch = stopLaunch,
blockLaunch = releaseViewModel.isInUpdate,
onPlayGame = { safeMode ->
launchInSafeMode = safeMode
beginLaunch = true
},
)
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
}
}
gdInstalled -> {
Text(
stringResource(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 = stringResource(R.string.launcher_download_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_download))
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
}
else -> LaunchBlockedLabel(stringResource(R.string.game_not_found))
}
UpdateCard(
releaseViewModel,
modifier = Modifier
.padding(12.dp)
)
}
}
if (beginLaunch) {
UpdateWarning(launchInSafeMode) {
beginLaunch = false
launchInSafeMode = false
}
}
}
@Preview(showSystemUi = true)
@Composable
fun MainScreenLightPreview() {
GeodeLauncherTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MainScreen()
}
}
}
@Preview(showSystemUi = true)
@Composable
fun MainScreenDarkPreview() {
GeodeLauncherTheme(theme = Theme.DARK) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MainScreen()
}
}
}
@Preview
@Composable
fun MainScreenNoGeometryDashPreview() {
GeodeLauncherTheme {
MainScreen(gdInstalled = false)
}
}
@@ -2,14 +2,40 @@ package com.geode.launcher
import android.content.Intent
import android.os.Bundle
import android.text.format.Formatter
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
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
@@ -17,34 +43,40 @@ 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
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.main.*
import com.geode.launcher.updater.ReleaseViewModel
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.ui.theme.launcherTitleStyle
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 com.geode.launcher.utils.GamePackageUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import java.net.ConnectException
import java.net.UnknownHostException
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
clearDownloadedApks(this)
super.onCreate(savedInstanceState)
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
@@ -61,9 +93,9 @@ class MainActivity : ComponentActivity() {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null }
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DISABLE_REDESIGN)
if (redirectToAlt) {
val launchIntent = Intent(this, AltMainActivity::class.java)
val launchIntent = Intent(this, LegacyMainActivity::class.java)
launchIntent.putExtras(intent)
startActivity(launchIntent)
@@ -77,17 +109,23 @@ class MainActivity : ComponentActivity() {
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
if (loadFailureInfo != null) {
launchViewModel.loadFailure = loadFailureInfo
}
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
modifier = Modifier.fillMaxSize()
) {
MainScreen(gdInstalled, geodeInstalled, loadFailureInfo)
AltMainScreen(launchViewModel)
}
}
}
}
if (gdInstalled && geodeInstalled) {
intent.getBooleanExtra("restarted", false).let {
if (it) {
@@ -98,174 +136,465 @@ class MainActivity : ComponentActivity() {
}
}
data class LaunchStatusInfo(
val title: String?,
val details: String? = null,
val progress: (() -> Float)? = null
)
@Composable
fun MainScreen(
gdInstalled: Boolean = true,
geodePreinstalled: Boolean = true,
loadFailureInfo: LoadFailureInfo? = null,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val context = LocalContext.current
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
when (cancelReason) {
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
}
}
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
val autoUpdateState by releaseViewModel.uiState.collectAsState()
@Composable
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)
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
title = null
)
/*
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
)
*/
}
}
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
?.hasUpdated == true
val geodeInstalled = geodePreinstalled || geodeJustInstalled
@Composable
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
return when (state) {
is LaunchViewModel.LaunchUIState.Initial,
is LaunchViewModel.LaunchUIState.Working,
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
title = stringResource(R.string.launcher_starting_game),
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
)
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
title = stringResource(R.string.release_fetch_in_progress)
)
is LaunchViewModel.LaunchUIState.Updating -> {
val context = LocalContext.current
var beginLaunch by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
LaunchedEffect(shouldUpdate) {
if (shouldUpdate && !releaseViewModel.hasPerformedCheck && gdInstalled) {
releaseViewModel.runReleaseCheck()
} else {
releaseViewModel.useGlobalCheckState()
val outOf = remember(state.outOf) {
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
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()
}.takeIf { state.outOf != null }
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
}
}
}
@Composable
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
return
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
icon()
Spacer(Modifier.size(8.dp))
Text(statusInfo.title)
}
}
}
if (inProgress) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(
statusInfo.details,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
val snackbarHostState = remember { SnackbarHostState() }
}
// automatically show load failed dialog (but not crash)
var showErrorInfo by remember { mutableStateOf(
loadFailureInfo != null && loadFailureInfo.title != LaunchUtils.LauncherError.CRASHED
)}
@Composable
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
}
val hasError = loadFailureInfo != null
LaunchedEffect(hasError, showErrorInfo) {
if (!showErrorInfo && loadFailureInfo != null) {
showFailureSnackbar(context, loadFailureInfo, snackbarHostState) {
showErrorInfo = true
if (statusInfo.progress != null) {
LinearProgressIndicator(
statusInfo.progress,
modifier = Modifier.fillMaxWidth()
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
when (reason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
Icon(
painterResource(R.drawable.icon_resume),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
painterResource(R.drawable.icon_play_arrow),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
Icon(
painterResource(R.drawable.icon_refresh),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_retry))
}
// these shouldn't be allowing retries anyways
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LaunchProgressCard(
uiState: LaunchViewModel.LaunchUIState,
crashInfo: LoadFailureInfo?,
onCancel: () -> Unit,
onResume: (safeMode: Boolean) -> Unit,
onMore: () -> Unit,
extraOptions: @Composable () -> Unit,
safeModeEnabled: Boolean,
modifier: Modifier = Modifier
) {
var showSafeModeDialog by remember { mutableStateOf(false) }
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
Box(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
inProgress = uiState.inProgress
)
if (uiState.reason.isGameInstallIssue()) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
if (showDownload) {
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
}
}
if (uiState.reason.allowsRetry()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
LongPressButton(onClick = { onResume(false) }, onLongPress = {
showSafeModeDialog = true
}) {
RetryButtonContents(uiState.reason)
}
if (crashInfo != null) {
FilledTonalButton(onClick = onMore) {
Icon(
painterResource(R.drawable.icon_question_mark),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_help_alt))
}
}
extraOptions()
}
}
} else {
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
Spacer(Modifier.weight(1.0f))
extraOptions()
}
}
}
}
if (showErrorInfo && loadFailureInfo != null) {
ErrorInfoSheet(loadFailureInfo, onDismiss = { showErrorInfo = false })
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
showSafeModeDialog = false
},
onLaunch = {
onResume(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun ExtraOptions(onSettings: () -> Unit) {
IconButton(
onClick = onSettings,
) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
@Composable
fun GeodeLogo(modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Icon(
painter = painterResource(id = R.drawable.geode_monochrome),
contentDescription = null,
modifier = Modifier
.size(64.dp, 64.dp)
)
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
fontSize = 64.sp,
modifier = Modifier
.padding(12.dp)
)
}
}
@Composable
fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Unit) {
val context = LocalContext.current
val resources = LocalResources.current
val releaseState = ReleaseManager.get(context).uiState.value
if (releaseState !is ReleaseManager.ReleaseManagerState.Failure) {
return
}
val message = when (releaseState.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet_short)
is ReleaseManager.UpdateException -> {
when (releaseState.exception.reason) {
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_external_in_use_short
)
else -> stringResource(R.string.release_fetch_generic_short)
}
}
is CancellationException -> null
else -> stringResource(R.string.release_fetch_generic_short)
}
if (message == null) {
return
}
LaunchedEffect(message) {
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = resources.getString(R.string.release_fetch_button_retry),
duration = SnackbarDuration.Indefinite,
withDismissAction = true
)
when (result) {
SnackbarResult.ActionPerformed -> {
onRetry()
}
else -> {}
}
}
}
@Composable
fun AltMainScreen(
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember { mutableStateOf(false) }
var showErrorInfo by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { innerPadding ->
Column(
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
.safeDrawingPadding()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.geode_logo),
contentDescription = stringResource(R.string.launcher_logo_alt),
modifier = Modifier.size(100.dp, 100.dp)
)
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
modifier = Modifier.padding(12.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
GeodeLogo()
when {
gdInstalled && geodeInstalled -> {
val gdVersion = remember {
GamePackageUtils.getGameVersionCode(context.packageManager)
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LaunchProgressCard(
launchUIState,
crashInfo = launchViewModel.currentCrashInfo(),
onCancel = {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
},
onResume = { safeMode ->
launchInSafeMode = safeMode
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN) {
val versionName = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
}
launchViewModel.clearCrashInfo()
coroutineScope.launch {
launchViewModel.beginLaunchFlow(true)
}
},
onMore = {
showErrorInfo = true
},
extraOptions = {
ExtraOptions(
onSettings = {
coroutineScope.launch {
launchViewModel.cancelLaunch(true)
}
LaunchBlockedLabel(stringResource(R.string.game_outdated, versionName))
} else {
val stopLaunch = releaseViewModel.isInUpdate || hasError
PlayButton(
stopAutomaticLaunch = stopLaunch,
blockLaunch = releaseViewModel.isInUpdate,
onPlayGame = { safeMode ->
launchInSafeMode = safeMode
beginLaunch = true
},
)
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
}
}
gdInstalled -> {
Text(
stringResource(R.string.geode_download_title),
modifier = Modifier.padding(12.dp)
onSettings(context)
}
)
},
safeModeEnabled = launchInSafeMode
)
Row {
Button(
onClick = { releaseViewModel.runReleaseCheck(true) },
enabled = !releaseViewModel.isInUpdate
) {
Icon(
painterResource(R.drawable.icon_download),
contentDescription = stringResource(R.string.launcher_download_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_download))
// only show launcher update in a case where a user won't see it ingame
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
if (nextLauncherUpdate != null) {
LauncherUpdateIndicator()
}
GeodeUpdateIndicator(snackbarHostState) {
launchViewModel.retryUpdate()
}
}
}
else -> LaunchBlockedLabel(stringResource(R.string.game_not_found))
}
UpdateCard(
releaseViewModel,
modifier = Modifier
.padding(12.dp)
)
}
}
if (beginLaunch) {
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
UpdateWarning(launchInSafeMode) {
beginLaunch = false
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
}
}
}
@Preview(showSystemUi = true)
@Composable
fun MainScreenLightPreview() {
GeodeLauncherTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MainScreen()
}
}
}
@Preview(showSystemUi = true)
@Composable
fun MainScreenDarkPreview() {
GeodeLauncherTheme(theme = Theme.DARK) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MainScreen()
}
}
}
@Preview
@Composable
fun MainScreenNoGeometryDashPreview() {
GeodeLauncherTheme {
MainScreen(gdInstalled = false)
val loadFailure = launchViewModel.currentCrashInfo()
if (showErrorInfo && loadFailure != null) {
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
}
}
@@ -18,8 +18,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
private const val COOLDOWN_LENGTH_MS = 3000L
class LaunchViewModel(private val application: Application): ViewModel() {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
@@ -37,7 +35,13 @@ class LaunchViewModel(private val application: Application): ViewModel() {
private var readyTimer: CountDownTimer? = null
private fun initReadyTimer() {
readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) {
val cooldownLength = PreferenceUtils.get(application).getInt(PreferenceUtils.Key.WAIT_PERIOD) * 1000L
if (cooldownLength == 0L) {
readyTimerPassed = true
return
}
readyTimer = object : CountDownTimer(cooldownLength, 1000) {
override fun onTick(millisUntilFinished: Long) {
// no tick necessary
}
@@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -208,9 +209,21 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
}
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
SettingsSelectCard(
title = stringResource(R.string.preference_launch_delay),
dialogTitle = stringResource(R.string.preference_launch_delay_select),
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
options = linkedMapOf(
0 to stringResource(R.string.preference_launch_delay_instant),
1 to pluralStringResource(R.plurals.preference_launch_delay_value, 1, 1),
3 to pluralStringResource(R.plurals.preference_launch_delay_value, 3, 3),
5 to pluralStringResource(R.plurals.preference_launch_delay_value, 5, 5),
)
)
SettingsCard(
title = stringResource(R.string.preference_enable_redesign),
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
title = stringResource(R.string.preference_disable_redesign),
preferenceKey = PreferenceUtils.Key.DISABLE_REDESIGN,
)
}
@@ -139,7 +139,6 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
LIMIT_ASPECT_RATIO,
DISPLAY_MODE,
FORCE_HRR,
ENABLE_REDESIGN,
RELEASE_CHANNEL_TAG,
DEVELOPER_MODE,
CLEANUP_APKS,
@@ -149,7 +148,9 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
SCREEN_ZOOM_FIT,
LIMIT_FRAME_RATE,
DISABLE_USER_THEME,
SELECTED_ICON
SELECTED_ICON,
DISABLE_REDESIGN,
WAIT_PERIOD
}
private fun defaultValueForBooleanKey(key: Key): Boolean {
@@ -163,6 +164,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
private fun defaultValueForIntKey(key: Key) = when (key) {
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
Key.SCREEN_ZOOM -> 100
Key.WAIT_PERIOD -> 3
// people wanted a reset on nightly anyways, so this is a good excuse to do so
else -> 0
}
@@ -189,7 +191,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
Key.ENABLE_REDESIGN -> "PreferenceEnableRedesign"
Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
@@ -200,6 +202,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
Key.WAIT_PERIOD -> "PreferenceWaitPeriod"
}
}
+9
View File
@@ -146,6 +146,15 @@
<string name="preference_set_application_icon">Application icon</string>
<string name="preference_force_hrr">Force maximum refresh rate</string>
<string name="preference_enable_redesign">✨ Enable UI refresh</string>
<string name="preference_disable_redesign">Disable UI refresh</string>
<string name="preference_launch_delay">Launch delay</string>
<string name="preference_launch_delay_select">Select launch delay</string>
<string name="preference_launch_delay_instant">None</string>
<plurals name="preference_launch_delay_value">
<item quantity="one">%1$d second</item>
<item quantity="other">%1$d seconds</item>
</plurals>
<string name="preference_display_mode_name">Display mode</string>
<string name="preference_display_mode_select">Select display mode</string>