From 02864ebd60ac9eb974a1b54b06368d20b0ca3ce5 Mon Sep 17 00:00:00 2001 From: Andreas Date: Sun, 30 Apr 2023 04:14:49 +0200 Subject: [PATCH] Move GitHub Release/App Update logic to data (#9422) * Move GitHub Release/App Update logic to data * Add tests for GetApplicationRelease * Review changes --- .../java/eu/kanade/domain/DomainModule.kt | 6 + .../more/settings/screen/AboutScreen.kt | 10 +- .../data/updater/AppUpdateChecker.kt | 85 ++------- .../data/updater/AppUpdateNotifier.kt | 21 +-- .../tachiyomi/data/updater/AppUpdateResult.kt | 7 - .../data/updater/AppUpdateService.kt | 41 ++--- .../tachiyomi/data/updater/GithubRelease.kt | 40 ----- .../kanade/tachiyomi/ui/main/MainActivity.kt | 4 +- data/build.gradle.kts | 10 ++ .../tachiyomi/data/release/GithubRelease.kt | 31 ++++ .../data/release/ReleaseServiceImpl.kt | 25 +++ domain/build.gradle.kts | 9 + .../interactor/GetApplicationRelease.kt | 79 +++++++++ .../tachiyomi/domain/release/model/Release.kt | 35 ++++ .../domain/release/service/ReleaseService.kt | 8 + .../interactor/GetApplicationReleaseTest.kt | 166 ++++++++++++++++++ gradle/kotlinx.versions.toml | 1 + gradle/libs.versions.toml | 4 +- 18 files changed, 425 insertions(+), 157 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateResult.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt create mode 100644 data/src/main/java/tachiyomi/data/release/GithubRelease.kt create mode 100644 data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt create mode 100644 domain/src/main/java/tachiyomi/domain/release/interactor/GetApplicationRelease.kt create mode 100644 domain/src/main/java/tachiyomi/domain/release/model/Release.kt create mode 100644 domain/src/main/java/tachiyomi/domain/release/service/ReleaseService.kt create mode 100644 domain/src/test/java/tachiyomi/domain/release/interactor/GetApplicationReleaseTest.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 571104cd3..6bb2b941f 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/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 { ReleaseServiceImpl(get(), get()) } + addFactory { GetApplicationRelease(get(), get()) } + addSingletonFactory { TrackRepositoryImpl(get()) } addFactory { DeleteTrack(get()) } addFactory { GetTracksPerManga(get()) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt index 1858181c5..5c0b83d9b 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/AboutScreen.kt +++ b/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 -> {} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt index 7f6f7d9d6..aa9e3a615 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt +++ b/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 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() - .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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt index 96d6a7e0d..7c0794bc7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt +++ b/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) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateResult.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateResult.kt deleted file mode 100644 index 695d13492..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateResult.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt index 0fb92cd43..b7a0f9517 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt +++ b/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) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt deleted file mode 100644 index 1d02058fd..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt +++ /dev/null @@ -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, -) { - - /** - * 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) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 089e50b82..0ac5a0fe5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/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, diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 621a7445e..c0deab2a0 100644 --- a/data/build.gradle.kts +++ b/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 { + kotlinOptions.freeCompilerArgs += listOf( + "-Xcontext-receivers", + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", + ) + } +} diff --git a/data/src/main/java/tachiyomi/data/release/GithubRelease.kt b/data/src/main/java/tachiyomi/data/release/GithubRelease.kt new file mode 100644 index 000000000..3677dc122 --- /dev/null +++ b/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, +) + +/** + * 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), + ) +} diff --git a/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt b/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt new file mode 100644 index 000000000..ea2363d53 --- /dev/null +++ b/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() + .let(releaseMapper) + } + } +} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index a57db9d0b..2ce1a4d55 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -22,4 +22,13 @@ dependencies { api(libs.sqldelight.android.paging) testImplementation(libs.bundles.test) + testImplementation(kotlinx.coroutines.test) +} + +tasks { + withType { + kotlinOptions.freeCompilerArgs += listOf( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + ) + } } diff --git a/domain/src/main/java/tachiyomi/domain/release/interactor/GetApplicationRelease.kt b/domain/src/main/java/tachiyomi/domain/release/interactor/GetApplicationRelease.kt new file mode 100644 index 000000000..435d87a29 --- /dev/null +++ b/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 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() + } +} diff --git a/domain/src/main/java/tachiyomi/domain/release/model/Release.kt b/domain/src/main/java/tachiyomi/domain/release/model/Release.kt new file mode 100644 index 000000000..337acdfd4 --- /dev/null +++ b/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, +) { + + /** + * 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) +} diff --git a/domain/src/main/java/tachiyomi/domain/release/service/ReleaseService.kt b/domain/src/main/java/tachiyomi/domain/release/service/ReleaseService.kt new file mode 100644 index 000000000..61bbdb351 --- /dev/null +++ b/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 +} diff --git a/domain/src/test/java/tachiyomi/domain/release/interactor/GetApplicationReleaseTest.kt b/domain/src/test/java/tachiyomi/domain/release/interactor/GetApplicationReleaseTest.kt new file mode 100644 index 000000000..41df15221 --- /dev/null +++ b/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 + + @BeforeEach + fun beforeEach() { + val preferenceStore = mockk() + 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 + } +} diff --git a/gradle/kotlinx.versions.toml b/gradle/kotlinx.versions.toml index 47692b9f5..bdceeb405 100644 --- a/gradle/kotlinx.versions.toml +++ b/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" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11106a7af..c2cd05bac 100644 --- a/gradle/libs.versions.toml +++ b/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"] \ No newline at end of file +test = ["junit", "kotest-assertions", "mockk"] \ No newline at end of file