From 65e9e6ab2433a6b3f9af95db24a1248d64a155fc Mon Sep 17 00:00:00 2001 From: qimiko <25387744+qimiko@users.noreply.github.com> Date: Thu, 4 Jan 2024 03:29:59 -0700 Subject: [PATCH] modify updater to be global --- .../java/com/geode/launcher/MainActivity.kt | 11 +- .../com/geode/launcher/SettingsActivity.kt | 9 +- .../geode/launcher/api/ReleaseRepository.kt | 25 +- .../geode/launcher/api/ReleaseViewModel.kt | 163 +++---------- .../geode/launcher/utils/ReleaseManager.kt | 229 ++++++++++++++++++ 5 files changed, 280 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/com/geode/launcher/utils/ReleaseManager.kt diff --git a/app/src/main/java/com/geode/launcher/MainActivity.kt b/app/src/main/java/com/geode/launcher/MainActivity.kt index c1000ebd..9820eab5 100644 --- a/app/src/main/java/com/geode/launcher/MainActivity.kt +++ b/app/src/main/java/com/geode/launcher/MainActivity.kt @@ -13,6 +13,7 @@ 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 @@ -86,8 +87,6 @@ fun UpdateCard(state: ReleaseViewModel.ReleaseUIState, modifier: Modifier = Modi when (state) { is ReleaseViewModel.ReleaseUIState.Failure -> { - state.exception.printStackTrace() - val message = state.exception.message Text( @@ -158,12 +157,20 @@ fun MainScreen( 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() + } + } + Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, diff --git a/app/src/main/java/com/geode/launcher/SettingsActivity.kt b/app/src/main/java/com/geode/launcher/SettingsActivity.kt index b468d2ca..9188297a 100644 --- a/app/src/main/java/com/geode/launcher/SettingsActivity.kt +++ b/app/src/main/java/com/geode/launcher/SettingsActivity.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -113,8 +112,6 @@ fun UpdateIndicator( when (updateStatus) { is ReleaseViewModel.ReleaseUIState.Failure -> { - updateStatus.exception.printStackTrace() - snackbarHostState.showSnackbar( context.getString(R.string.preference_check_for_updates_failed), ) @@ -146,7 +143,6 @@ fun SettingsScreen( val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG) val updateStatus by releaseViewModel.uiState.collectAsState() - var showUpdateProgress by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } Scaffold( @@ -219,12 +215,9 @@ fun SettingsScreen( modifier = Modifier .clickable(onClick = { releaseViewModel.runReleaseCheck() - showUpdateProgress = true }, role = Role.Button) ) { - if (showUpdateProgress) { - UpdateIndicator(snackbarHostState, updateStatus) - } + UpdateIndicator(snackbarHostState, updateStatus) } } /* diff --git a/app/src/main/java/com/geode/launcher/api/ReleaseRepository.kt b/app/src/main/java/com/geode/launcher/api/ReleaseRepository.kt index be32465c..0df0d4cd 100644 --- a/app/src/main/java/com/geode/launcher/api/ReleaseRepository.kt +++ b/app/src/main/java/com/geode/launcher/api/ReleaseRepository.kt @@ -19,35 +19,18 @@ class ReleaseRepository { class HttpException(message: String) : IOException(message) - private var foundNightlyRelease: Release? = null - private var foundRelease: Release? = null - - suspend fun getLatestNightlyRelease(isRefresh: Boolean = false): Release? { - if (!isRefresh && foundNightlyRelease != null) { - return foundNightlyRelease - } - + suspend fun getLatestNightlyRelease(): Release? { val nightlyPath = "$GITHUB_API_BASE/releases/tags/nightly" val url = URL(nightlyPath) - val release = getReleaseByUrl(url) - foundNightlyRelease = release - - return release + return getReleaseByUrl(url) } - suspend fun getLatestRelease(isRefresh: Boolean = false): Release? { - if (!isRefresh && foundRelease != null) { - return foundRelease - } - + suspend fun getLatestRelease(): Release? { val releasePath = "$GITHUB_API_BASE/releases/latest" val url = URL(releasePath) - val release = getReleaseByUrl(url) - foundRelease = release - - return release + return getReleaseByUrl(url) } @OptIn(ExperimentalSerializationApi::class) diff --git a/app/src/main/java/com/geode/launcher/api/ReleaseViewModel.kt b/app/src/main/java/com/geode/launcher/api/ReleaseViewModel.kt index 4fc7e7da..f42bfd92 100644 --- a/app/src/main/java/com/geode/launcher/api/ReleaseViewModel.kt +++ b/app/src/main/java/com/geode/launcher/api/ReleaseViewModel.kt @@ -7,27 +7,20 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import com.geode.launcher.utils.DownloadUtils -import com.geode.launcher.utils.LaunchUtils -import com.geode.launcher.utils.PreferenceUtils -import kotlinx.coroutines.delay +import com.geode.launcher.utils.ReleaseManager +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch -import java.io.File -import java.io.IOException - -class ReleaseViewModel(private val releaseRepository: ReleaseRepository, private val sharedPreferences: PreferenceUtils, private val application: Application): ViewModel() { +class ReleaseViewModel(private val application: Application): ViewModel() { companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val application = this[APPLICATION_KEY] as Application - val preferences = PreferenceUtils.get(application) ReleaseViewModel( - releaseRepository = ReleaseRepository(), - sharedPreferences = preferences, application = application ) } @@ -39,89 +32,26 @@ class ReleaseViewModel(private val releaseRepository: ReleaseRepository, private 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() - } - - private val _uiState = MutableStateFlow(ReleaseUIState.Finished()) - val uiState = _uiState.asStateFlow() - - private var isInUpdateCheck = false - private suspend fun retry(block: suspend () -> R): R { - val maxAttempts = 5 - val initialDelay = 1000L - - repeat(maxAttempts - 1) { attempt -> - try { - return block() - } catch (e: Exception) { - // only retry on exceptions that can be handled - if (e !is IOException) { - throw e + 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) } } - - delay(initialDelay * attempt) } - - // run final time for exceptions - return block() } - private suspend fun getLatestRelease(): Release? { - _uiState.value = ReleaseUIState.InUpdateCheck - - val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL) - val latestRelease = retry { - if (useNightly) { - releaseRepository.getLatestNightlyRelease(true) - } else { - releaseRepository.getLatestRelease(true) - } - } - - return latestRelease - } - - private suspend fun checkForNewRelease() { - val release = try { - getLatestRelease() - } catch (e: Exception) { - _uiState.value = ReleaseUIState.Failure(e) - return - } - - if (release == null) { - _uiState.value = ReleaseUIState.Finished() - return - } - - val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP) - val latestVersion = release.getDescriptor() - - if (latestVersion <= currentVersion) { - _uiState.value = ReleaseUIState.Finished() - return - } - - val releaseAsset = release.getAndroidDownload() - if (releaseAsset == null) { - val noAssetException = Exception("missing Android download") - _uiState.value = ReleaseUIState.Failure(noAssetException) - - return - } - - try { - createDownload(releaseAsset) - } catch (e: Exception) { - _uiState.value = ReleaseUIState.Failure(e) - return - } + private val _uiState = MutableStateFlow(ReleaseUIState.Finished()) + val uiState = _uiState.asStateFlow() - // extraction performed - updatePreferences(release) - _uiState.value = ReleaseUIState.Finished(true) - } + private var isInUpdateCheck = false + var hasPerformedCheck = false + private set fun runReleaseCheck() { // don't run multiple checks @@ -129,51 +59,32 @@ class ReleaseViewModel(private val releaseRepository: ReleaseRepository, private return } + hasPerformedCheck = true + viewModelScope.launch { isInUpdateCheck = true - checkForNewRelease() - isInUpdateCheck = false - } - } - private suspend fun createDownload(asset: Asset) { - _uiState.value = ReleaseUIState.InDownload(0, asset.size.toLong()) - - val outputFile = DownloadUtils.downloadFile(application, asset.browserDownloadUrl, asset.name) { progress, outOf -> - _uiState.value = ReleaseUIState.InDownload(progress, outOf) - } - - try { - val geodeName = LaunchUtils.getGeodeFilename() - - val fallbackPath = File(application.filesDir, "launcher") - val geodeDirectory = application.getExternalFilesDir("") ?: fallbackPath - - val geodeFile = File(geodeDirectory, geodeName) - - DownloadUtils.extractFileFromZip(outputFile, geodeFile, geodeName) - } finally { - // delete file now that it's no longer needed - outputFile.delete() - } - } + val releaseFlow = ReleaseManager.get(application) + .checkForUpdates( + // use the current scope so (hopefully) flow piping ends once collection is finished + CoroutineScope(coroutineContext) + ) - private fun updatePreferences(release: Release) { - sharedPreferences.setString( - PreferenceUtils.Key.CURRENT_VERSION_TAG, - release.getDescription() - ) + releaseFlow + .transformWhile { + // map the ui state into something that can be used + emit(ReleaseUIState.managerStateToUI(it)) - sharedPreferences.setLong( - PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP, - release.getDescriptor() - ) - } + // end collection once ReleaseManager reaches a state of "completion" + it !is ReleaseManager.ReleaseManagerState.Finished && + it !is ReleaseManager.ReleaseManagerState.Failure + } + .collect { + // send the mapped state to the ui + _uiState.value = it + } - init { - val shouldUpdate = sharedPreferences.getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY) - if (shouldUpdate) { - runReleaseCheck() + isInUpdateCheck = false } } } \ 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..9e73ff4c --- /dev/null +++ b/app/src/main/java/com/geode/launcher/utils/ReleaseManager.kt @@ -0,0 +1,229 @@ +package com.geode.launcher.utils + +import android.content.Context +import com.geode.launcher.api.Release +import com.geode.launcher.api.ReleaseRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException + +/** + * Singleton to manage Geode updates, from update checking to downloading. + */ +class ReleaseManager private constructor( + private val applicationContext: Context, + private val releaseRepository: ReleaseRepository = ReleaseRepository() +) { + companion object { + private lateinit var managerInstance: ReleaseManager + + fun get(context: Context): ReleaseManager { + if (!::managerInstance.isInitialized) { + val applicationContext = context.applicationContext + managerInstance = ReleaseManager(applicationContext) + } + + 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 var currentUpdate: Long? = null + + private val uiState = MutableStateFlow(ReleaseManagerState.Finished()) + + // runs a given function, retrying until it succeeds or max attempts are reached + private suspend fun retry(block: suspend () -> R): R { + val maxAttempts = 5 + val initialDelay = 1000L + + repeat(maxAttempts - 1) { attempt -> + try { + return block() + } catch (e: Exception) { + // only retry on exceptions that can be handled + if (e !is IOException) { + throw e + } + } + + delay(initialDelay * attempt) + } + + // run final time for exceptions + return block() + } + + private fun sendError(e: Exception) { + uiState.value = ReleaseManagerState.Failure(e) + e.printStackTrace() + } + + private suspend fun getLatestRelease(): Release? { + uiState.value = ReleaseManagerState.InUpdateCheck + + val sharedPreferences = PreferenceUtils.get(applicationContext) + val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL) + + val latestRelease = retry { + 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") + sendError(noAssetException) + + return + } + + // set an initial download size + uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong()) + + try { + val file = performDownload(releaseAsset.browserDownloadUrl) + performExtraction(file) + } catch (e: Exception) { + sendError(e) + return + } + + // extraction performed + updatePreferences(release) + uiState.value = ReleaseManagerState.Finished(true) + } + + // cancels the previous update and begins a new one + private suspend fun beginUpdate(release: Release) { + if (release.getDescriptor() == currentUpdate) { + return + } + + updateJob?.cancel() + + currentUpdate = release.getDescriptor() + updateJob = coroutineScope { + launch { + performUpdate(release) + } + } + } + + private suspend fun checkForNewRelease(updateFlow: MutableStateFlow, updateScope: CoroutineScope?) { + val release = try { + getLatestRelease() + } catch (e: Exception) { + sendError(e) + return + } + + if (release == null) { + updateFlow.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) { + updateFlow.value = ReleaseManagerState.Finished() + return + } + + // final act... sync update flow to uiState + if (updateScope?.isActive == true) { + updateScope.launch { + uiState.collect { + updateFlow.value = it + } + } + } + + beginUpdate(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 suspend fun performDownload(url: String): File { + return DownloadUtils.downloadFile(applicationContext, url, "geode-release.zip") { progress, outOf -> + uiState.value = ReleaseManagerState.InDownload(progress, outOf) + } + } + + private suspend fun performExtraction(outputFile: File) { + try { + val geodeName = LaunchUtils.getGeodeFilename() + val geodeFile = getGeodeOutputPath(geodeName) + + DownloadUtils.extractFileFromZip(outputFile, geodeFile, geodeName) + } finally { + // delete file now that it's no longer needed + outputFile.delete() + } + } + + private fun getGeodeOutputPath(geodeName: String): File { + val fallbackPath = File(applicationContext.filesDir, "launcher") + val geodeDirectory = applicationContext.getExternalFilesDir("") ?: fallbackPath + + return File(geodeDirectory, geodeName) + } + + /** + * Schedules a new update checking job. + * @param updateScope Limits the lifetime of flow collection to the scope. + * @return Flow that tracks the state of the update, eventually being merged into the main downloader state. + */ + @OptIn(DelicateCoroutinesApi::class) + fun checkForUpdates(updateScope: CoroutineScope? = null): StateFlow { + val flow = MutableStateFlow(ReleaseManagerState.InUpdateCheck) + + GlobalScope.launch { + checkForNewRelease(flow, updateScope) + } + + return flow.asStateFlow() + } +} \ No newline at end of file