reorganize release api

This commit is contained in:
qimiko committed 2024-01-22 00:16:01 -07:00
1 parent af0ebeab84
commit 1eab544692
6 files changed
+20 -28

No files matched your search

@@ -0,0 +1,54 @@
package com.geode.launcher.updater
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 platform = LaunchUtils.platformName
val releaseSuffix = "$platform.zip"
return assets.find {
it.name.endsWith(releaseSuffix)
}
}
}
@@ -0,0 +1,247 @@
package com.geode.launcher.updater
import android.content.Context
import android.util.Log
import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
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 okio.buffer
import okio.source
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 (!Companion::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()
}
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
enum class Reason {
EXTERNAL_FILE_IN_USE
}
var reason: Reason? = reason
private set
}
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)
return releaseRepository.getLatestGeodeRelease(useNightly)
}
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 geodeFile = getGeodeOutputPath()
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
geodeFile.parentFile?.mkdirs()
DownloadUtils.extractFileFromZipStream(
fileStream,
geodeFile.outputStream(),
geodeFile.name
)
} catch (e: Exception) {
sendError(e)
return
}
// extraction performed
updatePreferences(release)
_uiState.value = ReleaseManagerState.Finished(true)
}
private fun fileWasExternallyModified(): Boolean {
val geodeFile = getGeodeOutputPath()
if (!geodeFile.exists()) {
return false
}
val sharedPreferences = PreferenceUtils.get(applicationContext)
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
?: return false
val currentFileHash = computeFileHash(geodeFile)
return originalFileHash != currentFileHash
}
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) {
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()
// make sure geode is still here. just in case
val geodeFile = getGeodeOutputPath()
// check if an update is needed
if (latestVersion <= currentVersion && geodeFile.exists()) {
_uiState.value = ReleaseManagerState.Finished()
return
}
// check if the file was externally modified
if (!allowOverwriting && fileWasExternallyModified()) {
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
return
}
performUpdate(release)
}
private fun updatePreferences(release: Release) {
val sharedPreferences = PreferenceUtils.get(applicationContext)
sharedPreferences.setString(
PreferenceUtils.Key.CURRENT_VERSION_TAG,
release.getDescription()
)
sharedPreferences.setLong(
PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP,
release.getDescriptor()
)
// store hash as modified time is unreliable (don't know why)
val outputFile = getGeodeOutputPath()
val fileHash = computeFileHash(outputFile)
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
}
private fun computeFileHash(file: File): String =
file.source().buffer().readByteString().md5().hex()
private fun getGeodeOutputPath(): File {
val geodeName = LaunchUtils.geodeFilename
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
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(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
if (!isInUpdate) {
_uiState.value = ReleaseManagerState.InUpdateCheck
updateJob = GlobalScope.launch {
checkForNewRelease(isManual)
}
}
return _uiState.asStateFlow()
}
}
@@ -0,0 +1,61 @@
package com.geode.launcher.updater
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"
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
private const val GITHUB_API_VERSION = "2022-11-28"
}
suspend fun getLatestGeodeRelease(isNightly: Boolean = false): Release? {
val releasePath = if (isNightly) "$GITHUB_API_BASE/releases/tags/nightly"
else "$GITHUB_API_BASE/repos/geode-sdk/geode/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,98 @@
package com.geode.launcher.updater
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 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(isManual: Boolean = false) {
hasPerformedCheck = true
viewModelScope.launch {
val releaseFlow = ReleaseManager.get(application)
.checkForUpdates(isManual)
syncUiState(releaseFlow)
}
}
}