diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7e68eb1f3..808f8eaa8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -245,6 +245,9 @@
+
-
-
-
-
-
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
index c4a8b772d..a0fb6c91d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.main.ui
import android.Manifest
+import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.Bundle
@@ -66,7 +67,7 @@ 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.SettingsActivity
-import org.koitharu.kotatsu.settings.about.AppUpdateDialog
+import org.koitharu.kotatsu.settings.about.AppUpdateActivity
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -84,7 +85,6 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
private val viewModel by viewModels()
private val searchSuggestionViewModel by viewModels()
private val closeSearchCallback = CloseSearchCallback()
- private val appUpdateDialog = AppUpdateDialog(this)
private lateinit var navigationDelegate: MainNavigationDelegate
private lateinit var appUpdateBadge: OptionsMenuBadgeHelper
@@ -190,9 +190,8 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
}
R.id.action_app_update -> {
- viewModel.appUpdate.value?.also {
- appUpdateDialog.show(it)
- } != null
+ startActivity(Intent(this, AppUpdateActivity::class.java))
+ true
}
else -> super.onOptionsItemSelected(item)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt
index 53a80b7c7..7848a1a71 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt
@@ -29,7 +29,6 @@ import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
-import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
@@ -43,8 +42,6 @@ class SettingsActivity :
AppBarOwner,
FragmentManager.OnBackStackChangedListener {
- val appUpdateDialog = AppUpdateDialog(this)
-
override val appBar: AppBarLayout
get() = viewBinding.appbar
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
index fb2abd9cc..6394cc30d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
-import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -77,7 +76,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
return
}
- (activity as SettingsActivity).appUpdateDialog.show(version)
+ startActivity(Intent(requireContext(), AppUpdateActivity::class.java))
}
private fun openLink(url: String, title: CharSequence?) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt
new file mode 100644
index 000000000..01bdc2b4a
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt
@@ -0,0 +1,180 @@
+package org.koitharu.kotatsu.settings.about
+
+import android.Manifest
+import android.app.DownloadManager
+import android.content.ActivityNotFoundException
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.Insets
+import androidx.core.net.toUri
+import androidx.core.text.buildSpannedString
+import dagger.hilt.android.AndroidEntryPoint
+import io.noties.markwon.Markwon
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.github.AppVersion
+import org.koitharu.kotatsu.core.ui.BaseActivity
+import org.koitharu.kotatsu.core.util.FileSize
+import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
+import org.koitharu.kotatsu.core.util.ext.observe
+import org.koitharu.kotatsu.core.util.ext.observeEvent
+import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
+import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
+import org.koitharu.kotatsu.core.util.ext.showOrHide
+import org.koitharu.kotatsu.core.util.ext.textAndVisible
+import org.koitharu.kotatsu.databinding.ActivityAppUpdateBinding
+
+@AndroidEntryPoint
+class AppUpdateActivity : BaseActivity(), View.OnClickListener {
+
+ private val viewModel: AppUpdateViewModel by viewModels()
+ private lateinit var downloadReceiver: UpdateDownloadReceiver
+
+ private val permissionRequest = registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) {
+ if (it) {
+ viewModel.startDownload()
+ } else {
+ openInBrowser()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityAppUpdateBinding.inflate(layoutInflater))
+ downloadReceiver = UpdateDownloadReceiver(viewModel)
+ viewModel.nextVersion.observe(this, ::onNextVersionChanged)
+ viewBinding.buttonCancel.setOnClickListener(this)
+ viewBinding.buttonUpdate.setOnClickListener(this)
+
+ ContextCompat.registerReceiver(
+ this,
+ downloadReceiver,
+ IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
+ ContextCompat.RECEIVER_EXPORTED,
+ )
+ combine(viewModel.isLoading, viewModel.downloadProgress, ::Pair)
+ .observe(this, ::onProgressChanged)
+ viewModel.downloadState.observe(this, ::onDownloadStateChanged)
+ viewModel.onError.observeEvent(this, ::onError)
+ viewModel.onDownloadDone.observeEvent(this) { intent ->
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ e.printStackTraceDebug()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ unregisterReceiver(downloadReceiver)
+ super.onDestroy()
+ }
+
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_cancel -> finishAfterTransition()
+ R.id.button_update -> doUpdate()
+ }
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
+ viewBinding.root.setPadding(
+ basePadding + insets.left,
+ basePadding + insets.top,
+ basePadding + insets.right,
+ basePadding + insets.bottom,
+ )
+ }
+
+ private suspend fun onNextVersionChanged(version: AppVersion?) {
+ viewBinding.buttonUpdate.isEnabled = version != null && !viewModel.isLoading.value
+ if (version == null) {
+ viewBinding.textViewContent.setText(R.string.loading_)
+ return
+ }
+ val message = withContext(Dispatchers.Default) {
+ buildSpannedString {
+ append(getString(R.string.new_version_s, version.name))
+ appendLine()
+ append(getString(R.string.size_s, FileSize.BYTES.format(this@AppUpdateActivity, version.apkSize)))
+ appendLine()
+ appendLine()
+ append(Markwon.create(this@AppUpdateActivity).toMarkdown(version.description))
+ }
+ }
+ viewBinding.textViewContent.setText(message, TextView.BufferType.SPANNABLE)
+ }
+
+ private fun doUpdate() {
+ viewModel.installIntent.value?.let { intent ->
+ try {
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ onError(e)
+ }
+ return
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ permissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ } else {
+ viewModel.startDownload()
+ }
+ }
+
+ private fun openInBrowser() {
+ val latestVersion = viewModel.nextVersion.value ?: return
+ val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri())
+ startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
+ }
+
+ private fun onProgressChanged(value: Pair) {
+ val (isLoading, downloadProgress) = value
+ val indicator = viewBinding.progressBar
+ indicator.showOrHide(isLoading)
+ indicator.isIndeterminate = downloadProgress <= 0f
+ if (downloadProgress > 0f) {
+ indicator.setProgressCompat((indicator.max * downloadProgress).toInt(), true)
+ }
+ viewBinding.buttonUpdate.isEnabled = !isLoading && viewModel.nextVersion.value != null
+ }
+
+ private fun onDownloadStateChanged(state: Int) {
+ val message = when (state) {
+ DownloadManager.STATUS_FAILED -> R.string.error_occurred
+ DownloadManager.STATUS_PAUSED -> R.string.downloads_paused
+ else -> 0
+ }
+ viewBinding.textViewError.setTextAndVisible(message)
+ }
+
+ private fun onError(e: Throwable) {
+ viewBinding.textViewError.textAndVisible = e.getDisplayMessage(resources)
+ }
+
+ private class UpdateDownloadReceiver(
+ private val viewModel: AppUpdateViewModel,
+ ) : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
+ viewModel.onDownloadComplete(intent)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt
deleted file mode 100644
index a93bc1925..000000000
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt
+++ /dev/null
@@ -1,89 +0,0 @@
-package org.koitharu.kotatsu.settings.about
-
-import android.Manifest
-import android.app.DownloadManager
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import android.os.Environment
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.net.toUri
-import androidx.core.text.buildSpannedString
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import io.noties.markwon.Markwon
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.github.AppVersion
-import org.koitharu.kotatsu.core.util.FileSize
-import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
-import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
-
-class AppUpdateDialog(private val activity: AppCompatActivity) {
-
- private lateinit var latestVersion: AppVersion
-
- private val permissionRequest = activity.registerForActivityResult(
- ActivityResultContracts.RequestPermission(),
- ) {
- if (it) {
- downloadUpdateImpl()
- } else {
- openInBrowser()
- }
- }
-
- fun show(version: AppVersion) {
- latestVersion = version
- val message = buildSpannedString {
- append(activity.getString(R.string.new_version_s, version.name))
- appendLine()
- append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize)))
- appendLine()
- appendLine()
- append(Markwon.create(activity).toMarkdown(version.description))
- }
- MaterialAlertDialogBuilder(activity, DIALOG_THEME_CENTERED)
- .setTitle(R.string.app_update_available)
- .setMessage(message)
- .setIcon(R.drawable.ic_app_update)
- .setNeutralButton(R.string.open_in_browser) { _, _ ->
- val intent = Intent(Intent.ACTION_VIEW, version.url.toUri())
- activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
- }.setPositiveButton(R.string.update) { _, _ ->
- downloadUpdate()
- }.setNegativeButton(android.R.string.cancel, null)
- .setCancelable(false)
- .create()
- .show()
- }
-
- private fun downloadUpdate() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- permissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
- } else {
- downloadUpdateImpl()
- }
- }
-
- private fun downloadUpdateImpl() = runCatching {
- val version = latestVersion
- val url = version.apkUrl.toUri()
- val dm = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
- val request = DownloadManager.Request(url)
- .setTitle("${activity.getString(R.string.app_name)} v${version.name}")
- .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.lastPathSegment)
- .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
- .setMimeType("application/vnd.android.package-archive")
- dm.enqueue(request)
- }.onSuccess {
- Toast.makeText(activity, R.string.download_started, Toast.LENGTH_SHORT).show()
- }.onFailure { e ->
- Toast.makeText(activity, e.getDisplayMessage(activity.resources), Toast.LENGTH_SHORT).show()
- }
-
- private fun openInBrowser() {
- val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri())
- activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
- }
-}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateViewModel.kt
new file mode 100644
index 000000000..7ded312c2
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateViewModel.kt
@@ -0,0 +1,102 @@
+package org.koitharu.kotatsu.settings.about
+
+import android.app.DownloadManager
+import android.content.Context
+import android.content.Intent
+import android.os.Environment
+import androidx.core.net.toUri
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.isActive
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.github.AppUpdateRepository
+import org.koitharu.kotatsu.core.ui.BaseViewModel
+import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
+import org.koitharu.kotatsu.core.util.ext.call
+import org.koitharu.kotatsu.core.util.ext.requireValue
+import javax.inject.Inject
+import kotlin.coroutines.coroutineContext
+
+@HiltViewModel
+class AppUpdateViewModel @Inject constructor(
+ private val repository: AppUpdateRepository,
+ @ApplicationContext context: Context,
+) : BaseViewModel() {
+
+ val nextVersion = repository.observeAvailableUpdate()
+ val downloadProgress = MutableStateFlow(-1f)
+ val downloadState = MutableStateFlow(DownloadManager.STATUS_PENDING)
+ val installIntent = MutableStateFlow(null)
+ val onDownloadDone = MutableEventFlow()
+
+ private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ private val appName = context.getString(R.string.app_name)
+
+ init {
+ if (nextVersion.value == null) {
+ launchLoadingJob(Dispatchers.Default) {
+ repository.fetchUpdate()
+ }
+ }
+ }
+
+ fun startDownload() {
+ launchLoadingJob(Dispatchers.Default) {
+ val version = nextVersion.requireValue()
+ val url = version.apkUrl.toUri()
+ val request = DownloadManager.Request(url)
+ .setTitle("$appName v${version.name}")
+ .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.lastPathSegment)
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ .setMimeType("application/vnd.android.package-archive")
+ val downloadId = downloadManager.enqueue(request)
+ observeDownload(downloadId)
+ }
+ }
+
+ fun onDownloadComplete(intent: Intent) {
+ launchLoadingJob(Dispatchers.Default) {
+ val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
+ if (downloadId == 0L) {
+ return@launchLoadingJob
+ }
+ @Suppress("DEPRECATION")
+ val installerIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
+ installerIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ installerIntent.setDataAndType(
+ downloadManager.getUriForDownloadedFile(downloadId),
+ downloadManager.getMimeTypeForDownloadedFile(downloadId),
+ )
+ installerIntent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
+ installIntent.value = installerIntent
+ onDownloadDone.call(installerIntent)
+ }
+ }
+
+ private suspend fun observeDownload(id: Long) {
+ val query = DownloadManager.Query()
+ query.setFilterById(id)
+ while (coroutineContext.isActive) {
+ downloadManager.query(query).use { cursor ->
+ if (cursor.moveToFirst()) {
+ val bytesDownloaded = cursor.getLong(
+ cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR),
+ )
+ val bytesTotal = cursor.getLong(
+ cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES),
+ )
+ downloadProgress.value = bytesDownloaded.toFloat() / bytesTotal
+ val state = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
+ downloadState.value = state
+ if (state == DownloadManager.STATUS_SUCCESSFUL || state == DownloadManager.STATUS_FAILED) {
+ return
+ }
+ }
+ }
+ delay(100)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/UpdateDownloadReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/UpdateDownloadReceiver.kt
deleted file mode 100644
index bb2b8e9c9..000000000
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/UpdateDownloadReceiver.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.koitharu.kotatsu.settings.about
-
-import android.app.DownloadManager
-import android.content.ActivityNotFoundException
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
-
-
-class UpdateDownloadReceiver : BroadcastReceiver() {
-
- override fun onReceive(context: Context, intent: Intent) {
- when (intent.action) {
- DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
- val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
- if (downloadId == 0L) {
- return
- }
- val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
-
- @Suppress("DEPRECATION")
- val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
- installIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
- installIntent.setDataAndType(
- dm.getUriForDownloadedFile(downloadId),
- dm.getMimeTypeForDownloadedFile(downloadId),
- )
- installIntent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
- try {
- context.startActivity(installIntent)
- } catch (e: ActivityNotFoundException) {
- e.printStackTraceDebug()
- }
- }
- }
- }
-}
diff --git a/app/src/main/res/layout/activity_app_update.xml b/app/src/main/res/layout/activity_app_update.xml
new file mode 100644
index 000000000..0fc0605ed
--- /dev/null
+++ b/app/src/main/res/layout/activity_app_update.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+