Implement built in updater (#6)

* implement release check api

* add options for updater

* fix settings scroll

* minor preference reorganization

* implement initial release properties

* add downloading code

* change download api

* add extraction code

* fix modifier padding

* update geode status after update

* set stored release after update

* disable updater by default for armv8

* add settings toasts

* use snackbar instead of toast

* small cleanup

* remove unused constant

* implement stable channel

* child doesn't require viewmodel

* update release format

* enable updater by default for all archs

* modify updater to be global

* fix copyfile reference

* correct state ordering issue

* only allow one update at a time

* show message in settings on second update

* allow update cancellation and retry

* disable start until update finished

* fix countdown lifecycle

* always sync release state to main activity

* allow scrolling main activity

* use a library for internal downloads
mat allowed me to

* show download progress in settings

* no internet messages

* enable api request cache

* delete old library before extracting

* oops....
This commit is contained in:
Chloe authored and GitHub committed 2024-01-06 15:20:21 -07:00
1 parent c421e37e1a
commit 4c555881b5
17 files changed
+1184 -209

No files matched your search

+7
View File
@@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
}
android {
@@ -65,10 +66,16 @@ dependencies {
implementation 'androidx.compose.material3:material3:1.1.2'
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2"
implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation "com.squareup.okio:okio:3.7.0"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.5.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.0"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
}
@@ -1,51 +0,0 @@
package com.geode.launcher
import android.content.Context
import android.content.Intent
import android.os.AsyncTask
import android.util.Log
import com.geode.launcher.MainActivity
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipInputStream
class DownloadGeode constructor(context: Context) : AsyncTask<String, Int, Boolean>() {
private val context: Context = context
override fun doInBackground(vararg params: String?): Boolean {
val url = URL(params[0])
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connect()
// downloads a zip file
ZipInputStream(connection.inputStream).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val file = entry.name
context.getExternalFilesDir(null)?.let { dir->
val destFile = File(dir, file)
destFile.outputStream().use { output ->
zip.copyTo(output)
Log.d("Geode", "Downloading to $destFile")
}
}
entry = zip.nextEntry
}
}
return true
}
override fun onPostExecute(result: Boolean) {
super.onPostExecute(result)
Log.d("Geode", "Downloading Result: $result")
// update the screen
val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}
}
@@ -23,8 +23,10 @@ import androidx.core.view.WindowInsetsControllerCompat
import com.customRobTop.BaseRobTopActivity
import com.customRobTop.JniToCpp
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils
import org.cocos2dx.lib.Cocos2dxEditText
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
import org.cocos2dx.lib.Cocos2dxHelper
@@ -33,8 +35,6 @@ import org.fmod.FMOD
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener {
@@ -149,7 +149,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
// there doesn't seem to be a way to load a library from a file descriptor
val libraryCopy = File(cacheDir, "lib$libraryName.so")
val libraryOutput = libraryCopy.outputStream()
copyFile(libraryFd.createInputStream(), libraryOutput)
DownloadUtils.copyFile(libraryFd.createInputStream(), libraryOutput)
System.load(libraryCopy.path)
@@ -190,7 +190,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return true
} catch (e: UnsatisfiedLinkError) {
// but users may prefer it stored with data
val geodePath = File(filesDir.path, "launcher/Geode.so")
val geodeFilename = LaunchUtils.getGeodeFilename()
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
if (geodePath.exists()) {
System.load(geodePath.path)
return true
@@ -211,7 +212,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val geodePath = File(copiedPath.path, "Geode.so")
if (externalGeodePath.exists()) {
copyFile(FileInputStream(externalGeodePath), FileOutputStream(geodePath))
DownloadUtils.copyFile(
FileInputStream(externalGeodePath),
FileOutputStream(geodePath)
)
if (geodePath.exists()) {
try {
@@ -395,9 +399,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
}
private fun getLoadTesting(): Boolean {
val preferences = getSharedPreferences(getString(R.string.preference_file_key), Context.MODE_PRIVATE)
return preferences.getBoolean(getString(R.string.preference_load_testing), false)
val preferences = PreferenceUtils.get(this)
return preferences.getBoolean(PreferenceUtils.Key.LOAD_TESTING)
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
@@ -416,7 +419,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
if (it.isFile) {
// welcome to the world of Android classloader permissions
val outputFile = File(testDirPath.path + File.separator + it.name)
copyFile(FileInputStream(it), FileOutputStream(outputFile))
DownloadUtils.copyFile(
FileInputStream(it),
FileOutputStream(outputFile)
)
try {
println("Loading test library ${outputFile.name}")
@@ -428,23 +434,4 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
}
}
}
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
// gotta love copying
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FileUtils.copy(inputStream, outputStream)
} else {
inputStream.use { input ->
outputStream.use { output ->
val buffer = ByteArray(4 * 1024)
while (true) {
val byteCount = input.read(buffer)
if (byteCount < 0) break
output.write(buffer, 0, byteCount)
}
output.flush()
}
}
}
}
}
@@ -3,28 +3,41 @@ package com.geode.launcher
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.Formatter.formatShortFileSize
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.api.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.useCountdownTimer
import com.geode.launcher.utils.usePreference
import java.net.ConnectException
import java.net.UnknownHostException
class MainActivity : ComponentActivity() {
@@ -51,18 +64,149 @@ class MainActivity : ComponentActivity() {
}
}
}
/*
if (gdInstalled && !geodeInstalled) {
downloadGeode(this)
}
*/
}
}
fun downloadGeode(context: Context) {
// download geode in the background and update the screen when it's done
DownloadGeode(context).execute(Constants.GEODE_DOWNLOAD_LINK)
@Composable
fun UpdateProgressIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
progress: Float? = null
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(message)
Spacer(modifier = Modifier.padding(4.dp))
if (progress == null) {
LinearProgressIndicator()
} else {
LinearProgressIndicator(progress)
}
TextButton(
onClick = {
releaseViewModel.cancelUpdate()
},
modifier = Modifier.offset((-12).dp)
) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
}
}
@Composable
fun UpdateMessageIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
allowRetry: Boolean = false
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
message,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.padding(4.dp))
if (allowRetry) {
OutlinedButton(
onClick = {
releaseViewModel.runReleaseCheck()
},
) {
Text(stringResource(R.string.release_fetch_button_retry))
}
}
}
}
@Composable
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val releaseState by releaseViewModel.uiState.collectAsState()
when (val state = releaseState) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (state.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet)
else -> state.exception.message
}
UpdateMessageIndicator(
stringResource(R.string.release_fetch_failed, message ?: ""),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
val progress = state.downloaded / state.outOf.toDouble()
val downloaded = remember(state.downloaded) {
formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
formatShortFileSize(context, state.outOf)
}
UpdateProgressIndicator(
stringResource(
R.string.release_fetch_downloading,
downloaded,
outOf
),
modifier = modifier,
releaseViewModel = releaseViewModel,
progress = progress.toFloat()
)
}
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_in_progress),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (state.hasUpdated) {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_success),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
}
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
if (state.isCancelling) {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_cancelling),
modifier = modifier,
releaseViewModel = releaseViewModel
)
} else {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_cancelled),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
}
}
}
fun onLaunch(context: Context) {
@@ -78,16 +222,37 @@ fun onSettings(context: Context) {
}
@Composable
fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
fun MainScreen(
gdInstalled: Boolean = true,
geodePreinstalled: Boolean = true,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val context = LocalContext.current
val shouldAutomaticallyLaunch = usePreference(
preferenceFileKey = R.string.preference_file_key,
preferenceId = R.string.preference_load_automatically
val shouldAutomaticallyLaunch = PreferenceUtils.useBooleanPreference(
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
val autoUpdateState by releaseViewModel.uiState.collectAsState()
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
?.hasUpdated ?: false
val geodeInstalled = geodePreinstalled || geodeJustInstalled
LaunchedEffect(shouldUpdate) {
if (shouldUpdate && !releaseViewModel.hasPerformedCheck) {
releaseViewModel.runReleaseCheck()
} else {
releaseViewModel.useGlobalCheckState()
}
}
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -101,8 +266,9 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
fontSize = 32.sp,
modifier = Modifier.padding(12.dp)
)
if (gdInstalled && geodeInstalled) {
if (shouldAutomaticallyLaunch.value) {
if (shouldAutomaticallyLaunch.value && !releaseViewModel.isInUpdate) {
val countdownTimer = useCountdownTimer(
time = 3000,
onCountdownFinish = {
@@ -113,10 +279,12 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
}
)
if (countdownTimer.value != 0) {
if (countdownTimer != 0L) {
Text(
context.resources.getQuantityString(
R.plurals.automatically_load_countdown, countdownTimer.value, countdownTimer.value
pluralStringResource(
R.plurals.automatically_load_countdown,
countdownTimer.toInt(),
countdownTimer
),
style = Typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
@@ -126,7 +294,10 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
}
Row {
Button(onClick = { onLaunch(context) }) {
Button(
onClick = { onLaunch(context) },
enabled = !releaseViewModel.isInUpdate
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
@@ -169,6 +340,11 @@ fun MainScreen(gdInstalled: Boolean = true, geodeInstalled: Boolean = true) {
Text(context.getString(R.string.launcher_settings))
}
}
UpdateCard(
releaseViewModel,
modifier = Modifier.padding(12.dp)
)
}
}
@@ -9,26 +9,36 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.api.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.PreferenceUtils
import java.net.ConnectException
import java.net.UnknownHostException
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -78,12 +88,89 @@ fun onOpenFolder(context: Context) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
fun UpdateIndicator(
snackbarHostState: SnackbarHostState,
updateStatus: ReleaseViewModel.ReleaseUIState
) {
val context = LocalContext.current
var enablePopup by remember { mutableStateOf(false) }
when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
enablePopup = true
CircularProgressIndicator()
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
// is this the ideal design? idk
enablePopup = true
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
CircularProgressIndicator(progress.toFloat())
}
else -> {}
}
LaunchedEffect(updateStatus) {
// only show popup if some progress was shown
if (!enablePopup) {
return@LaunchedEffect
}
when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (updateStatus.exception) {
is UnknownHostException, is ConnectException ->
context.getString(R.string.release_fetch_no_internet)
else -> context.getString(R.string.preference_check_for_updates_failed)
}
snackbarHostState.showSnackbar(message)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (updateStatus.hasUpdated) {
snackbarHostState.showSnackbar(
context.getString(R.string.preference_check_for_updates_success)
)
} else {
snackbarHostState.showSnackbar(
context.getString(R.string.preference_check_for_updates_none_found)
)
}
}
else -> {}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val context = LocalContext.current
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
val updateStatus by releaseViewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showUpdateInProgress by remember { mutableStateOf(false) }
if (showUpdateInProgress) {
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
context.getString(R.string.preference_check_for_updates_already_updating)
)
showUpdateInProgress = false
}
}
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
navigationIcon = {
@@ -107,18 +194,19 @@ fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth(),
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OptionsGroup(context.getString(R.string.preference_category_testing)) {
SettingsCard(
title = context.getString(R.string.preference_load_testing_name),
preferenceId = R.string.preference_load_testing
preferenceKey = PreferenceUtils.Key.LOAD_TESTING,
)
SettingsCard(
title = context.getString(R.string.preference_load_automatically_name),
description = context.getString(R.string.preference_load_automatically_description),
preferenceId = R.string.preference_load_automatically
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
)
OptionsButton(
title = context.getString(R.string.preferences_copy_external_button),
@@ -126,6 +214,41 @@ fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
onClick = { onOpenFolder(context) }
)
}
OptionsGroup(context.getString(R.string.preference_category_updater)) {
SettingsCard(
title = context.getString(R.string.preference_update_automatically_name),
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
)
SettingsCard(
title = context.getString(R.string.preference_release_channel_name),
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL,
)
OptionsCard(
title = {
OptionsTitle(
title = stringResource(R.string.preference_check_for_updates_button),
description = stringResource(
R.string.preference_check_for_updates_description,
currentRelease ?: "unknown"
)
)
},
modifier = Modifier
.clickable(
onClick = {
if (releaseViewModel.isInUpdate) {
showUpdateInProgress = true
} else {
releaseViewModel.runReleaseCheck()
}
},
role = Role.Button
)
) {
UpdateIndicator(snackbarHostState, updateStatus)
}
}
/*
OptionsGroup("Data") {
OptionsButton(
@@ -139,28 +262,16 @@ fun SettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
)
}
fun toggleSetting(context: Context, @StringRes preferenceId: Int): Boolean {
val preferences = context.getSharedPreferences(
context.getString(R.string.preference_file_key), Context.MODE_PRIVATE
)
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
val current = preferences.getBoolean(
context.getString(preferenceId), false
)
preferences.edit {
putBoolean(context.getString(preferenceId), !current)
commit()
}
return preferences.getBoolean(context.getString(preferenceId), false)
return preferences.toggleBoolean(preferenceKey)
}
fun getSetting(context: Context, @StringRes preferenceId: Int): Boolean {
val preferences = context.getSharedPreferences(
context.getString(R.string.preference_file_key), Context.MODE_PRIVATE
)
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
return preferences.getBoolean(context.getString(preferenceId), false)
return preferences.getBoolean(preferenceKey)
}
@Composable
@@ -190,10 +301,11 @@ fun OptionsButton(title: String, description: String? = null, onClick: () -> Uni
}
@Composable
fun SettingsCard(title: String, description: String? = null, @StringRes preferenceId: Int) {
fun SettingsCard(title: String, description: String? = null, preferenceKey: PreferenceUtils.Key) {
val context = LocalContext.current
val settingEnabled = remember {
mutableStateOf(getSetting(context, preferenceId)) }
mutableStateOf(getSetting(context, preferenceKey))
}
OptionsCard(
title = {
@@ -205,7 +317,7 @@ fun SettingsCard(title: String, description: String? = null, @StringRes preferen
},
modifier = Modifier.toggleable(
value = settingEnabled.value,
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceId) },
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
role = Role.Switch,
)
) {
@@ -253,11 +365,11 @@ fun OptionsCardPreview() {
SettingsCard(
title = "Load files from /test",
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
preferenceId = R.string.preference_load_testing
preferenceKey = PreferenceUtils.Key.LOAD_TESTING
)
SettingsCard(
title = "Testing option 2",
preferenceId = R.string.preference_load_automatically
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
}
}
@@ -0,0 +1,56 @@
package com.geode.launcher.api
import com.geode.launcher.utils.LaunchUtils
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class Asset(
val url: String,
val id: Int,
val name: String,
val size: Int,
val createdAt: Instant,
val updatedAt: Instant,
val browserDownloadUrl: String,
)
@Serializable
class Release(
val url: String,
val id: Int,
val targetCommitish: String,
val tagName: String,
val createdAt: Instant,
val publishedAt: Instant,
val assets: List<Asset>
) {
fun getDescription(): String {
if (tagName == "nightly") {
// get the commit from the assets
// otherwise, a separate request is needed to get the hash (ew)
val asset = assets.first()
val commit = asset.name.substring(6..12)
return "nightly-$commit"
}
return tagName
}
fun getDescriptor(): Long {
return createdAt.epochSeconds
}
fun getAndroidDownload(): Asset? {
// try to find an asset that matches the architecture first
val architecture = LaunchUtils.getApplicationArchitecture()
val platform = if (architecture == "arm64-v8a")
"android64" else "android32"
val releaseSuffix = "$platform.zip"
return assets.find {
it.name.endsWith(releaseSuffix)
}
}
}
@@ -0,0 +1,66 @@
package com.geode.launcher.api
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.net.URL
class ReleaseRepository(private val httpClient: OkHttpClient) {
companion object {
private const val GITHUB_API_BASE = "https://api.github.com/repos/geode-sdk/geode"
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
private const val GITHUB_API_VERSION = "2022-11-28"
}
suspend fun getLatestNightlyRelease(): Release? {
val nightlyPath = "$GITHUB_API_BASE/releases/tags/nightly"
val url = URL(nightlyPath)
return getReleaseByUrl(url)
}
suspend fun getLatestRelease(): Release? {
val releasePath = "$GITHUB_API_BASE/releases/latest"
val url = URL(releasePath)
return getReleaseByUrl(url)
}
@OptIn(ExperimentalSerializationApi::class)
private suspend fun getReleaseByUrl(url: URL): Release? {
val request = Request.Builder()
.url(url)
.addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.build()
val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
}
val release = format.decodeFromBufferedSource<Release>(
response.body!!.source()
)
release
}
404 -> {
null
}
else -> {
throw IOException("unknown response ${response.code}")
}
}
}
}
@@ -0,0 +1,99 @@
package com.geode.launcher.api
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.utils.ReleaseManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class ReleaseViewModel(private val application: Application): ViewModel() {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as Application
ReleaseViewModel(
application = application
)
}
}
}
sealed class ReleaseUIState {
data object InUpdateCheck : ReleaseUIState()
data class Failure(val exception: Exception) : ReleaseUIState()
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseUIState()
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
companion object {
fun managerStateToUI(state: ReleaseManager.ReleaseManagerState): ReleaseUIState {
return when (state) {
is ReleaseManager.ReleaseManagerState.InUpdateCheck -> InUpdateCheck
is ReleaseManager.ReleaseManagerState.Failure -> Failure(state.exception)
is ReleaseManager.ReleaseManagerState.InDownload ->
InDownload(state.downloaded, state.outOf)
is ReleaseManager.ReleaseManagerState.Finished -> Finished(state.hasUpdated)
}
}
}
}
private val _uiState = MutableStateFlow<ReleaseUIState>(ReleaseUIState.Finished())
val uiState = _uiState.asStateFlow()
val isInUpdate
get() = ReleaseManager.get(application).isInUpdate
var hasPerformedCheck = false
private set
fun cancelUpdate() {
viewModelScope.launch {
_uiState.value = ReleaseUIState.Cancelled(true)
ReleaseManager.get(application).cancelUpdate()
_uiState.value = ReleaseUIState.Cancelled()
}
}
private suspend fun syncUiState(
flow: StateFlow<ReleaseManager.ReleaseManagerState>
) {
flow.map(ReleaseUIState::managerStateToUI).collect {
// send the mapped state to the ui
_uiState.value = it
}
}
fun useGlobalCheckState() {
hasPerformedCheck = true
viewModelScope.launch {
val releaseFlow = ReleaseManager.get(application)
.uiState
syncUiState(releaseFlow)
}
}
fun runReleaseCheck() {
hasPerformedCheck = true
viewModelScope.launch {
val releaseFlow = ReleaseManager.get(application)
.checkForUpdates()
syncUiState(releaseFlow)
}
}
}
@@ -12,6 +12,4 @@ object Constants {
// this value is hardcoded into GD
@SuppressLint("SdCardPath")
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
const val GEODE_DOWNLOAD_LINK = "https://nightly.link/geode-sdk/geode/workflows/build/1.4.0-dev/geode-android.zip"
}
@@ -0,0 +1,175 @@
package com.geode.launcher.utils
import android.os.Build
import android.os.FileUtils
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Source
import okio.buffer
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
object DownloadUtils {
suspend fun downloadStream(
httpClient: OkHttpClient,
url: String,
onProgress: ProgressCallback? = null
): InputStream {
val request = Request.Builder()
.url(url)
.build()
// build a new client using the same pool as the old client
// (more efficient)
val progressClientBuilder = httpClient.newBuilder()
.cache(null)
// disable timeout
.readTimeout(0, TimeUnit.SECONDS)
// add progress listener
if (onProgress != null) {
progressClientBuilder.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(
originalResponse.body!!, onProgress
))
.build()
}
}
val progressClient = progressClientBuilder.build()
val call = progressClient.newCall(request)
val response = call.executeCoroutine()
return response.body!!.byteStream()
}
suspend fun Call.executeCoroutine(): Response {
return suspendCancellableCoroutine { continuation ->
this.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) {
return
}
continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
continuation.resume(response)
}
})
continuation.invokeOnCancellation {
this.cancel()
}
}
}
suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) {
// note to self: ZipInputStreams are a little silly
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
runInterruptible {
inputStream.use {
outputStream.use {
// nice indentation
val zip = ZipInputStream(inputStream)
zip.use {
var entry = it.nextEntry
while (entry != null) {
if (entry.name == zipPath) {
it.copyTo(outputStream)
return@use
}
entry = it.nextEntry
}
// no matching entries found, throw exception
throw IOException("no file found for $zipPath")
}
}
}
}
}
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
// gotta love copying
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FileUtils.copy(inputStream, outputStream)
} else {
inputStream.use { input ->
outputStream.use { output ->
val buffer = ByteArray(4 * 1024)
while (true) {
val byteCount = input.read(buffer)
if (byteCount < 0) break
output.write(buffer, 0, byteCount)
}
output.flush()
}
}
}
}
}
// based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java>
// lazily ported to kotlin
private class ProgressResponseBody (
private val responseBody: ResponseBody,
private val progressCallback: ProgressCallback
) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType? {
return responseBody.contentType()
}
override fun contentLength(): Long {
return responseBody.contentLength()
}
override fun source(): BufferedSource {
return bufferedSource
}
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead = 0L
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressCallback(
totalBytesRead,
responseBody.contentLength()
)
return bytesRead
}
}
}
}
@@ -25,9 +25,13 @@ object LaunchUtils {
return Build.CPU_ABI
}
fun getGeodeFilename(): String {
val abi = getApplicationArchitecture()
return "Geode.$abi.so"
}
fun getInstalledGeodePath(context: Context): File? {
val arch = getApplicationArchitecture()
val geodeName = "Geode.$arch.so"
val geodeName = getGeodeFilename()
val internalGeodePath = File(context.filesDir.path, "launcher/$geodeName")
if (internalGeodePath.exists()) {
@@ -0,0 +1,153 @@
package com.geode.launcher.utils
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.core.content.edit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
/**
* Extension object for SharedPreferences to add better key safety and default values.
*/
class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
companion object {
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
@Composable
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
p.getBoolean(k)
}
}
@Composable
fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> {
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
p.getString(k)
}
}
@Composable
private fun <T> usePreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, preferenceGet: (PreferenceUtils, Key) -> T): MutableState<T> {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
val preferences = get(sharedPreferences)
val preferenceValue = remember {
mutableStateOf(
preferenceGet(preferences, preferenceKey)
)
}
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
_, _ -> preferenceValue.value = preferenceGet(preferences, preferenceKey)
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
} else if (event == Lifecycle.Event.ON_DESTROY) {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
return preferenceValue
}
fun get(context: Context): PreferenceUtils {
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
return get(sharedPreferences)
}
fun get(sharedPreferences: SharedPreferences): PreferenceUtils {
return PreferenceUtils(sharedPreferences)
}
}
enum class Key {
LOAD_TESTING,
LOAD_AUTOMATICALLY,
UPDATE_AUTOMATICALLY,
RELEASE_CHANNEL,
CURRENT_VERSION_TAG,
CURRENT_VERSION_TIMESTAMP
}
private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) {
Key.UPDATE_AUTOMATICALLY, Key.RELEASE_CHANNEL -> true
else -> false
}
}
private fun keyToName(key: Key): String {
return when (key) {
Key.LOAD_TESTING -> "PreferenceLoadTesting"
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
}
}
fun getBoolean(key: Key): Boolean {
val defaultValue = defaultValueForBooleanKey(key)
val keyName = keyToName(key)
return sharedPreferences.getBoolean(keyName, defaultValue)
}
fun toggleBoolean(key: Key): Boolean {
val currentValue = getBoolean(key)
val keyName = keyToName(key)
sharedPreferences.edit {
putBoolean(keyName, !currentValue)
}
return !currentValue
}
fun getString(key: Key): String? {
val keyName = keyToName(key)
return sharedPreferences.getString(keyName, null)
}
fun setString(key: Key, value: String) {
val keyName = keyToName(key)
sharedPreferences.edit {
putString(keyName, value)
}
}
fun getLong(key: Key): Long {
val keyName = keyToName(key)
return sharedPreferences.getLong(keyName, 0L)
}
fun setLong(key: Key, value: Long) {
val keyName = keyToName(key)
sharedPreferences.edit {
putLong(keyName, value)
}
}
}
@@ -0,0 +1,207 @@
package com.geode.launcher.utils
import android.content.Context
import android.util.Log
import com.geode.launcher.api.Release
import com.geode.launcher.api.ReleaseRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.io.InterruptedIOException
/**
* Singleton to manage Geode updates, from update checking to downloading.
*/
class ReleaseManager private constructor(
private val applicationContext: Context,
private val httpClient: OkHttpClient,
private val releaseRepository: ReleaseRepository = ReleaseRepository(httpClient)
) {
companion object {
private lateinit var managerInstance: ReleaseManager
fun get(context: Context): ReleaseManager {
if (!::managerInstance.isInitialized) {
val applicationContext = context.applicationContext
val httpClient = OkHttpClient.Builder()
.cache(Cache(
applicationContext.cacheDir,
maxSize = 10L * 1024L * 1024L // 10mb cache size
))
.build()
managerInstance = ReleaseManager(
applicationContext,
httpClient
)
}
return managerInstance
}
}
sealed class ReleaseManagerState {
data object InUpdateCheck : ReleaseManagerState()
data class Failure(val exception: Exception) : ReleaseManagerState()
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseManagerState()
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
}
private var updateJob: Job? = null
private val _uiState = MutableStateFlow<ReleaseManagerState>(ReleaseManagerState.Finished())
val uiState = _uiState.asStateFlow()
val isInUpdate: Boolean
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
private fun sendError(e: Exception) {
_uiState.value = ReleaseManagerState.Failure(e)
// ignore cancellation, it's good actually
if (e !is CancellationException && e !is InterruptedIOException) {
Log.w("Geode", "Release download has failed:")
e.printStackTrace()
}
}
private suspend fun getLatestRelease(): Release? {
val sharedPreferences = PreferenceUtils.get(applicationContext)
val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL)
val latestRelease = if (useNightly) {
releaseRepository.getLatestNightlyRelease()
} else {
releaseRepository.getLatestRelease()
}
return latestRelease
}
private suspend fun performUpdate(release: Release) {
val releaseAsset = release.getAndroidDownload()
if (releaseAsset == null) {
val noAssetException = Exception("missing Android download")
_uiState.value = ReleaseManagerState.Failure(noAssetException)
return
}
// set an initial download size
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
try {
val fileStream = DownloadUtils.downloadStream(httpClient, releaseAsset.browserDownloadUrl) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
}
val geodeName = LaunchUtils.getGeodeFilename()
val geodeFile = getGeodeOutputPath(geodeName)
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
DownloadUtils.extractFileFromZipStream(
fileStream,
geodeFile.outputStream(),
geodeName
)
} catch (e: Exception) {
sendError(e)
return
}
// extraction performed
updatePreferences(release)
_uiState.value = ReleaseManagerState.Finished(true)
}
private suspend fun checkForNewRelease() {
val release = try {
getLatestRelease()
} catch (e: Exception) {
sendError(e)
return
}
if (release == null) {
_uiState.value = ReleaseManagerState.Finished()
return
}
val sharedPreferences = PreferenceUtils.get(applicationContext)
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
val latestVersion = release.getDescriptor()
// check if an update is needed
if (latestVersion <= currentVersion) {
_uiState.value = ReleaseManagerState.Finished()
return
}
performUpdate(release)
}
private suspend fun updatePreferences(release: Release) {
coroutineScope {
val sharedPreferences = PreferenceUtils.get(applicationContext)
sharedPreferences.setString(
PreferenceUtils.Key.CURRENT_VERSION_TAG,
release.getDescription()
)
sharedPreferences.setLong(
PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP,
release.getDescriptor()
)
}
}
private fun getGeodeOutputPath(geodeName: String): File {
val fallbackPath = File(applicationContext.filesDir, "launcher")
val geodeDirectory = applicationContext.getExternalFilesDir("") ?: fallbackPath
return File(geodeDirectory, geodeName)
}
/**
* Cancels the current update job.
*/
suspend fun cancelUpdate() {
updateJob?.cancelAndJoin()
updateJob = null
_uiState.value = ReleaseManagerState.Finished()
}
/**
* Schedules a new update checking job.
* @return Flow that tracks the state of the update.
*/
@OptIn(DelicateCoroutinesApi::class)
fun checkForUpdates(): StateFlow<ReleaseManagerState> {
if (!isInUpdate) {
_uiState.value = ReleaseManagerState.InUpdateCheck
updateJob = GlobalScope.launch {
checkForNewRelease()
}
}
return _uiState.asStateFlow()
}
}
@@ -1,37 +1,53 @@
package com.geode.launcher.utils
import android.os.CountDownTimer
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.delay
const val MS_TO_SEC = 1000
const val MS_TO_SEC = 1000L
@Composable
fun useCountdownTimer(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, time: Int, onCountdownFinish: () -> Unit): MutableState<Int> {
val timeData = remember {
mutableStateOf(time / MS_TO_SEC % MS_TO_SEC)
fun useCountdownTimer(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
time: Long,
onCountdownFinish: () -> Unit
): Long {
var millisUntilFinished by remember {
mutableLongStateOf(time)
}
val countdownTimer =
object : CountDownTimer(time.toLong(), 1000) {
override fun onTick(millisUntilFinished: Long) {
timeData.value = (millisUntilFinished / MS_TO_SEC % MS_TO_SEC).toInt() + 1
}
var shouldBeCounting by remember {
mutableStateOf(true)
}
override fun onFinish() {
onCountdownFinish()
}
LaunchedEffect(shouldBeCounting, millisUntilFinished) {
if (!shouldBeCounting) {
return@LaunchedEffect
}
if (millisUntilFinished > 0) {
delay(MS_TO_SEC)
millisUntilFinished -= MS_TO_SEC
} else {
onCountdownFinish()
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
countdownTimer.start()
} else if (event == Lifecycle.Event.ON_STOP) {
countdownTimer.cancel()
when (event) {
Lifecycle.Event.ON_START -> {
shouldBeCounting = true
millisUntilFinished = time
}
Lifecycle.Event.ON_PAUSE,
Lifecycle.Event.ON_STOP -> {
shouldBeCounting = false
}
else -> {}
}
}
@@ -42,5 +58,5 @@ fun useCountdownTimer(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.curre
}
}
return timeData
return millisUntilFinished / MS_TO_SEC
}
@@ -1,51 +0,0 @@
package com.geode.launcher.utils
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.StringRes
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@Composable
fun usePreference(@StringRes preferenceFileKey: Int, @StringRes preferenceId: Int, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
val context = LocalContext.current
val preferences = context.getSharedPreferences(
context.getString(preferenceFileKey), Context.MODE_PRIVATE
)
val preferenceValue = remember {
mutableStateOf(
preferences.getBoolean(
context.getString(preferenceId),
false
)
)
}
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
sharedPreferences, key -> preferenceValue.value = sharedPreferences.getBoolean(key, false)
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
preferences.registerOnSharedPreferenceChangeListener(listener)
} else if (event == Lifecycle.Event.ON_DESTROY) {
preferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
return preferenceValue
}
+26 -6
View File
@@ -17,13 +17,20 @@
<item quantity="other">Automatically launching game in %1$d seconds.</item>
</plurals>
<!-- GeometryDashActivity strings -->
<string name="message_box_accept">Ok</string>
<!-- Updater strings -->
<string name="release_fetch_failed">Update failed.\n%1$s</string>
<string name="release_fetch_no_releases">No updates found.</string>
<string name="release_fetch_in_progress">Checking for new releases…</string>
<string name="release_fetch_success">Geode updated!</string>
<string name="release_fetch_downloading">Downloading update… (%1$s/%2$s)</string>
<string name="release_fetch_cancelling">Cancelling update…</string>
<string name="release_fetch_no_internet">No internet found.</string>
<string name="release_fetch_cancelled">Update cancelled.</string>
<string name="release_fetch_button_retry">Retry</string>
<string name="release_fetch_button_cancel">Cancel</string>
<!-- Preference keys -->
<string name="preference_file_key">GeodeLauncherPreferencesFileKey</string>
<string name="preference_load_testing">PreferenceLoadTesting</string>
<string name="preference_load_automatically">PreferenceLoadAutomatically</string>
<!-- GeometryDashActivity strings -->
<string name="message_box_accept">OK</string>
<!-- SettingsActivity strings -->
<string name="title_activity_settings">Settings</string>
@@ -37,4 +44,17 @@
<string name="preference_load_automatically_name">Automatically launch</string>
<string name="preference_load_automatically_description">Launches the game after a short delay.</string>
<string name="preferences_copy_external_button">Copy external files directory</string>
<string name="preference_category_updater">Updates</string>
<string name="preference_update_automatically_name">Enable automatic updates</string>
<string name="preference_release_channel_name">Use nightly release channel</string>
<string name="preference_check_for_updates_button">Check for updates</string>
<string name="preference_check_for_updates_description">Current version: %1$s</string>
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
<string name="preference_check_for_updates_none_found">No updates found.</string>
<string name="preference_check_for_updates_success">Geode has been updated!</string>
<string name="preference_check_for_updates_downloading">Downloading update…</string>
<string name="preference_check_for_updates_no_internet">No internet found.</string>
<string name="preference_check_for_updates_already_updating">An update is already in progress!</string>
</resources>