mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-21 16:32:39 +00:00
implement new release api features (#86)
* implement new release api features * make hash nullable
This commit is contained in:
1 parent
ef44d48ed0
commit
3b4951def1
5 files changed
+162
-90
No files matched your search
@@ -716,6 +716,9 @@ fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Un
|
||||
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
|
||||
R.string.release_fetch_external_in_use_short
|
||||
)
|
||||
ReleaseManager.UpdateException.Reason.HASH_VALIDATION_FAILED -> stringResource(
|
||||
R.string.release_fetch_hash_validation_failed_short
|
||||
)
|
||||
else -> stringResource(R.string.release_fetch_generic_short)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ package com.geode.launcher.updater
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Serializable
|
||||
data class Asset @OptIn(ExperimentalTime::class) constructor(
|
||||
data class Asset(
|
||||
val url: String,
|
||||
val id: Int,
|
||||
val name: String,
|
||||
@@ -14,10 +13,11 @@ data class Asset @OptIn(ExperimentalTime::class) constructor(
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val browserDownloadUrl: String,
|
||||
val digest: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Release @OptIn(ExperimentalTime::class) constructor(
|
||||
data class Release(
|
||||
val url: String,
|
||||
val id: Int,
|
||||
val targetCommitish: String,
|
||||
@@ -31,12 +31,29 @@ data class Release @OptIn(ExperimentalTime::class) constructor(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderVersion @OptIn(ExperimentalTime::class) constructor(
|
||||
data class LoaderPlatformDownload(
|
||||
val url: String,
|
||||
val hash: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderDownload(
|
||||
val win: LoaderPlatformDownload,
|
||||
val mac: LoaderPlatformDownload,
|
||||
val android32: LoaderPlatformDownload,
|
||||
val android64: LoaderPlatformDownload,
|
||||
val ios: LoaderPlatformDownload,
|
||||
val resources: LoaderPlatformDownload,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderVersion(
|
||||
val tag: String,
|
||||
val version: String,
|
||||
val createdAt: Instant,
|
||||
val commitHash: String,
|
||||
val prerelease: Boolean
|
||||
val prerelease: Boolean,
|
||||
val downloads: LoaderDownload,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -45,7 +62,25 @@ data class LoaderPayload<T>(
|
||||
val error: String
|
||||
)
|
||||
|
||||
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
|
||||
data class DownloadableAsset(
|
||||
val url: String,
|
||||
val filename: String,
|
||||
val size: Long? = null,
|
||||
val hash: String? = null,
|
||||
)
|
||||
|
||||
private fun mapDownload(download: Asset): DownloadableAsset {
|
||||
val hash = if (download.digest?.startsWith("sha256:") == true)
|
||||
download.digest.removePrefix("sha256:")
|
||||
else null
|
||||
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong(),
|
||||
hash = hash,
|
||||
)
|
||||
}
|
||||
|
||||
abstract class Downloadable {
|
||||
/**
|
||||
@@ -83,7 +118,6 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
|
||||
return release.tagName
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return release.createdAt.epochSeconds
|
||||
}
|
||||
@@ -100,11 +134,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
|
||||
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
val download = getGitHubDownload() ?: return null
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong()
|
||||
)
|
||||
return mapDownload(download)
|
||||
}
|
||||
|
||||
override fun getResourcesDownload(): DownloadableAsset? {
|
||||
@@ -112,11 +142,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
|
||||
it.name == "resources.zip"
|
||||
} ?: return null
|
||||
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong()
|
||||
)
|
||||
return mapDownload(download)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +151,6 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||
return release.tagName
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return release.createdAt.epochSeconds
|
||||
}
|
||||
@@ -149,11 +174,7 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
val download = getGitHubDownload() ?: return null
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong()
|
||||
)
|
||||
return mapDownload(download)
|
||||
}
|
||||
|
||||
override fun getResourcesDownload(): DownloadableAsset? {
|
||||
@@ -166,24 +187,27 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab
|
||||
return version.tag
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return version.createdAt.epochSeconds
|
||||
}
|
||||
|
||||
override fun getDownload(): DownloadableAsset {
|
||||
val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32
|
||||
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
|
||||
return DownloadableAsset(
|
||||
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
|
||||
filename = filename
|
||||
url = data.url,
|
||||
filename = filename,
|
||||
hash = data.hash,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getResourcesDownload(): DownloadableAsset {
|
||||
val data = version.downloads.resources
|
||||
val filename = "resources.zip"
|
||||
return DownloadableAsset(
|
||||
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/resources.zip",
|
||||
filename = filename
|
||||
url = data.url,
|
||||
filename = filename,
|
||||
hash = data.hash,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,8 @@ class ReleaseManager private constructor(
|
||||
|
||||
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
|
||||
enum class Reason {
|
||||
EXTERNAL_FILE_IN_USE
|
||||
EXTERNAL_FILE_IN_USE,
|
||||
HASH_VALIDATION_FAILED
|
||||
}
|
||||
|
||||
var reason: Reason? = reason
|
||||
@@ -155,17 +156,17 @@ class ReleaseManager private constructor(
|
||||
val outputFile = File(outputDirectory, download.filename)
|
||||
|
||||
try {
|
||||
DownloadUtils.downloadStream(
|
||||
val hash = DownloadUtils.downloadFile(
|
||||
httpClient,
|
||||
download.url,
|
||||
onResponse = { body ->
|
||||
body.source().use { source ->
|
||||
outputFile.sink().buffer().use { sink ->
|
||||
sink.writeAll(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
outputFile
|
||||
)
|
||||
|
||||
if (download.hash != null && download.hash != hash) {
|
||||
Log.w("ReleaseManager", "downloadLauncherUpdate failed: found $hash but expected ${download.hash}")
|
||||
outputFile.delete()
|
||||
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
@@ -183,42 +184,52 @@ class ReleaseManager private constructor(
|
||||
|
||||
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
|
||||
|
||||
val downloadFile = getTempFile("resources.zip")
|
||||
|
||||
try {
|
||||
if (!outputDir.exists()) {
|
||||
outputDir.mkdirs()
|
||||
}
|
||||
|
||||
DownloadUtils.downloadStream(
|
||||
val hash = DownloadUtils.downloadFile(
|
||||
httpClient,
|
||||
resourceAsset.url,
|
||||
downloadFile,
|
||||
onProgress = { progress, outOf ->
|
||||
if (!skipStateUpdate)
|
||||
_uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize)
|
||||
},
|
||||
onResponse = { body ->
|
||||
DownloadUtils.copyZipStreamToDirectory(
|
||||
body.byteStream(),
|
||||
outputDir
|
||||
)
|
||||
|
||||
if (finalDir.exists()) {
|
||||
finalDir.deleteRecursively()
|
||||
}
|
||||
|
||||
if (!outputDir.renameTo(finalDir)) {
|
||||
println("Failed to rename temporary directory!")
|
||||
DownloadUtils.copyDirectory(outputDir, finalDir)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (resourceAsset.hash != null && resourceAsset.hash != hash) {
|
||||
Log.w("ReleaseManager", "performResourceDownload failed: found $hash but expected ${resourceAsset.hash}")
|
||||
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||
}
|
||||
|
||||
downloadFile.inputStream().use { zip ->
|
||||
DownloadUtils.copyZipStreamToDirectory(
|
||||
zip,
|
||||
outputDir
|
||||
)
|
||||
}
|
||||
|
||||
if (finalDir.exists()) {
|
||||
finalDir.deleteRecursively()
|
||||
}
|
||||
|
||||
if (!outputDir.renameTo(finalDir)) {
|
||||
println("Failed to rename temporary directory!")
|
||||
DownloadUtils.copyDirectory(outputDir, finalDir)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return false
|
||||
} finally {
|
||||
val tempPathClone = File(outputPath)
|
||||
runCatching {
|
||||
try {
|
||||
if (downloadFile.exists()) downloadFile.delete()
|
||||
if (tempPathClone.exists()) tempPathClone.deleteRecursively()
|
||||
}
|
||||
} catch (_: IOException) {}
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -244,12 +255,14 @@ class ReleaseManager private constructor(
|
||||
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize
|
||||
_uiState.value = ReleaseManagerState.InDownload(0, initialSize)
|
||||
|
||||
val outputFile = getTempFile()
|
||||
val outputFile = getTempFile(LaunchUtils.geodeFilename)
|
||||
// clone the file instance as renameTo may move the original file
|
||||
val tempFilePath = outputFile.path
|
||||
|
||||
val geodeFile = getGeodeOutputPath()
|
||||
|
||||
val downloadFile = getTempFile("geode.zip")
|
||||
|
||||
try {
|
||||
// tempFile should be in same path as geodeFile
|
||||
val geodeParent = geodeFile.parentFile
|
||||
@@ -257,45 +270,53 @@ class ReleaseManager private constructor(
|
||||
geodeParent.mkdirs()
|
||||
}
|
||||
|
||||
DownloadUtils.downloadStream(
|
||||
val hash = DownloadUtils.downloadFile(
|
||||
httpClient,
|
||||
releaseAsset.url,
|
||||
downloadFile,
|
||||
onProgress = { progress, outOf ->
|
||||
if (!skipStateUpdate)
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize)
|
||||
|
||||
releaseSize = max(outOf, releaseSize)
|
||||
},
|
||||
onResponse = { body ->
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
body.byteStream(),
|
||||
outputFile,
|
||||
geodeFile.name
|
||||
)
|
||||
|
||||
// work around a permission issue from adb push
|
||||
if (geodeFile.exists()) {
|
||||
geodeFile.delete()
|
||||
}
|
||||
|
||||
val renameSuccessful = runCatching {
|
||||
outputFile.renameTo(geodeFile)
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (!renameSuccessful) {
|
||||
// attempt a manual copy if rename fails for whatever reason
|
||||
DownloadUtils.copyFile(outputFile, geodeFile)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (releaseAsset.hash != null && releaseAsset.hash != hash) {
|
||||
Log.w("ReleaseManager", "performUpdate failed: found $hash but expected ${releaseAsset.hash}")
|
||||
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
|
||||
}
|
||||
|
||||
downloadFile.inputStream().use { zip ->
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
zip,
|
||||
outputFile,
|
||||
geodeFile.name
|
||||
)
|
||||
}
|
||||
|
||||
// work around a permission issue from adb push
|
||||
if (geodeFile.exists()) {
|
||||
geodeFile.delete()
|
||||
}
|
||||
|
||||
val renameSuccessful = runCatching {
|
||||
outputFile.renameTo(geodeFile)
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (!renameSuccessful) {
|
||||
// attempt a manual copy if rename fails for whatever reason
|
||||
DownloadUtils.copyFile(outputFile, geodeFile)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
} finally {
|
||||
val tempFileClone = File(tempFilePath)
|
||||
runCatching {
|
||||
try {
|
||||
if (downloadFile.exists()) downloadFile.delete()
|
||||
if (tempFileClone.exists()) tempFileClone.delete()
|
||||
}
|
||||
} catch (_: IOException) {}
|
||||
}
|
||||
|
||||
if (resourcesAsset != null) {
|
||||
@@ -496,15 +517,14 @@ class ReleaseManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTempFile(): File {
|
||||
val geodeName = LaunchUtils.geodeFilename
|
||||
private fun getTempFile(filename: String): File {
|
||||
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||
|
||||
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
|
||||
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
|
||||
|
||||
val suffix = createRandomString()
|
||||
val tmpName = "tmp-$suffix.$geodeName"
|
||||
val tmpName = "tmp-$suffix.$filename"
|
||||
|
||||
val tempFile = File(geodeDirectory, tmpName)
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ import okio.BufferedSource
|
||||
import okio.FileSystem
|
||||
import okio.ForwardingSink
|
||||
import okio.ForwardingSource
|
||||
import okio.HashingSink
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Sink
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
@@ -32,12 +34,31 @@ import java.util.zip.ZipInputStream
|
||||
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
||||
|
||||
object DownloadUtils {
|
||||
suspend fun downloadFile(
|
||||
httpClient: OkHttpClient,
|
||||
url: String,
|
||||
outputFile: File,
|
||||
onProgress: ProgressCallback? = null,
|
||||
): String = downloadStream(
|
||||
httpClient,
|
||||
url,
|
||||
onProgress = onProgress
|
||||
).use { body ->
|
||||
val hashingSink = HashingSink.sha256(outputFile.sink().buffer())
|
||||
hashingSink.use { sink ->
|
||||
body.source().use { source ->
|
||||
source.readAll(sink)
|
||||
}
|
||||
}
|
||||
|
||||
hashingSink.hash.hex()
|
||||
}
|
||||
|
||||
suspend fun downloadStream(
|
||||
httpClient: OkHttpClient,
|
||||
url: String,
|
||||
onProgress: ProgressCallback? = null,
|
||||
onResponse: suspend (ResponseBody) -> Unit,
|
||||
) {
|
||||
): ResponseBody {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
@@ -64,12 +85,15 @@ object DownloadUtils {
|
||||
val progressClient = progressClientBuilder.build()
|
||||
|
||||
val call = progressClient.newCall(request)
|
||||
call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> onResponse(response.body)
|
||||
else -> throw IOException("unexpected response ${response.code}")
|
||||
}
|
||||
val response = call.executeAsync()
|
||||
|
||||
val responseCode = response.code
|
||||
if (responseCode != 200) {
|
||||
response.close()
|
||||
throw IOException("unexpected response ${response.code}")
|
||||
}
|
||||
|
||||
return response.body
|
||||
}
|
||||
|
||||
suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible {
|
||||
|
||||
Reference in new issue
Block a user