forked from geode-sdk/android-launcher
reorganize release api
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user