App udpate activity #880

This commit is contained in:
Koitharu
2024-05-15 18:20:35 +03:00
parent e25ccf6b25
commit 3691db8e8e
9 changed files with 385 additions and 144 deletions

View File

@@ -245,6 +245,9 @@
<activity
android:name="org.koitharu.kotatsu.alternatives.ui.AlternativesActivity"
android:label="@string/alternatives" />
<activity
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
android:label="@string/app_update_available" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -357,13 +360,6 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" />
</receiver>
<receiver
android:name="org.koitharu.kotatsu.settings.about.UpdateDownloadReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<receiver
android:name="org.koitharu.kotatsu.core.ErrorReporterReceiver"
android:exported="false">

View File

@@ -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<ActivityMainBinding>(), AppBarOwner, BottomNav
private val viewModel by viewModels<MainViewModel>()
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
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<ActivityMainBinding>(), 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)

View File

@@ -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

View File

@@ -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?) {

View File

@@ -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<ActivityAppUpdateBinding>(), 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<Boolean, Float>) {
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)
}
}
}
}
}

View File

@@ -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)))
}
}

View File

@@ -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<Intent?>(null)
val onDownloadDone = MutableEventFlow<Intent>()
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)
}
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/screen_padding">
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:drawablePadding="16dp"
android:gravity="center_horizontal"
android:text="@string/app_update_available"
android:textAppearance="?textAppearanceHeadline5"
app:drawableTint="?colorPrimary"
app:drawableTopCompat="@drawable/ic_app_update"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/screen_padding"
android:max="100"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_error"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="?colorError"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:text="@string/error_corrupted_file"
tools:visibility="visible" />
<ScrollView
android:id="@+id/scrollView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginVertical="@dimen/screen_padding"
app:layout_constraintBottom_toTopOf="@id/barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_error">
<TextView
android:id="@+id/textView_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?textAppearanceBodyMedium"
tools:text="@tools:sample/lorem/random" />
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/button_cancel"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_update"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/update"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="button_cancel,button_update" />
</androidx.constraintlayout.widget.ConstraintLayout>