From 3691db8e8e34fca9079f9ccf46a892b2400718e1 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 15 May 2024 18:20:35 +0300 Subject: [PATCH] App udpate activity #880 --- app/src/main/AndroidManifest.xml | 10 +- .../koitharu/kotatsu/main/ui/MainActivity.kt | 9 +- .../kotatsu/settings/SettingsActivity.kt | 3 - .../settings/about/AboutSettingsFragment.kt | 3 +- .../settings/about/AppUpdateActivity.kt | 180 ++++++++++++++++++ .../kotatsu/settings/about/AppUpdateDialog.kt | 89 --------- .../settings/about/AppUpdateViewModel.kt | 102 ++++++++++ .../settings/about/UpdateDownloadReceiver.kt | 38 ---- .../main/res/layout/activity_app_update.xml | 95 +++++++++ 9 files changed, 385 insertions(+), 144 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateViewModel.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/about/UpdateDownloadReceiver.kt create mode 100644 app/src/main/res/layout/activity_app_update.xml 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 @@ + + + + + + + + + + + + + + + + + + + + + +