feat: auto updater (#35)

This commit is contained in:
rushii 2022-10-30 21:21:41 -07:00 • committed by GitHub
parent 8452fc5d28
commit 2c4be41821
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 238 additions and 7 deletions

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.aliucord.manager.domain.manager.PreferencesManager
import com.aliucord.manager.ui.dialog.StoragePermissionsDialog
import com.aliucord.manager.ui.dialog.UpdaterDialog
import com.aliucord.manager.ui.navigation.AppDestination
import com.aliucord.manager.ui.navigation.HomeDestination
import com.aliucord.manager.ui.screen.*
@ -54,6 +55,15 @@ class MainActivity : ComponentActivity() {
StoragePermissionsDialog()
@Suppress("KotlinConstantConditions")
if (
BuildConfig.GIT_BRANCH == "release" &&
!BuildConfig.GIT_LOCAL_CHANGES &&
!BuildConfig.GIT_LOCAL_COMMITS
) {
UpdaterDialog()
}
Taxi(
modifier = Modifier.fillMaxSize(),
navigator = navigator,

View file

@ -11,4 +11,5 @@ val viewModelModule = module {
viewModelOf(::AboutViewModel)
viewModelOf(::InstallViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::UpdaterViewModel)
}

View file

@ -3,11 +3,9 @@ package com.aliucord.manager.domain.manager
import android.app.Application
import android.app.DownloadManager
import android.content.*
import androidx.annotation.StringRes
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.domain.repository.AliucordMavenRepository
import com.aliucord.manager.network.service.GithubService
import java.io.File

View file

@ -15,6 +15,6 @@ class GithubRepository(
suspend fun getDiscordKtVersion() = service.getVersion()
suspend fun getHermesReleases() = service.getHermesReleases()
suspend fun getAliucordNativeReleases() = service.getAliucordNativeReleases()
suspend fun getManagerReleases() = service.getManagerReleases()
}

View file

@ -9,6 +9,9 @@ import android.os.Build
import com.aliucord.manager.installer.service.InstallService
import java.io.File
fun Application.installApks(vararg apks: File) =
packageManager.packageInstaller.installApks(this, *apks)
fun PackageInstaller.installApks(application: Application, vararg apks: File) {
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = createSession(params)

View file

@ -1,9 +1,6 @@
package com.aliucord.manager.network.service
import com.aliucord.manager.network.dto.Commit
import com.aliucord.manager.network.dto.GithubRelease
import com.aliucord.manager.network.dto.GithubUser
import com.aliucord.manager.network.dto.Version
import com.aliucord.manager.network.dto.*
import com.aliucord.manager.network.utils.ApiResponse
import io.ktor.client.request.*
import kotlinx.coroutines.Dispatchers
@ -43,11 +40,18 @@ class GithubService(
}
}
suspend fun getManagerReleases(): ApiResponse<List<GithubRelease>> = withContext(Dispatchers.IO) {
http.request {
url(MANAGER_RELEASES_URL)
}
}
companion object {
private const val ORG = "Aliucord"
private const val REPO = "Aliucord"
private const val HERMES_REPO = "Hermes"
private const val NATIVE_REPO = "AliucordNative"
private const val MANAGER_REPO = "AliucordManager"
private const val COMMITS_URL = "https://api.github.com/repos/$ORG/$REPO/commits"
private const val CONTRIBUTORS_URL = "https://api.github.com/repos/$ORG/$REPO/contributors"
@ -58,5 +62,7 @@ class GithubService(
private const val HERMES_RELEASES_URL = "https://api.github.com/repos/$ORG/$HERMES_REPO/releases"
private const val ALIUCORD_NATIVE_RELEASES_URL = "https://api.github.com/repos/$ORG/$NATIVE_REPO/releases"
const val MANAGER_RELEASES_URL = "https://api.github.com/repos/$ORG/$MANAGER_REPO/releases"
}
}

View file

@ -54,6 +54,13 @@ inline fun <T> ApiResponse<T>.getOrThrow(): T {
)
}
inline fun <T> ApiResponse<T>.getOrNull(): T? {
return fold(
success = { it },
fail = { null }
)
}
@Suppress("UNCHECKED_CAST")
inline fun <T, R> ApiResponse<T>.chain(block: (T) -> ApiResponse<R>): ApiResponse<R> {
return if (this !is ApiResponse.Success) {

View file

@ -0,0 +1,60 @@
package com.aliucord.manager.network.utils
/**
* Parses a Semantic version in the format of `v1.0.0` (v[major].[minor].[patch])
*/
data class SemVer(
val major: Int,
val minor: Int,
val patch: Int,
private val vPrefix: Boolean = false
) : Comparable<SemVer> {
override fun compareTo(other: SemVer): Int {
val pairs = arrayOf(
major to other.major,
minor to other.minor,
patch to other.patch
)
return pairs
.map { (first, second) -> first.compareTo(second) }
.find { it != 0 }
?: 0
}
override fun equals(other: Any?): Boolean {
val ver = other as? SemVer
?: return false
return ver.major == major &&
ver.minor == minor &&
ver.patch == patch
}
override fun toString(): String {
return "${if (vPrefix) "v" else ""}$major.$minor.$patch"
}
override fun hashCode(): Int {
var result = major
result = 31 * result + minor
result = 31 * result + patch
return result
}
companion object {
fun parse(version: String, vPrefix: Boolean = false): SemVer? {
if (vPrefix && version.getOrNull(0) != 'v')
return null
val str = if (vPrefix) version.substring(1) else version
val parts = str
.split(".")
.mapNotNull { it.toIntOrNull() }
.takeIf { it.size == 3 }
?: return null
return SemVer(parts[0], parts[1], parts[2])
}
}
}

View file

@ -0,0 +1,53 @@
package com.aliucord.manager.ui.dialog
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.aliucord.manager.R
import com.aliucord.manager.ui.viewmodel.UpdaterViewModel
import org.koin.androidx.compose.getViewModel
@Composable
fun UpdaterDialog(
viewModel: UpdaterViewModel = getViewModel()
) {
if (!viewModel.showDialog) return
AlertDialog(
confirmButton = {
Button(onClick = viewModel::triggerUpdate) {
if (!viewModel.isWorking) {
Text(stringResource(R.string.action_update))
} else {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onSecondary,
strokeWidth = 2.5.dp,
modifier = Modifier
.size(20.dp)
)
}
}
},
dismissButton = {
Button(
onClick = viewModel::dismissDialog,
enabled = !viewModel.isWorking
) {
Text(stringResource(R.string.action_dismiss))
}
},
onDismissRequest = {},
title = {
Text(stringResource(R.string.updater_title, viewModel.targetVersion ?: ""))
},
text = { Text(stringResource(R.string.updater_body)) },
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}

View file

@ -0,0 +1,89 @@
package com.aliucord.manager.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.domain.manager.DownloadManager
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installer.util.installApks
import com.aliucord.manager.network.utils.SemVer
import com.aliucord.manager.network.utils.getOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class UpdaterViewModel(
private val github: GithubRepository,
private val downloadManager: DownloadManager,
private val application: Application
) : ViewModel() {
var showDialog by mutableStateOf(false)
private set
var targetVersion by mutableStateOf<String?>(null)
private set
var isWorking by mutableStateOf(false)
private set
private var targetApkUrl: String? = null
fun dismissDialog() {
showDialog = false
}
fun triggerUpdate() {
if (isWorking) return
val url = targetApkUrl ?: return
viewModelScope.launch {
isWorking = true
application.externalCacheDir!!.resolve("manager.apk").apply {
delete()
downloadManager.download(url, this)
application.installApks(this)
delay(10000)
showDialog = false
delete()
}
isWorking = false
}
}
// This updates to the latest release based on the tag name
// It finds all releases that have an asset named aliucord-manager-[tagname].apk (this is in case the filename changes in the future)
// Then compares the semantic versioning of the tag name which should be in the v1.0.0 format
// Finds the latest and downloads/installs it
init {
viewModelScope.launch(Dispatchers.IO) {
val (version, asset) = github.getManagerReleases().getOrNull()
?.mapNotNull { release ->
val version = SemVer.parse(release.tagName, vPrefix = true)
?: return@mapNotNull null
val asset = release.assets.find { it.name == "aliucord-manager-${release.tagName}.apk" }
?: return@mapNotNull null
version to asset
}
?.maxByOrNull { it.first }
?: return@launch
val currentVersion = SemVer.parse(BuildConfig.VERSION_NAME)
?: throw Error("Failed to parse app version")
if (currentVersion >= version)
return@launch
targetVersion = version.toString()
targetApkUrl = asset.browserDownloadUrl
showDialog = true
}
}
}

View file

@ -14,6 +14,7 @@
<string name="action_apply">Apply</string>
<string name="action_confirm">Confirm</string>
<string name="action_cancel">Cancel</string>
<string name="action_dismiss">Dismiss</string>
<string name="action_install">Install</string>
<string name="action_reinstall">Reinstall</string>
<string name="action_update">Update</string>
@ -125,4 +126,7 @@
<string name="status_ongoing">Ongoing</string>
<string name="status_failed">Failed</string>
<string name="status_queued">Queued</string>
<string name="updater_body">A new update has been released for Aliucord Manager! It may be required in order to function properly. Would you like to update?</string>
<string name="updater_title">Update to %1$s</string>
</resources>