Move GitHub Release/App Update logic to data (#9422)

* Move GitHub Release/App Update logic to data

* Add tests for GetApplicationRelease

* Review changes
This commit is contained in:
Andreas
2023-04-30 04:14:49 +02:00
committed by GitHub
parent eed91f6360
commit 02864ebd60
18 changed files with 425 additions and 157 deletions

View File

@@ -2,92 +2,37 @@ package eu.kanade.tachiyomi.data.updater
import android.content.Context
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.system.isInstalledFromFDroid
import kotlinx.serialization.json.Json
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.domain.release.interactor.GetApplicationRelease
import uy.kohesive.injekt.injectLazy
import java.util.Date
import kotlin.time.Duration.Companion.days
class AppUpdateChecker {
private val networkService: NetworkHelper by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy()
private val json: Json by injectLazy()
private val lastAppCheck: Preference<Long> by lazy {
preferenceStore.getLong("last_app_check", 0)
}
suspend fun checkForUpdate(context: Context, isUserPrompt: Boolean = false): AppUpdateResult {
// Limit checks to once every 3 days at most
if (isUserPrompt.not() && Date().time < lastAppCheck.get() + 3.days.inWholeMilliseconds) {
return AppUpdateResult.NoNewUpdate
}
private val getApplicationRelease: GetApplicationRelease by injectLazy()
suspend fun checkForUpdate(context: Context, forceCheck: Boolean = false): GetApplicationRelease.Result {
return withIOContext {
val result = with(json) {
networkService.client
.newCall(GET("https://api.github.com/repos/$GITHUB_REPO/releases/latest"))
.awaitSuccess()
.parseAs<GithubRelease>()
.let {
lastAppCheck.set(Date().time)
// Check if latest version is different from current version
if (isNewVersion(it.version)) {
if (context.isInstalledFromFDroid()) {
AppUpdateResult.NewUpdateFdroidInstallation
} else {
AppUpdateResult.NewUpdate(it)
}
} else {
AppUpdateResult.NoNewUpdate
}
}
}
val result = getApplicationRelease.await(
GetApplicationRelease.Arguments(
BuildConfig.PREVIEW,
context.isInstalledFromFDroid(),
BuildConfig.COMMIT_COUNT.toInt(),
BuildConfig.VERSION_NAME,
GITHUB_REPO,
forceCheck,
),
)
when (result) {
is AppUpdateResult.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release)
is AppUpdateResult.NewUpdateFdroidInstallation -> AppUpdateNotifier(context).promptFdroidUpdate()
is GetApplicationRelease.Result.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release)
is GetApplicationRelease.Result.ThirdPartyInstallation -> AppUpdateNotifier(context).promptFdroidUpdate()
else -> {}
}
result
}
}
private fun isNewVersion(versionTag: String): Boolean {
// Removes prefixes like "r" or "v"
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
return if (BuildConfig.PREVIEW) {
// Preview builds: based on releases in "tachiyomiorg/tachiyomi-preview" repo
// tagged as something like "r1234"
newVersion.toInt() > BuildConfig.COMMIT_COUNT.toInt()
} else {
// Release builds: based on releases in "tachiyomiorg/tachiyomi" repo
// tagged as something like "v0.1.2"
val oldVersion = BuildConfig.VERSION_NAME.replace("[^\\d.]".toRegex(), "")
val newSemVer = newVersion.split(".").map { it.toInt() }
val oldSemVer = oldVersion.split(".").map { it.toInt() }
oldSemVer.mapIndexed { index, i ->
if (newSemVer[index] > i) {
return true
}
}
false
}
}
}
val GITHUB_REPO: String by lazy {

View File

@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notify
import tachiyomi.domain.release.model.Release
internal class AppUpdateNotifier(private val context: Context) {
@@ -27,18 +28,22 @@ internal class AppUpdateNotifier(private val context: Context) {
context.notify(id, build())
}
fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_APP_UPDATER)
}
@SuppressLint("LaunchActivityFromNotification")
fun promptUpdate(release: GithubRelease) {
val intent = Intent(context, AppUpdateService::class.java).apply {
fun promptUpdate(release: Release) {
val updateIntent = Intent(context, AppUpdateService::class.java).run {
putExtra(AppUpdateService.EXTRA_DOWNLOAD_URL, release.getDownloadLink())
putExtra(AppUpdateService.EXTRA_DOWNLOAD_TITLE, release.version)
PendingIntent.getService(context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val updateIntent = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val releaseIntent = Intent(Intent.ACTION_VIEW, release.releaseLink.toUri()).apply {
val releaseIntent = Intent(Intent.ACTION_VIEW, release.releaseLink.toUri()).run {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
PendingIntent.getActivity(context, release.hashCode(), this, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val releaseInfoIntent = PendingIntent.getActivity(context, release.hashCode(), releaseIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
with(notificationBuilder) {
setContentTitle(context.getString(R.string.update_check_notification_update_available))
@@ -55,7 +60,7 @@ internal class AppUpdateNotifier(private val context: Context) {
addAction(
R.drawable.ic_info_24dp,
context.getString(R.string.whats_new),
releaseInfoIntent,
releaseIntent,
)
}
notificationBuilder.show()
@@ -169,8 +174,4 @@ internal class AppUpdateNotifier(private val context: Context) {
}
notificationBuilder.show(Notifications.ID_APP_UPDATER)
}
fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_APP_UPDATER)
}
}

View File

@@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.data.updater
sealed class AppUpdateResult {
class NewUpdate(val release: GithubRelease) : AppUpdateResult()
object NewUpdateFdroidInstallation : AppUpdateResult()
object NoNewUpdate : AppUpdateResult()
}

View File

@@ -20,13 +20,13 @@ import eu.kanade.tachiyomi.util.storage.saveTo
import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.isServiceRunning
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import logcat.LogPriority
import okhttp3.Call
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okhttp3.internal.http2.ErrorCode
import okhttp3.internal.http2.StreamResetException
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.io.File
@@ -38,11 +38,10 @@ class AppUpdateService : Service() {
* Wake lock that will be held until the service is destroyed.
*/
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var notifier: AppUpdateNotifier
private var runningJob: Job? = null
private var runningCall: Call? = null
private val job = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.IO + job)
override fun onCreate() {
notifier = AppUpdateNotifier(this)
@@ -62,11 +61,11 @@ class AppUpdateService : Service() {
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY
val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name)
runningJob = launchIO {
serviceScope.launch {
downloadApk(title, url)
}
runningJob?.invokeOnCompletion { stopSelf(startId) }
job.invokeOnCompletion { stopSelf(startId) }
return START_NOT_STICKY
}
@@ -80,8 +79,8 @@ class AppUpdateService : Service() {
}
private fun destroyJob() {
runningJob?.cancel()
runningCall?.cancel()
serviceScope.cancel()
job.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
@@ -116,9 +115,8 @@ class AppUpdateService : Service() {
try {
// Download the new update.
val call = network.client.newCachelessCallWithProgress(GET(url), progressListener)
runningCall = call
val response = call.await()
val response = network.client.newCachelessCallWithProgress(GET(url), progressListener)
.await()
// File where the apk will be saved.
val apkFile = File(externalCacheDir, "update.apk")
@@ -131,10 +129,9 @@ class AppUpdateService : Service() {
}
notifier.promptInstall(apkFile.getUriCompat(this))
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
if (e is CancellationException ||
val shouldCancel = e is CancellationException ||
(e is StreamResetException && e.errorCode == ErrorCode.CANCEL)
) {
if (shouldCancel) {
notifier.cancel()
} else {
notifier.onDownloadError(url)
@@ -165,11 +162,11 @@ class AppUpdateService : Service() {
fun start(context: Context, url: String, title: String? = context.getString(R.string.app_name)) {
if (isRunning(context)) return
val intent = Intent(context, AppUpdateService::class.java).apply {
Intent(context, AppUpdateService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_TITLE, title)
putExtra(EXTRA_DOWNLOAD_URL, url)
ContextCompat.startForegroundService(context, this)
}
ContextCompat.startForegroundService(context, intent)
}
/**
@@ -188,10 +185,10 @@ class AppUpdateService : Service() {
* @return [PendingIntent]
*/
internal fun downloadApkPendingService(context: Context, url: String): PendingIntent {
val intent = Intent(context, AppUpdateService::class.java).apply {
return Intent(context, AppUpdateService::class.java).run {
putExtra(EXTRA_DOWNLOAD_URL, url)
PendingIntent.getService(context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
}
}

View File

@@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.data.updater
import android.os.Build
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Contains information about the latest release from GitHub.
*/
@Serializable
data class GithubRelease(
@SerialName("tag_name") val version: String,
@SerialName("body") val info: String,
@SerialName("html_url") val releaseLink: String,
@SerialName("assets") private val assets: List<Assets>,
) {
/**
* Get download link of latest release from the assets.
* @return download link of latest release.
*/
fun getDownloadLink(): String {
val apkVariant = when (Build.SUPPORTED_ABIS[0]) {
"arm64-v8a" -> "-arm64-v8a"
"armeabi-v7a" -> "-armeabi-v7a"
"x86" -> "-x86"
"x86_64" -> "-x86_64"
else -> ""
}
return assets.find { it.downloadLink.contains("tachiyomi$apkVariant-") }?.downloadLink
?: assets[0].downloadLink
}
/**
* Assets class containing download url.
*/
@Serializable
data class Assets(@SerialName("browser_download_url") val downloadLink: String)
}