Update in-app update checking

This commit is contained in:
Koitharu
2022-07-21 19:32:32 +03:00
parent c158c4e18e
commit 089e3dc209
19 changed files with 232 additions and 105 deletions

View File

@@ -18,7 +18,7 @@ import org.koin.core.context.startKoin
import org.koitharu.kotatsu.bookmarks.bookmarksModule import org.koitharu.kotatsu.bookmarks.bookmarksModule
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.databaseModule import org.koitharu.kotatsu.core.db.databaseModule
import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.github.appUpdateModule
import org.koitharu.kotatsu.core.network.networkModule import org.koitharu.kotatsu.core.network.networkModule
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.uiModule import org.koitharu.kotatsu.core.ui.uiModule
@@ -61,7 +61,7 @@ class KotatsuApp : Application() {
modules( modules(
networkModule, networkModule,
databaseModule, databaseModule,
githubModule, appUpdateModule,
uiModule, uiModule,
mainModule, mainModule,
searchModule, searchModule,
@@ -136,7 +136,7 @@ class KotatsuApp : Application() {
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()
.detectAll() .detectAll()
.penaltyLog() .penaltyLog()
.build() .build(),
) )
StrictMode.setVmPolicy( StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder() StrictMode.VmPolicy.Builder()
@@ -145,7 +145,7 @@ class KotatsuApp : Application() {
.setClassInstanceLimit(PagesCache::class.java, 1) .setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1) .setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog() .penaltyLog()
.build() .build(),
) )
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath() .penaltyDeath()

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.github
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val appUpdateModule
get() = module {
single { AppUpdateRepository(androidContext(), get()) }
}

View File

@@ -0,0 +1,91 @@
package org.koitharu.kotatsu.core.github
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
class AppUpdateRepository(
private val context: Context,
private val okHttp: OkHttpClient,
) {
private val availableUpdate = MutableStateFlow<AppVersion?>(null)
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
suspend fun getAvailableVersions(): List<AppVersion> {
val request = Request.Builder()
.get()
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10")
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
return jsonArray.mapJSONNotNull { json ->
val asset = json.optJSONArray("assets")?.optJSONObject(0) ?: return@mapJSONNotNull null
AppVersion(
id = json.getLong("id"),
url = json.getString("html_url"),
name = json.getString("name").removePrefix("v"),
apkSize = asset.getLong("size"),
apkUrl = asset.getString("browser_download_url"),
description = json.getString("body"),
)
}
}
suspend fun fetchUpdate(): AppVersion? {
if (!isUpdateSupported()) {
return null
}
return runCatching {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId }
if (currentVersion.isStable) {
available.retainAll { it.versionId.isStable }
}
available.maxByOrNull { it.versionId }
?.takeIf { it.versionId > currentVersion }
}.onFailure {
it.printStackTraceDebug()
}.onSuccess {
availableUpdate.value = it
}.getOrNull()
}
fun isUpdateSupported(): Boolean {
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.github package org.koitharu.kotatsu.core.github
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -10,5 +11,9 @@ data class AppVersion(
val url: String, val url: String,
val apkSize: Long, val apkSize: Long,
val apkUrl: String, val apkUrl: String,
val description: String val description: String,
) : Parcelable ) : Parcelable {
@IgnoredOnParcel
val versionId = VersionId(name)
}

View File

@@ -1,8 +0,0 @@
package org.koitharu.kotatsu.core.github
import org.koin.dsl.module
val githubModule
get() = module {
factory { GithubRepository(get()) }
}

View File

@@ -1,25 +0,0 @@
package org.koitharu.kotatsu.core.github
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
class GithubRepository(private val okHttp: OkHttpClient) {
suspend fun getLatestVersion(): AppVersion {
val request = Request.Builder()
.get()
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases/latest")
val json = okHttp.newCall(request.build()).await().parseJson()
val asset = json.getJSONArray("assets").getJSONObject(0)
return AppVersion(
id = json.getLong("id"),
url = json.getString("html_url"),
name = json.getString("name").removePrefix("v"),
apkSize = asset.getLong("size"),
apkUrl = asset.getString("browser_download_url"),
description = json.getString("body")
)
}
}

View File

@@ -63,6 +63,9 @@ class VersionId(
} }
} }
val VersionId.isStable: Boolean
get() = variantType.isEmpty()
fun VersionId(versionName: String): VersionId { fun VersionId(versionName: String): VersionId {
val parts = versionName.substringBeforeLast('-').split('.') val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "") val variant = versionName.substringAfterLast('-', "")
@@ -73,4 +76,4 @@ fun VersionId(versionName: String): VersionId {
variantType = variant.filter(Char::isLetter), variantType = variant.filter(Char::isLetter),
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0, variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0,
) )
} }

View File

@@ -24,6 +24,6 @@ val mainModule
factory { ShortcutsUpdater(androidContext(), get(), get(), get()) } factory { ShortcutsUpdater(androidContext(), get(), get(), get()) }
} }
viewModel { MainViewModel(get(), get()) } viewModel { MainViewModel(get(), get(), get(), get()) }
viewModel { ProtectViewModel(get(), get()) } viewModel { ProtectViewModel(get(), get()) }
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.main.ui package org.koitharu.kotatsu.main.ui
import android.os.Bundle import android.os.Bundle
import android.util.SparseIntArray
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
@@ -9,12 +10,14 @@ import androidx.annotation.IdRes
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.util.size
import androidx.core.view.* import androidx.core.view.*
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.* import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigation.NavigationBarView
@@ -40,7 +43,6 @@ import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.settings.tools.ToolsFragment import org.koitharu.kotatsu.settings.tools.ToolsFragment
@@ -50,7 +52,6 @@ import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary" private const val TAG_PRIMARY = "primary"
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@@ -60,7 +61,8 @@ class MainActivity :
AppBarOwner, AppBarOwner,
View.OnClickListener, View.OnClickListener,
View.OnFocusChangeListener, View.OnFocusChangeListener,
SearchSuggestionListener, NavigationBarView.OnItemSelectedListener { SearchSuggestionListener,
NavigationBarView.OnItemSelectedListener {
private val viewModel by viewModel<MainViewModel>() private val viewModel by viewModel<MainViewModel>()
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>() private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>()
@@ -108,6 +110,7 @@ class MainActivity :
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -269,6 +272,20 @@ class MainActivity :
Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show() Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
} }
private fun onCountersChanged(counters: SparseIntArray) {
repeat(counters.size) { i ->
val id = counters.keyAt(i)
val counter = counters.valueAt(i)
if (counter == 0) {
navBar.getBadge(id)?.isVisible = false
} else {
val badge = navBar.getOrCreateBadge(id)
badge.number = counter
badge.isVisible = true
}
}
}
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
binding.fab?.isEnabled = !isLoading binding.fab?.isEnabled = !isLoading
} }
@@ -330,13 +347,9 @@ class MainActivity :
private fun onFirstStart() { private fun onFirstStart() {
lifecycleScope.launchWhenResumed { lifecycleScope.launchWhenResumed {
val isUpdateSupported = withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext)
AppUpdateChecker.isUpdateSupported(this@MainActivity)
}
if (isUpdateSupported) {
AppUpdateChecker(this@MainActivity).checkIfNeeded()
} }
val settings = get<AppSettings>() val settings = get<AppSettings>()
when { when {
@@ -378,4 +391,4 @@ class MainActivity :
} }
} }
} }
} }

View File

@@ -1,49 +1,54 @@
package org.koitharu.kotatsu.main.ui package org.koitharu.kotatsu.main.ui
import android.util.SparseIntArray
import androidx.core.util.set
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.prefs.AppSection import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class MainViewModel( class MainViewModel(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val appUpdateRepository: AppUpdateRepository,
private val trackingRepository: TrackingRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
var defaultSection: AppSection
get() = settings.defaultSection
set(value) {
settings.defaultSection = value
}
val isSuggestionsEnabled = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_SUGGESTIONS,
valueProducer = { isSuggestionsEnabled },
)
val isTrackerEnabled = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_TRACKER_ENABLED,
valueProducer = { isTrackerEnabled },
)
val isResumeEnabled = historyRepository val isResumeEnabled = historyRepository
.observeHasItems() .observeHasItems()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
val counters = combine(
appUpdateRepository.observeAvailableUpdate(),
trackingRepository.observeUpdatedMangaCount(),
) { appUpdate, tracks ->
val a = SparseIntArray(2)
a[R.id.nav_tools] = if (appUpdate != null) 1 else 0
a[R.id.nav_feed] = tracks
a
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0))
init {
launchJob(Dispatchers.Default) {
appUpdateRepository.fetchUpdate()
}
}
fun openLastReader() { fun openLastReader() {
launchLoadingJob { launchLoadingJob {
val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException()
onOpenReader.call(manga) onOpenReader.call(manga)
} }
} }
} }

View File

@@ -7,31 +7,32 @@ import android.content.pm.PackageManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.net.toUri import androidx.core.net.toUri
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.github.GithubRepository
import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest import java.security.MessageDigest
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import com.google.android.material.R as materialR import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
@Deprecated("")
class AppUpdateChecker(private val activity: ComponentActivity) { class AppUpdateChecker(private val activity: ComponentActivity) {
private val settings = activity.get<AppSettings>() private val settings = activity.get<AppSettings>()
private val repo = activity.get<GithubRepository>() private val repo = activity.get<AppUpdateRepository>()
suspend fun checkIfNeeded(): Boolean? = if ( suspend fun checkIfNeeded(): Boolean? = if (
settings.isUpdateCheckingEnabled && settings.isUpdateCheckingEnabled &&
@@ -43,7 +44,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
} }
suspend fun checkNow() = runCatching { suspend fun checkNow() = runCatching {
val version = repo.getLatestVersion() val version = repo.fetchUpdate() ?: return@runCatching false
val newVersionId = VersionId(version.name) val newVersionId = VersionId(version.name)
val currentVersionId = VersionId(BuildConfig.VERSION_NAME) val currentVersionId = VersionId(BuildConfig.VERSION_NAME)
val result = newVersionId > currentVersionId val result = newVersionId > currentVersionId
@@ -107,4 +108,4 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
error.printStackTraceDebug() error.printStackTraceDebug()
}.getOrNull() }.getOrNull()
} }
} }

View File

@@ -35,5 +35,5 @@ val settingsModule
viewModel { OnboardViewModel(get()) } viewModel { OnboardViewModel(get()) }
viewModel { SourcesSettingsViewModel(get()) } viewModel { SourcesSettingsViewModel(get()) }
viewModel { NewSourcesViewModel(get()) } viewModel { NewSourcesViewModel(get()) }
viewModel { ToolsViewModel(get(), get()) } viewModel { ToolsViewModel(get(), get(), get()) }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.settings.tools package org.koitharu.kotatsu.settings.tools
import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.os.Bundle import android.os.Bundle
import android.transition.TransitionManager import android.transition.TransitionManager
@@ -10,6 +11,7 @@ import android.widget.CompoundButton
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
@@ -19,9 +21,9 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.widgets.SegmentedBarView import org.koitharu.kotatsu.base.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.databinding.FragmentToolsBinding import org.koitharu.kotatsu.databinding.FragmentToolsBinding
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.tools.model.StorageUsage import org.koitharu.kotatsu.settings.tools.model.StorageUsage
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
@@ -32,7 +34,6 @@ class ToolsFragment :
CompoundButton.OnCheckedChangeListener, CompoundButton.OnCheckedChangeListener,
View.OnClickListener { View.OnClickListener {
private var updateChecker: AppUpdateChecker? = null
private val viewModel by viewModel<ToolsViewModel>() private val viewModel by viewModel<ToolsViewModel>()
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentToolsBinding { override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentToolsBinding {
@@ -51,12 +52,19 @@ class ToolsFragment :
binding.switchIncognito.isChecked = it binding.switchIncognito.isChecked = it
} }
viewModel.storageUsage.observe(viewLifecycleOwner, ::onStorageUsageChanged) viewModel.storageUsage.observe(viewLifecycleOwner, ::onStorageUsageChanged)
viewModel.appUpdate.observe(viewLifecycleOwner, ::onAppUpdateAvailable)
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_settings -> startActivity(SettingsActivity.newIntent(v.context)) R.id.button_settings -> startActivity(SettingsActivity.newIntent(v.context))
R.id.button_downloads -> startActivity(DownloadsActivity.newIntent(v.context)) R.id.button_downloads -> startActivity(DownloadsActivity.newIntent(v.context))
R.id.button_download -> {
val url = viewModel.appUpdate.value?.apkUrl ?: return
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url.toUri()
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
}
} }
} }
@@ -72,6 +80,15 @@ class ToolsFragment :
) )
} }
private fun onAppUpdateAvailable(version: AppVersion?) {
if (version == null) {
binding.cardUpdate.root.isVisible = false
return
}
binding.cardUpdate.textPrimary.text = getString(R.string.new_version_s, version.name)
binding.cardUpdate.root.isVisible = true
}
private fun onStorageUsageChanged(usage: StorageUsage) { private fun onStorageUsageChanged(usage: StorageUsage) {
val storageSegment = SegmentedBarView.Segment(usage.savedManga.percent, segmentColor(1)) val storageSegment = SegmentedBarView.Segment(usage.savedManga.percent, segmentColor(1))
val pagesSegment = SegmentedBarView.Segment(usage.pagesCache.percent, segmentColor(2)) val pagesSegment = SegmentedBarView.Segment(usage.pagesCache.percent, segmentColor(2))

View File

@@ -1,10 +1,12 @@
package org.koitharu.kotatsu.settings.tools package org.koitharu.kotatsu.settings.tools
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
@@ -13,9 +15,13 @@ import org.koitharu.kotatsu.settings.tools.model.StorageUsage
class ToolsViewModel( class ToolsViewModel(
private val storageManager: LocalStorageManager, private val storageManager: LocalStorageManager,
private val appUpdateRepository: AppUpdateRepository,
private val settings: AppSettings, private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val appUpdate = appUpdateRepository.observeAvailableUpdate()
.asLiveData(viewModelScope.coroutineContext)
val storageUsage: LiveData<StorageUsage> = liveData( val storageUsage: LiveData<StorageUsage> = liveData(
context = viewModelScope.coroutineContext + Dispatchers.Default, context = viewModelScope.coroutineContext + Dispatchers.Default,
) { ) {

View File

@@ -18,6 +18,9 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int? abstract suspend fun findNewChapters(mangaId: Long): Int?
@Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>>
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract fun observeNewChapters(mangaId: Long): Flow<Int?> abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
@@ -42,4 +45,4 @@ abstract class TracksDao {
insert(entity) insert(entity)
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.room.withTransaction import androidx.room.withTransaction
import java.util.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -18,7 +19,6 @@ import org.koitharu.kotatsu.tracker.data.toTrackingLogItem
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.*
private const val NO_ID = 0L private const val NO_ID = 0L
@@ -34,6 +34,10 @@ class TrackingRepository(
return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 } return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 }
} }
fun observeUpdatedMangaCount(): Flow<Int> {
return db.tracksDao.observeNewChapters().map { list -> list.count { it > 0 } }
}
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> { suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
val ids = mangaList.mapToSet { it.id } val ids = mangaList.mapToSet { it.id }
val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId } val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }
@@ -47,7 +51,7 @@ class TrackingRepository(
result += MangaTracking( result += MangaTracking(
manga = item, manga = item,
lastChapterId = track?.lastChapterId ?: NO_ID, lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date) lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date),
) )
} }
return result return result
@@ -59,7 +63,7 @@ class TrackingRepository(
return MangaTracking( return MangaTracking(
manga = manga, manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID, lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date) lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date),
) )
} }
@@ -166,4 +170,4 @@ class TrackingRepository(
} }
private fun Collection<MangaEntity>.toMangaList() = map { it.toManga(emptySet()) } private fun Collection<MangaEntity>.toMangaList() = map { it.toManga(emptySet()) }
} }

View File

@@ -3,11 +3,11 @@ package org.koitharu.kotatsu.utils.ext
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.utils.BufferedObserver import org.koitharu.kotatsu.utils.BufferedObserver
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) { fun <T> LiveData<T>.requireValue(): T = checkNotNull(value) {
"LiveData value is null" "LiveData value is null"
@@ -22,12 +22,12 @@ fun <T> LiveData<T>.observeWithPrevious(owner: LifecycleOwner, observer: Buffere
} }
fun <T> StateFlow<T>.asLiveDataDistinct( fun <T> StateFlow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext context: CoroutineContext = EmptyCoroutineContext,
): LiveData<T> = asLiveDataDistinct(context, value) ): LiveData<T> = asLiveDataDistinct(context, value)
fun <T> Flow<T>.asLiveDataDistinct( fun <T> Flow<T>.asLiveDataDistinct(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
defaultValue: T defaultValue: T,
): LiveData<T> = liveData(context) { ): LiveData<T> = liveData(context) {
if (latestValue == null) { if (latestValue == null) {
emit(defaultValue) emit(defaultValue)
@@ -37,4 +37,4 @@ fun <T> Flow<T>.asLiveDataDistinct(
emit(it) emit(it)
} }
} }
} }

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@@ -14,7 +15,9 @@
layout="@layout/layout_app_update" layout="@layout/layout_app_update"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_normal" /> android:layout_margin="@dimen/margin_normal"
android:visibility="gone"
tools:visibility="visible" />
<include <include
android:id="@+id/layout_sync" android:id="@+id/layout_sync"

View File

@@ -9,10 +9,10 @@ import org.junit.Test
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
class GithubRepositoryTest { class AppUpdateRepositoryTest {
private val okHttpClient = OkHttpClient() private val okHttpClient = OkHttpClient()
private val repository = GithubRepository(okHttpClient) private val repository = AppUpdateRepository(okHttpClient)
@Test @Test
fun getLatestVersion() = runTest { fun getLatestVersion() = runTest {
@@ -23,11 +23,11 @@ class GithubRepositoryTest {
Request.Builder() Request.Builder()
.url(version.apkUrl) .url(version.apkUrl)
.head() .head()
.build() .build(),
).await() ).await()
Assert.assertTrue(versionId <= VersionId(BuildConfig.VERSION_NAME)) Assert.assertTrue(versionId <= VersionId(BuildConfig.VERSION_NAME))
Assert.assertTrue(apkHead.isSuccessful) Assert.assertTrue(apkHead.isSuccessful)
Assert.assertEquals(version.apkSize, apkHead.headersContentLength()) Assert.assertEquals(version.apkSize, apkHead.headersContentLength())
} }
} }