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

* Move GitHub Release/App Update logic to data

* Add tests for GetApplicationRelease

* Review changes
This commit is contained in:
Andreas 2023-04-30 04:14:49 +02:00 committed by GitHub
parent eed91f6360
commit 02864ebd60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 425 additions and 157 deletions

View File

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

View File

@ -31,7 +31,6 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.updater.AppUpdateChecker 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.data.updater.RELEASE_URL
import eu.kanade.tachiyomi.ui.more.NewUpdateScreen import eu.kanade.tachiyomi.ui.more.NewUpdateScreen
import eu.kanade.tachiyomi.util.CrashLogUtil import eu.kanade.tachiyomi.util.CrashLogUtil
@ -43,6 +42,7 @@ import logcat.LogPriority
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.lang.withUIContext import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.presentation.core.components.LinkIcon import tachiyomi.presentation.core.components.LinkIcon
import tachiyomi.presentation.core.components.ScrollbarLazyColumn import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold 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. * 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() val updateChecker = AppUpdateChecker()
withUIContext { withUIContext {
context.toast(R.string.update_check_look_for_updates) context.toast(R.string.update_check_look_for_updates)
try { try {
when (val result = withIOContext { updateChecker.checkForUpdate(context, isUserPrompt = true) }) { when (val result = withIOContext { updateChecker.checkForUpdate(context, forceCheck = true) }) {
is AppUpdateResult.NewUpdate -> { is GetApplicationRelease.Result.NewUpdate -> {
onAvailableUpdate(result) onAvailableUpdate(result)
} }
is AppUpdateResult.NoNewUpdate -> { is GetApplicationRelease.Result.NoNewUpdate -> {
context.toast(R.string.update_check_no_new_updates) context.toast(R.string.update_check_no_new_updates)
} }
else -> {} else -> {}

View File

@ -2,92 +2,37 @@ package eu.kanade.tachiyomi.data.updater
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.BuildConfig 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 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.core.util.lang.withIOContext
import tachiyomi.domain.release.interactor.GetApplicationRelease
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.util.Date
import kotlin.time.Duration.Companion.days
class AppUpdateChecker { class AppUpdateChecker {
private val networkService: NetworkHelper by injectLazy() private val getApplicationRelease: GetApplicationRelease 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
}
suspend fun checkForUpdate(context: Context, forceCheck: Boolean = false): GetApplicationRelease.Result {
return withIOContext { return withIOContext {
val result = with(json) { val result = getApplicationRelease.await(
networkService.client GetApplicationRelease.Arguments(
.newCall(GET("https://api.github.com/repos/$GITHUB_REPO/releases/latest")) BuildConfig.PREVIEW,
.awaitSuccess() context.isInstalledFromFDroid(),
.parseAs<GithubRelease>() BuildConfig.COMMIT_COUNT.toInt(),
.let { BuildConfig.VERSION_NAME,
lastAppCheck.set(Date().time) GITHUB_REPO,
forceCheck,
// 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
}
}
}
when (result) { when (result) {
is AppUpdateResult.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release) is GetApplicationRelease.Result.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release)
is AppUpdateResult.NewUpdateFdroidInstallation -> AppUpdateNotifier(context).promptFdroidUpdate() is GetApplicationRelease.Result.ThirdPartyInstallation -> AppUpdateNotifier(context).promptFdroidUpdate()
else -> {} else -> {}
} }
result 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 { val GITHUB_REPO: String by lazy {

View File

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notify import eu.kanade.tachiyomi.util.system.notify
import tachiyomi.domain.release.model.Release
internal class AppUpdateNotifier(private val context: Context) { internal class AppUpdateNotifier(private val context: Context) {
@ -27,18 +28,22 @@ internal class AppUpdateNotifier(private val context: Context) {
context.notify(id, build()) context.notify(id, build())
} }
fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_APP_UPDATER)
}
@SuppressLint("LaunchActivityFromNotification") @SuppressLint("LaunchActivityFromNotification")
fun promptUpdate(release: GithubRelease) { fun promptUpdate(release: Release) {
val intent = Intent(context, AppUpdateService::class.java).apply { val updateIntent = Intent(context, AppUpdateService::class.java).run {
putExtra(AppUpdateService.EXTRA_DOWNLOAD_URL, release.getDownloadLink()) putExtra(AppUpdateService.EXTRA_DOWNLOAD_URL, release.getDownloadLink())
putExtra(AppUpdateService.EXTRA_DOWNLOAD_TITLE, release.version) 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 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) { with(notificationBuilder) {
setContentTitle(context.getString(R.string.update_check_notification_update_available)) setContentTitle(context.getString(R.string.update_check_notification_update_available))
@ -55,7 +60,7 @@ internal class AppUpdateNotifier(private val context: Context) {
addAction( addAction(
R.drawable.ic_info_24dp, R.drawable.ic_info_24dp,
context.getString(R.string.whats_new), context.getString(R.string.whats_new),
releaseInfoIntent, releaseIntent,
) )
} }
notificationBuilder.show() notificationBuilder.show()
@ -169,8 +174,4 @@ internal class AppUpdateNotifier(private val context: Context) {
} }
notificationBuilder.show(Notifications.ID_APP_UPDATER) notificationBuilder.show(Notifications.ID_APP_UPDATER)
} }
fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_APP_UPDATER)
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View 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),
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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