diff --git a/app/build.gradle b/app/build.gradle index 08e093b0..9c6416c7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" } \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/DownloadGeode.kt b/app/src/main/java/com/geode/launcher/DownloadGeode.kt deleted file mode 100644 index e6210def..00000000 --- a/app/src/main/java/com/geode/launcher/DownloadGeode.kt +++ /dev/null @@ -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() { - 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/GeometryDashActivity.kt b/app/src/main/java/com/geode/launcher/GeometryDashActivity.kt index 399f9543..70adad03 100644 --- a/app/src/main/java/com/geode/launcher/GeometryDashActivity.kt +++ b/app/src/main/java/com/geode/launcher/GeometryDashActivity.kt @@ -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() - } - } - } - } } diff --git a/app/src/main/java/com/geode/launcher/MainActivity.kt b/app/src/main/java/com/geode/launcher/MainActivity.kt index 4a044cd6..7b9b6621 100644 --- a/app/src/main/java/com/geode/launcher/MainActivity.kt +++ b/app/src/main/java/com/geode/launcher/MainActivity.kt @@ -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() { } } } + } +} + +@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 (gdInstalled && !geodeInstalled) { - downloadGeode(this) + if (progress == null) { + LinearProgressIndicator() + } else { + LinearProgressIndicator(progress) + } + + TextButton( + onClick = { + releaseViewModel.cancelUpdate() + }, + modifier = Modifier.offset((-12).dp) + ) { + Text(stringResource(R.string.release_fetch_button_cancel)) } - */ } } -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 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) + ) } } diff --git a/app/src/main/java/com/geode/launcher/SettingsActivity.kt b/app/src/main/java/com/geode/launcher/SettingsActivity.kt index 0c5d6137..286517d4 100644 --- a/app/src/main/java/com/geode/launcher/SettingsActivity.kt +++ b/app/src/main/java/com/geode/launcher/SettingsActivity.kt @@ -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) { } } +@Composable +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?) { +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 - ) - - val current = preferences.getBoolean( - context.getString(preferenceId), false - ) - preferences.edit { - putBoolean(context.getString(preferenceId), !current) - commit() - } +fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean { + val preferences = PreferenceUtils.get(context) - 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 ) } } diff --git a/app/src/main/java/com/geode/launcher/api/Release.kt b/app/src/main/java/com/geode/launcher/api/Release.kt new file mode 100644 index 00000000..ba41b2b1 --- /dev/null +++ b/app/src/main/java/com/geode/launcher/api/Release.kt @@ -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 +) { + 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) + } + } +} diff --git a/app/src/main/java/com/geode/launcher/api/ReleaseRepository.kt b/app/src/main/java/com/geode/launcher/api/ReleaseRepository.kt new file mode 100644 index 00000000..7d13266b --- /dev/null +++ b/app/src/main/java/com/geode/launcher/api/ReleaseRepository.kt @@ -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( + response.body!!.source() + ) + + release + } + 404 -> { + null + } + else -> { + throw IOException("unknown response ${response.code}") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/api/ReleaseViewModel.kt b/app/src/main/java/com/geode/launcher/api/ReleaseViewModel.kt new file mode 100644 index 00000000..086c8f33 --- /dev/null +++ b/app/src/main/java/com/geode/launcher/api/ReleaseViewModel.kt @@ -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.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 + ) { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/utils/Constants.kt b/app/src/main/java/com/geode/launcher/utils/Constants.kt index 7a2e69be..2e807957 100644 --- a/app/src/main/java/com/geode/launcher/utils/Constants.kt +++ b/app/src/main/java/com/geode/launcher/utils/Constants.kt @@ -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" } diff --git a/app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt b/app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt new file mode 100644 index 00000000..467f3332 --- /dev/null +++ b/app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt @@ -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 +// 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/utils/LaunchUtils.kt b/app/src/main/java/com/geode/launcher/utils/LaunchUtils.kt index 82d3576e..1f49aeaf 100644 --- a/app/src/main/java/com/geode/launcher/utils/LaunchUtils.kt +++ b/app/src/main/java/com/geode/launcher/utils/LaunchUtils.kt @@ -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()) { diff --git a/app/src/main/java/com/geode/launcher/utils/PreferenceUtils.kt b/app/src/main/java/com/geode/launcher/utils/PreferenceUtils.kt new file mode 100644 index 00000000..40780778 --- /dev/null +++ b/app/src/main/java/com/geode/launcher/utils/PreferenceUtils.kt @@ -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 { + return usePreference(preferenceKey, lifecycleOwner) { p, k -> + p.getBoolean(k) + } + } + + @Composable + fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState { + return usePreference(preferenceKey, lifecycleOwner) { p, k -> + p.getString(k) + } + } + + @Composable + private fun usePreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, preferenceGet: (PreferenceUtils, Key) -> T): MutableState { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/utils/ReleaseManager.kt b/app/src/main/java/com/geode/launcher/utils/ReleaseManager.kt new file mode 100644 index 00000000..87206cdb --- /dev/null +++ b/app/src/main/java/com/geode/launcher/utils/ReleaseManager.kt @@ -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.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 { + if (!isInUpdate) { + _uiState.value = ReleaseManagerState.InUpdateCheck + updateJob = GlobalScope.launch { + checkForNewRelease() + } + } + + return _uiState.asStateFlow() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/utils/useCountdownTimer.kt b/app/src/main/java/com/geode/launcher/utils/useCountdownTimer.kt index 8656a16c..c8512958 100644 --- a/app/src/main/java/com/geode/launcher/utils/useCountdownTimer.kt +++ b/app/src/main/java/com/geode/launcher/utils/useCountdownTimer.kt @@ -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 { - 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 } \ No newline at end of file diff --git a/app/src/main/java/com/geode/launcher/utils/usePreference.kt b/app/src/main/java/com/geode/launcher/utils/usePreference.kt deleted file mode 100644 index 1ec9ed58..00000000 --- a/app/src/main/java/com/geode/launcher/utils/usePreference.kt +++ /dev/null @@ -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 { - 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 -} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af220ee4..6aa0d6bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,13 +17,20 @@ Automatically launching game in %1$d seconds. - - Ok + + Update failed.\n%1$s + No updates found. + Checking for new releases… + Geode updated! + Downloading update… (%1$s/%2$s) + Cancelling update… + No internet found. + Update cancelled. + Retry + Cancel - - GeodeLauncherPreferencesFileKey - PreferenceLoadTesting - PreferenceLoadAutomatically + + OK Settings @@ -37,4 +44,17 @@ Automatically launch Launches the game after a short delay. Copy external files directory + + Updates + Enable automatic updates + Use nightly release channel + + Check for updates + Current version: %1$s + Update failed. See logs for more details. + No updates found. + Geode has been updated! + Downloading update… + No internet found. + An update is already in progress! \ No newline at end of file diff --git a/build.gradle b/build.gradle index a0118ae2..325c793a 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,7 @@ plugins { id 'com.android.application' version '8.2.0' apply false id 'com.android.library' version '8.2.0' apply false id 'org.jetbrains.kotlin.android' version '1.9.21' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21' apply false } tasks.register('clean', Delete) {