mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-14 20:39:59 -05: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:
parent
eed91f6360
commit
02864ebd60
18 changed files with 425 additions and 157 deletions
|
@ -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"]
|
Loading…
Reference in a new issue