浏览代码

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

* Move GitHub Release/App Update logic to data

* Add tests for GetApplicationRelease

* Review changes
Andreas 2 年之前
父节点
当前提交
02864ebd60

+ 6 - 0
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -20,6 +20,7 @@ import tachiyomi.data.category.CategoryRepositoryImpl
 import tachiyomi.data.chapter.ChapterRepositoryImpl
 import tachiyomi.data.history.HistoryRepositoryImpl
 import tachiyomi.data.manga.MangaRepositoryImpl
+import tachiyomi.data.release.ReleaseServiceImpl
 import tachiyomi.data.source.SourceDataRepositoryImpl
 import tachiyomi.data.source.SourceRepositoryImpl
 import tachiyomi.data.track.TrackRepositoryImpl
@@ -56,6 +57,8 @@ import tachiyomi.domain.manga.interactor.NetworkToLocalManga
 import tachiyomi.domain.manga.interactor.ResetViewerFlags
 import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
 import tachiyomi.domain.manga.repository.MangaRepository
+import tachiyomi.domain.release.interactor.GetApplicationRelease
+import tachiyomi.domain.release.service.ReleaseService
 import tachiyomi.domain.source.interactor.GetRemoteManga
 import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
 import tachiyomi.domain.source.repository.SourceDataRepository
@@ -102,6 +105,9 @@ class DomainModule : InjektModule {
         addFactory { UpdateManga(get()) }
         addFactory { SetMangaCategories(get()) }
 
+        addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
+        addFactory { GetApplicationRelease(get(), get()) }
+
         addSingletonFactory<TrackRepository> { TrackRepositoryImpl(get()) }
         addFactory { DeleteTrack(get()) }
         addFactory { GetTracksPerManga(get()) }

+ 5 - 5
app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt

@@ -31,7 +31,6 @@ import eu.kanade.presentation.util.Screen
 import eu.kanade.tachiyomi.BuildConfig
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
-import eu.kanade.tachiyomi.data.updater.AppUpdateResult
 import eu.kanade.tachiyomi.data.updater.RELEASE_URL
 import eu.kanade.tachiyomi.ui.more.NewUpdateScreen
 import eu.kanade.tachiyomi.util.CrashLogUtil
@@ -43,6 +42,7 @@ import logcat.LogPriority
 import tachiyomi.core.util.lang.withIOContext
 import tachiyomi.core.util.lang.withUIContext
 import tachiyomi.core.util.system.logcat
+import tachiyomi.domain.release.interactor.GetApplicationRelease
 import tachiyomi.presentation.core.components.LinkIcon
 import tachiyomi.presentation.core.components.ScrollbarLazyColumn
 import tachiyomi.presentation.core.components.material.Scaffold
@@ -186,16 +186,16 @@ object AboutScreen : Screen() {
     /**
      * Checks version and shows a user prompt if an update is available.
      */
-    private suspend fun checkVersion(context: Context, onAvailableUpdate: (AppUpdateResult.NewUpdate) -> Unit) {
+    private suspend fun checkVersion(context: Context, onAvailableUpdate: (GetApplicationRelease.Result.NewUpdate) -> Unit) {
         val updateChecker = AppUpdateChecker()
         withUIContext {
             context.toast(R.string.update_check_look_for_updates)
             try {
-                when (val result = withIOContext { updateChecker.checkForUpdate(context, isUserPrompt = true) }) {
-                    is AppUpdateResult.NewUpdate -> {
+                when (val result = withIOContext { updateChecker.checkForUpdate(context, forceCheck = true) }) {
+                    is GetApplicationRelease.Result.NewUpdate -> {
                         onAvailableUpdate(result)
                     }
-                    is AppUpdateResult.NoNewUpdate -> {
+                    is GetApplicationRelease.Result.NoNewUpdate -> {
                         context.toast(R.string.update_check_no_new_updates)
                     }
                     else -> {}

+ 15 - 70
app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt

@@ -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 {

+ 11 - 10
app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt

@@ -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)
-    }
 }

+ 0 - 7
app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateResult.kt

@@ -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()
-}

+ 19 - 22
app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt

@@ -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)
         }
     }
 }

+ 0 - 40
app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt

@@ -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)
-}

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

@@ -70,7 +70,6 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
 import eu.kanade.tachiyomi.data.download.DownloadCache
 import eu.kanade.tachiyomi.data.notification.NotificationReceiver
 import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
-import eu.kanade.tachiyomi.data.updater.AppUpdateResult
 import eu.kanade.tachiyomi.data.updater.RELEASE_URL
 import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
 import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
@@ -97,6 +96,7 @@ import logcat.LogPriority
 import tachiyomi.core.Constants
 import tachiyomi.core.util.system.logcat
 import tachiyomi.domain.library.service.LibraryPreferences
+import tachiyomi.domain.release.interactor.GetApplicationRelease
 import tachiyomi.presentation.core.components.material.Scaffold
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
@@ -328,7 +328,7 @@ class MainActivity : BaseActivity() {
             if (BuildConfig.INCLUDE_UPDATER) {
                 try {
                     val result = AppUpdateChecker().checkForUpdate(context)
-                    if (result is AppUpdateResult.NewUpdate) {
+                    if (result is GetApplicationRelease.Result.NewUpdate) {
                         val updateScreen = NewUpdateScreen(
                             versionName = result.release.version,
                             changelogInfo = result.release.info,

+ 10 - 0
data/build.gradle.kts

@@ -1,6 +1,7 @@
 plugins {
     id("com.android.library")
     kotlin("android")
+    kotlin("plugin.serialization")
     id("com.squareup.sqldelight")
 }
 
@@ -28,3 +29,12 @@ dependencies {
     api(libs.sqldelight.coroutines)
     api(libs.sqldelight.android.paging)
 }
+
+tasks {
+    withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
+        kotlinOptions.freeCompilerArgs += listOf(
+            "-Xcontext-receivers",
+            "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
+        )
+    }
+}

+ 31 - 0
data/src/main/java/tachiyomi/data/release/GithubRelease.kt

@@ -0,0 +1,31 @@
+package tachiyomi.data.release
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import tachiyomi.domain.release.model.Release
+
+/**
+ * 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") val assets: List<GitHubAssets>,
+)
+
+/**
+ * Assets class containing download url.
+ */
+@Serializable
+data class GitHubAssets(@SerialName("browser_download_url") val downloadLink: String)
+
+val releaseMapper: (GithubRelease) -> Release = {
+    Release(
+        it.version,
+        it.info,
+        it.releaseLink,
+        it.assets.map(GitHubAssets::downloadLink),
+    )
+}

+ 25 - 0
data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt

@@ -0,0 +1,25 @@
+package tachiyomi.data.release
+
+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 kotlinx.serialization.json.Json
+import tachiyomi.domain.release.model.Release
+import tachiyomi.domain.release.service.ReleaseService
+
+class ReleaseServiceImpl(
+    private val networkService: NetworkHelper,
+    private val json: Json,
+) : ReleaseService {
+
+    override suspend fun latest(repository: String): Release {
+        return with(json) {
+            networkService.client
+                .newCall(GET("https://api.github.com/repos/$repository/releases/latest"))
+                .awaitSuccess()
+                .parseAs<GithubRelease>()
+                .let(releaseMapper)
+        }
+    }
+}

+ 9 - 0
domain/build.gradle.kts

@@ -22,4 +22,13 @@ dependencies {
     api(libs.sqldelight.android.paging)
 
     testImplementation(libs.bundles.test)
+    testImplementation(kotlinx.coroutines.test)
+}
+
+tasks {
+    withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
+        kotlinOptions.freeCompilerArgs += listOf(
+            "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
+        )
+    }
 }

+ 79 - 0
domain/src/main/java/tachiyomi/domain/release/interactor/GetApplicationRelease.kt

@@ -0,0 +1,79 @@
+package tachiyomi.domain.release.interactor
+
+import tachiyomi.core.preference.Preference
+import tachiyomi.core.preference.PreferenceStore
+import tachiyomi.domain.release.model.Release
+import tachiyomi.domain.release.service.ReleaseService
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+class GetApplicationRelease(
+    private val service: ReleaseService,
+    private val preferenceStore: PreferenceStore,
+) {
+
+    private val lastChecked: Preference<Long> by lazy {
+        preferenceStore.getLong("last_app_check", 0)
+    }
+
+    suspend fun await(arguments: Arguments): Result {
+        val now = Instant.now()
+
+        // Limit checks to once every 3 days at most
+        if (arguments.forceCheck.not() && now.isBefore(Instant.ofEpochMilli(lastChecked.get()).plus(3, ChronoUnit.DAYS))) {
+            return Result.NoNewUpdate
+        }
+
+        val release = service.latest(arguments.repository)
+
+        lastChecked.set(now.toEpochMilli())
+
+        // Check if latest version is different from current version
+        val isNewVersion = isNewVersion(arguments.isPreview, arguments.commitCount, arguments.versionName, release.version)
+        return when {
+            isNewVersion && arguments.isThirdParty -> Result.ThirdPartyInstallation
+            isNewVersion -> Result.NewUpdate(release)
+            else -> Result.NoNewUpdate
+        }
+    }
+
+    private fun isNewVersion(isPreview: Boolean, commitCount: Int, versionName: String, versionTag: String): Boolean {
+        // Removes prefixes like "r" or "v"
+        val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
+        return if (isPreview) {
+            // Preview builds: based on releases in "tachiyomiorg/tachiyomi-preview" repo
+            // tagged as something like "r1234"
+            newVersion.toInt() > commitCount
+        } else {
+            // Release builds: based on releases in "tachiyomiorg/tachiyomi" repo
+            // tagged as something like "v0.1.2"
+            val oldVersion = versionName.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
+        }
+    }
+
+    data class Arguments(
+        val isPreview: Boolean,
+        val isThirdParty: Boolean,
+        val commitCount: Int,
+        val versionName: String,
+        val repository: String,
+        val forceCheck: Boolean = false,
+    )
+
+    sealed class Result {
+        class NewUpdate(val release: Release) : Result()
+        object NoNewUpdate : Result()
+        object ThirdPartyInstallation : Result()
+    }
+}

+ 35 - 0
domain/src/main/java/tachiyomi/domain/release/model/Release.kt

@@ -0,0 +1,35 @@
+package tachiyomi.domain.release.model
+
+import android.os.Build
+
+/**
+ * Contains information about the latest release.
+ */
+data class Release(
+    val version: String,
+    val info: String,
+    val releaseLink: String,
+    private val assets: List<String>,
+) {
+
+    /**
+     * 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.contains("tachiyomi$apkVariant-") } ?: assets[0]
+    }
+
+    /**
+     * Assets class containing download url.
+     */
+    data class Assets(val downloadLink: String)
+}

+ 8 - 0
domain/src/main/java/tachiyomi/domain/release/service/ReleaseService.kt

@@ -0,0 +1,8 @@
+package tachiyomi.domain.release.service
+
+import tachiyomi.domain.release.model.Release
+
+interface ReleaseService {
+
+    suspend fun latest(repository: String): Release
+}

+ 166 - 0
domain/src/test/java/tachiyomi/domain/release/interactor/GetApplicationReleaseTest.kt

@@ -0,0 +1,166 @@
+package tachiyomi.domain.release.interactor
+
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import tachiyomi.core.preference.Preference
+import tachiyomi.core.preference.PreferenceStore
+import tachiyomi.domain.release.model.Release
+import tachiyomi.domain.release.service.ReleaseService
+import java.time.Instant
+
+class GetApplicationReleaseTest {
+
+    lateinit var getApplicationRelease: GetApplicationRelease
+    lateinit var releaseService: ReleaseService
+    lateinit var preference: Preference<Long>
+
+    @BeforeEach
+    fun beforeEach() {
+        val preferenceStore = mockk<PreferenceStore>()
+        preference = mockk()
+        every { preferenceStore.getLong(any(), any()) } returns preference
+        releaseService = mockk()
+
+        getApplicationRelease = GetApplicationRelease(releaseService, preferenceStore)
+    }
+
+    @Test
+    fun `When has update but is third party expect third party installation`() = runTest {
+        every { preference.get() } returns 0
+        every { preference.set(any()) }.answers { }
+
+        coEvery { releaseService.latest(any()) } returns Release(
+            "v2.0.0",
+            "info",
+            "http://example.com/release_link",
+            listOf("http://example.com/assets"),
+        )
+
+        val result = getApplicationRelease.await(
+            GetApplicationRelease.Arguments(
+                isPreview = false,
+                isThirdParty = true,
+                commitCount = 0,
+                versionName = "v1.0.0",
+                repository = "test",
+            ),
+        )
+
+        result shouldBe GetApplicationRelease.Result.ThirdPartyInstallation
+    }
+
+    @Test
+    fun `When has update but is preview expect new update`() = runTest {
+        every { preference.get() } returns 0
+        every { preference.set(any()) }.answers { }
+
+        val release = Release(
+            "r2000",
+            "info",
+            "http://example.com/release_link",
+            listOf("http://example.com/assets"),
+        )
+
+        coEvery { releaseService.latest(any()) } returns release
+
+        val result = getApplicationRelease.await(
+            GetApplicationRelease.Arguments(
+                isPreview = true,
+                isThirdParty = false,
+                commitCount = 1000,
+                versionName = "",
+                repository = "test",
+            ),
+        )
+
+        (result as GetApplicationRelease.Result.NewUpdate).release shouldBe GetApplicationRelease.Result.NewUpdate(release).release
+    }
+
+    @Test
+    fun `When has update expect new update`() = runTest {
+        every { preference.get() } returns 0
+        every { preference.set(any()) }.answers { }
+
+        val release = Release(
+            "v2.0.0",
+            "info",
+            "http://example.com/release_link",
+            listOf("http://example.com/assets"),
+        )
+
+        coEvery { releaseService.latest(any()) } returns release
+
+        val result = getApplicationRelease.await(
+            GetApplicationRelease.Arguments(
+                isPreview = false,
+                isThirdParty = false,
+                commitCount = 0,
+                versionName = "v1.0.0",
+                repository = "test",
+            ),
+        )
+
+        (result as GetApplicationRelease.Result.NewUpdate).release shouldBe GetApplicationRelease.Result.NewUpdate(release).release
+    }
+
+    @Test
+    fun `When has no update expect no new update`() = runTest {
+        every { preference.get() } returns 0
+        every { preference.set(any()) }.answers { }
+
+        val release = Release(
+            "v1.0.0",
+            "info",
+            "http://example.com/release_link",
+            listOf("http://example.com/assets"),
+        )
+
+        coEvery { releaseService.latest(any()) } returns release
+
+        val result = getApplicationRelease.await(
+            GetApplicationRelease.Arguments(
+                isPreview = false,
+                isThirdParty = false,
+                commitCount = 0,
+                versionName = "v2.0.0",
+                repository = "test",
+            ),
+        )
+
+        result shouldBe GetApplicationRelease.Result.NoNewUpdate
+    }
+
+    @Test
+    fun `When now is before three days expect no new update`() = runTest {
+        every { preference.get() } returns Instant.now().toEpochMilli()
+        every { preference.set(any()) }.answers { }
+
+        val release = Release(
+            "v1.0.0",
+            "info",
+            "http://example.com/release_link",
+            listOf("http://example.com/assets"),
+        )
+
+        coEvery { releaseService.latest(any()) } returns release
+
+        val result = getApplicationRelease.await(
+            GetApplicationRelease.Arguments(
+                isPreview = false,
+                isThirdParty = false,
+                commitCount = 0,
+                versionName = "v2.0.0",
+                repository = "test",
+            ),
+        )
+
+        coVerify(exactly = 0) { releaseService.latest(any()) }
+        result shouldBe GetApplicationRelease.Result.NoNewUpdate
+    }
+}

+ 1 - 0
gradle/kotlinx.versions.toml

@@ -11,6 +11,7 @@ coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", vers
 coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
 coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" }
 coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava" }
+coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test" }
 
 serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_version" }
 serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization_version" }

+ 3 - 1
gradle/libs.versions.toml

@@ -92,6 +92,8 @@ voyager-transitions = { module = "ca.gosyer:voyager-transitions", version.ref =
 
 kotlinter = "org.jmailen.gradle:kotlinter-gradle:3.13.0"
 
+mockk = "io.mockk:mockk:1.13.5"
+
 [bundles]
 reactivex = ["rxandroid", "rxjava", "rxrelay"]
 okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
@@ -101,4 +103,4 @@ coil = ["coil-core", "coil-gif", "coil-compose"]
 shizuku = ["shizuku-api", "shizuku-provider"]
 voyager = ["voyager-navigator", "voyager-tab-navigator", "voyager-transitions"]
 richtext = ["richtext-commonmark", "richtext-m3"]
-test = ["junit", "kotest-assertions"]
+test = ["junit", "kotest-assertions", "mockk"]