mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 22:37:56 +01:00 
			
		
		
		
	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:
		| @@ -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()) } | ||||
|   | ||||
| @@ -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 -> {} | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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
									
								
								data/src/main/java/tachiyomi/data/release/GithubRelease.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								data/src/main/java/tachiyomi/data/release/GithubRelease.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -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), | ||||
|     ) | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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", | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| package tachiyomi.domain.release.service | ||||
|  | ||||
| import tachiyomi.domain.release.model.Release | ||||
|  | ||||
| interface ReleaseService { | ||||
|  | ||||
|     suspend fun latest(repository: String): Release | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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" } | ||||
|   | ||||
| @@ -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"] | ||||
		Reference in New Issue
	
	Block a user