From 089e3dc20908c2b77c6bad749ff2cd14b85d5b41 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 21 Jul 2022 19:32:32 +0300 Subject: [PATCH] Update in-app update checking --- .../java/org/koitharu/kotatsu/KotatsuApp.kt | 8 +- .../kotatsu/core/github/AppUpdateModule.kt | 9 ++ .../core/github/AppUpdateRepository.kt | 91 +++++++++++++++++++ .../kotatsu/core/github/AppVersion.kt | 9 +- .../kotatsu/core/github/GithubModule.kt | 8 -- .../kotatsu/core/github/GithubRepository.kt | 25 ----- .../koitharu/kotatsu/core/github/VersionId.kt | 5 +- .../org/koitharu/kotatsu/main/MainModule.kt | 4 +- .../koitharu/kotatsu/main/ui/MainActivity.kt | 31 +++++-- .../koitharu/kotatsu/main/ui/MainViewModel.kt | 45 +++++---- .../kotatsu/settings/AppUpdateChecker.kt | 33 +++---- .../kotatsu/settings/SettingsModule.kt | 2 +- .../kotatsu/settings/tools/ToolsFragment.kt | 21 ++++- .../kotatsu/settings/tools/ToolsViewModel.kt | 6 ++ .../kotatsu/tracker/data/TracksDao.kt | 5 +- .../tracker/domain/TrackingRepository.kt | 12 ++- .../koitharu/kotatsu/utils/ext/LiveDataExt.kt | 10 +- app/src/main/res/layout/fragment_tools.xml | 5 +- ...toryTest.kt => AppUpdateRepositoryTest.kt} | 8 +- 19 files changed, 232 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateModule.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt rename app/src/test/java/org/koitharu/kotatsu/core/github/{GithubRepositoryTest.kt => AppUpdateRepositoryTest.kt} (87%) diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index 957dc47ea..b94ecdde2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -18,7 +18,7 @@ import org.koin.core.context.startKoin import org.koitharu.kotatsu.bookmarks.bookmarksModule import org.koitharu.kotatsu.core.db.MangaDatabase 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.prefs.AppSettings import org.koitharu.kotatsu.core.ui.uiModule @@ -61,7 +61,7 @@ class KotatsuApp : Application() { modules( networkModule, databaseModule, - githubModule, + appUpdateModule, uiModule, mainModule, searchModule, @@ -136,7 +136,7 @@ class KotatsuApp : Application() { StrictMode.ThreadPolicy.Builder() .detectAll() .penaltyLog() - .build() + .build(), ) StrictMode.setVmPolicy( StrictMode.VmPolicy.Builder() @@ -145,7 +145,7 @@ class KotatsuApp : Application() { .setClassInstanceLimit(PagesCache::class.java, 1) .setClassInstanceLimit(MangaLoaderContext::class.java, 1) .penaltyLog() - .build() + .build(), ) FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() .penaltyDeath() diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateModule.kt new file mode 100644 index 000000000..9e5b0b29e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateModule.kt @@ -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()) } + } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt new file mode 100644 index 000000000..619472a1d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt @@ -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(null) + + fun observeAvailableUpdate() = availableUpdate.asStateFlow() + + suspend fun getAvailableVersions(): List { + 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() +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt index ff6babc95..1dcf7e26f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.github import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -10,5 +11,9 @@ data class AppVersion( val url: String, val apkSize: Long, val apkUrl: String, - val description: String -) : Parcelable \ No newline at end of file + val description: String, +) : Parcelable { + + @IgnoredOnParcel + val versionId = VersionId(name) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt deleted file mode 100644 index 58d8d22c6..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.core.github - -import org.koin.dsl.module - -val githubModule - get() = module { - factory { GithubRepository(get()) } - } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt deleted file mode 100644 index 0176823db..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt +++ /dev/null @@ -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") - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt index 88304755b..4080b8c76 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt @@ -63,6 +63,9 @@ class VersionId( } } +val VersionId.isStable: Boolean + get() = variantType.isEmpty() + fun VersionId(versionName: String): VersionId { val parts = versionName.substringBeforeLast('-').split('.') val variant = versionName.substringAfterLast('-', "") @@ -73,4 +76,4 @@ fun VersionId(versionName: String): VersionId { variantType = variant.filter(Char::isLetter), variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt index 51aa633ec..64d43c526 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt @@ -24,6 +24,6 @@ val mainModule factory { ShortcutsUpdater(androidContext(), get(), get(), get()) } } - viewModel { MainViewModel(get(), get()) } + viewModel { MainViewModel(get(), get(), get(), get()) } viewModel { ProtectViewModel(get(), get()) } - } \ No newline at end of file + } diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index fb463cea5..6ecd72fb9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.main.ui import android.os.Bundle +import android.util.SparseIntArray import android.view.MenuItem import android.view.View import android.view.ViewGroup.MarginLayoutParams @@ -9,12 +10,14 @@ import androidx.annotation.IdRes import androidx.appcompat.view.ActionMode import androidx.core.app.ActivityOptionsCompat import androidx.core.graphics.Insets +import androidx.core.util.size import androidx.core.view.* import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope 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.LayoutParams.* 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.SearchSuggestionListener 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.onboard.OnboardDialogFragment 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.utils.VoiceInputContract import org.koitharu.kotatsu.utils.ext.* -import com.google.android.material.R as materialR private const val TAG_PRIMARY = "primary" private const val TAG_SEARCH = "search" @@ -60,7 +61,8 @@ class MainActivity : AppBarOwner, View.OnClickListener, View.OnFocusChangeListener, - SearchSuggestionListener, NavigationBarView.OnItemSelectedListener { + SearchSuggestionListener, + NavigationBarView.OnItemSelectedListener { private val viewModel by viewModel() private val searchSuggestionViewModel by viewModel() @@ -108,6 +110,7 @@ class MainActivity : viewModel.onError.observe(this, this::onError) viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) + viewModel.counters.observe(this, ::onCountersChanged) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -269,6 +272,20 @@ class MainActivity : 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) { binding.fab?.isEnabled = !isLoading } @@ -330,13 +347,9 @@ class MainActivity : private fun onFirstStart() { lifecycleScope.launchWhenResumed { - val isUpdateSupported = withContext(Dispatchers.Default) { + withContext(Dispatchers.Default) { TrackWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext) - AppUpdateChecker.isUpdateSupported(this@MainActivity) - } - if (isUpdateSupported) { - AppUpdateChecker(this@MainActivity).checkIfNeeded() } val settings = get() when { @@ -378,4 +391,4 @@ class MainActivity : } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt index 139e9939f..1362ff449 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -1,49 +1,54 @@ package org.koitharu.kotatsu.main.ui +import android.util.SparseIntArray +import androidx.core.util.set import androidx.lifecycle.viewModelScope 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.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.observeAsLiveData import org.koitharu.kotatsu.history.domain.HistoryRepository 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.ext.asLiveDataDistinct class MainViewModel( private val historyRepository: HistoryRepository, private val settings: AppSettings, + private val appUpdateRepository: AppUpdateRepository, + private val trackingRepository: TrackingRepository, ) : BaseViewModel() { val onOpenReader = SingleLiveEvent() - 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 .observeHasItems() .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() { launchLoadingJob { val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() onOpenReader.call(manga) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt index f148969f5..1308de583 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt @@ -7,31 +7,32 @@ import android.content.pm.PackageManager import androidx.activity.ComponentActivity import androidx.annotation.MainThread import androidx.core.net.toUri +import com.google.android.material.R as materialR 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.InputStream import java.security.MessageDigest import java.security.cert.CertificateFactory import java.security.cert.X509Certificate 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) { private val settings = activity.get() - private val repo = activity.get() + private val repo = activity.get() suspend fun checkIfNeeded(): Boolean? = if ( settings.isUpdateCheckingEnabled && @@ -43,7 +44,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) { } suspend fun checkNow() = runCatching { - val version = repo.getLatestVersion() + val version = repo.fetchUpdate() ?: return@runCatching false val newVersionId = VersionId(version.name) val currentVersionId = VersionId(BuildConfig.VERSION_NAME) val result = newVersionId > currentVersionId @@ -107,4 +108,4 @@ class AppUpdateChecker(private val activity: ComponentActivity) { error.printStackTraceDebug() }.getOrNull() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt index d8b2c3b0e..35371fbd7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt @@ -35,5 +35,5 @@ val settingsModule viewModel { OnboardViewModel(get()) } viewModel { SourcesSettingsViewModel(get()) } viewModel { NewSourcesViewModel(get()) } - viewModel { ToolsViewModel(get(), get()) } + viewModel { ToolsViewModel(get(), get(), get()) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt index a9987c6c1..0fd64bb6a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.settings.tools +import android.content.Intent import android.content.res.ColorStateList import android.os.Bundle import android.transition.TransitionManager @@ -10,6 +11,7 @@ import android.widget.CompoundButton import androidx.annotation.ColorInt import androidx.core.graphics.ColorUtils import androidx.core.graphics.Insets +import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.core.view.updatePadding 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.base.ui.BaseFragment 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.download.ui.DownloadsActivity -import org.koitharu.kotatsu.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.tools.model.StorageUsage import org.koitharu.kotatsu.utils.FileSize @@ -32,7 +34,6 @@ class ToolsFragment : CompoundButton.OnCheckedChangeListener, View.OnClickListener { - private var updateChecker: AppUpdateChecker? = null private val viewModel by viewModel() override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentToolsBinding { @@ -51,12 +52,19 @@ class ToolsFragment : binding.switchIncognito.isChecked = it } viewModel.storageUsage.observe(viewLifecycleOwner, ::onStorageUsageChanged) + viewModel.appUpdate.observe(viewLifecycleOwner, ::onAppUpdateAvailable) } override fun onClick(v: View) { when (v.id) { R.id.button_settings -> startActivity(SettingsActivity.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) { val storageSegment = SegmentedBarView.Segment(usage.savedManga.percent, segmentColor(1)) val pagesSegment = SegmentedBarView.Segment(usage.pagesCache.percent, segmentColor(2)) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt index 1929b63ff..06b581304 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/tools/ToolsViewModel.kt @@ -1,10 +1,12 @@ package org.koitharu.kotatsu.settings.tools import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers 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.observeAsLiveData import org.koitharu.kotatsu.local.data.CacheDir @@ -13,9 +15,13 @@ import org.koitharu.kotatsu.settings.tools.model.StorageUsage class ToolsViewModel( private val storageManager: LocalStorageManager, + private val appUpdateRepository: AppUpdateRepository, private val settings: AppSettings, ) : BaseViewModel() { + val appUpdate = appUpdateRepository.observeAvailableUpdate() + .asLiveData(viewModelScope.coroutineContext) + val storageUsage: LiveData = liveData( context = viewModelScope.coroutineContext + Dispatchers.Default, ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt index 2fed9de12..118480f75 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt @@ -18,6 +18,9 @@ abstract class TracksDao { @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") abstract suspend fun findNewChapters(mangaId: Long): Int? + @Query("SELECT chapters_new FROM tracks") + abstract fun observeNewChapters(): Flow> + @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") abstract fun observeNewChapters(mangaId: Long): Flow @@ -42,4 +45,4 @@ abstract class TracksDao { insert(entity) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index 7586a7f50..9f2f77341 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.tracker.domain import androidx.annotation.VisibleForTesting import androidx.room.withTransaction +import java.util.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map 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.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem -import java.util.* private const val NO_ID = 0L @@ -34,6 +34,10 @@ class TrackingRepository( return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 } } + fun observeUpdatedMangaCount(): Flow { + return db.tracksDao.observeNewChapters().map { list -> list.count { it > 0 } } + } + suspend fun getTracks(mangaList: Collection): List { val ids = mangaList.mapToSet { it.id } val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId } @@ -47,7 +51,7 @@ class TrackingRepository( result += MangaTracking( manga = item, lastChapterId = track?.lastChapterId ?: NO_ID, - lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date) + lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date), ) } return result @@ -59,7 +63,7 @@ class TrackingRepository( return MangaTracking( manga = manga, 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.toMangaList() = map { it.toManga(emptySet()) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt index 769aec051..6024452dd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -3,11 +3,11 @@ package org.koitharu.kotatsu.utils.ext import androidx.lifecycle.LifecycleOwner 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.StateFlow import org.koitharu.kotatsu.utils.BufferedObserver -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext fun LiveData.requireValue(): T = checkNotNull(value) { "LiveData value is null" @@ -22,12 +22,12 @@ fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: Buffere } fun StateFlow.asLiveDataDistinct( - context: CoroutineContext = EmptyCoroutineContext + context: CoroutineContext = EmptyCoroutineContext, ): LiveData = asLiveDataDistinct(context, value) fun Flow.asLiveDataDistinct( context: CoroutineContext = EmptyCoroutineContext, - defaultValue: T + defaultValue: T, ): LiveData = liveData(context) { if (latestValue == null) { emit(defaultValue) @@ -37,4 +37,4 @@ fun Flow.asLiveDataDistinct( emit(it) } } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/fragment_tools.xml b/app/src/main/res/layout/fragment_tools.xml index 3e9a5694e..8219cdaac 100644 --- a/app/src/main/res/layout/fragment_tools.xml +++ b/app/src/main/res/layout/fragment_tools.xml @@ -1,6 +1,7 @@ @@ -14,7 +15,9 @@ layout="@layout/layout_app_update" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_margin="@dimen/margin_normal" /> + android:layout_margin="@dimen/margin_normal" + android:visibility="gone" + tools:visibility="visible" />