Implemented feedback for update flow.

This commit is contained in:
Koen J
2026-05-07 22:53:26 +02:00
parent cf3fc61f6a
commit c65cee86b1
11 changed files with 478 additions and 166 deletions
@@ -5,50 +5,14 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
@@ -1,11 +1,12 @@
package com.futo.platformplayer
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -35,18 +36,16 @@ class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : C
return@withContext Result.success()
}
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
}
ContextCompat.startForegroundService(applicationContext, serviceIntent)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
}
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
}
Result.success()
@@ -1,17 +1,15 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import androidx.core.app.NotificationManagerCompat
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
@@ -30,8 +28,6 @@ class UpdateDownloadService : Service() {
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
private val job = SupervisorJob()
@@ -56,6 +52,7 @@ class UpdateDownloadService : Service() {
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
StateUpdate.Companion.instance.clearUi()
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
@@ -75,6 +72,10 @@ class UpdateDownloadService : Service() {
isDownloading = true
cancelRequested = false
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
@@ -97,6 +98,7 @@ class UpdateDownloadService : Service() {
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
if(onProgress != null)
onProgress.invoke(progress);
@@ -159,6 +161,7 @@ class UpdateDownloadService : Service() {
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
StateUpdate.Companion.instance.setUiFailed(version, t.message)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
@@ -264,39 +267,16 @@ class UpdateDownloadService : Service() {
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
StateUpdate.Companion.instance.setUiReady(version, apkFile)
try {
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
AnnouncementType.SESSION,
OffsetDateTime.now(), "update", "Install", {
val ctx = applicationContext
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.", AnnouncementType.SESSION, OffsetDateTime.now(), "update", "Install") {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
});
}
catch(ex: Throwable) {
}
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
}
}
}
} catch (ex: Throwable) {
Logger.w(TAG, "Failed to register install announcement", ex)
}
}
}
@@ -21,6 +21,7 @@ import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@@ -61,6 +62,17 @@ object UpdateInstaller {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val dataLength = apkFile.length()
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
if (usable in 0 until dataLength) {
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
Logger.w(TAG, msg)
withContext(Dispatchers.Main) {
UIDialogs.toast(context, msg)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
return@launch
}
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
@@ -68,7 +80,6 @@ object UpdateInstaller {
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
@@ -91,11 +102,18 @@ object UpdateInstaller {
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
val raw = e.message ?: ""
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
"Not enough storage to install update. Free up some space and try again."
} else {
"Failed to install update: $raw"
}
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
UIDialogs.toast(context, friendly)
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
} finally {
session?.close()
inputStream?.close()
@@ -110,10 +128,12 @@ object UpdateInstaller {
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
StateUpdate.instance.clearUi()
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
StateUpdate.instance.setUiReady(version, apkFile)
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
@@ -22,9 +22,6 @@ object UpdateNotificationManager {
private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
@@ -32,7 +29,6 @@ object UpdateNotificationManager {
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
@@ -84,43 +80,6 @@ object UpdateNotificationManager {
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context)
@@ -223,11 +182,9 @@ object UpdateNotificationManager {
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
fun emit() : Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke();
}
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke();
}
return handled;
}
@@ -88,16 +86,15 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
fun emit(value : T1): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
handled = handled || conditional.handler.invoke(value);
}
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value);
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value);
}
return handled;
}
@@ -106,16 +103,14 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
fun emit(value1 : T1, value2 : T2): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2);
}
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2);
}
return handled;
}
@@ -125,16 +120,14 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
var handled = false;
synchronized(_conditionalListeners) {
for (conditional in _conditionalListeners)
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
for (conditional in condSnapshot)
handled = handled || conditional.handler.invoke(value1, value2, value3);
}
synchronized(_listeners) {
handled = handled || _listeners.isNotEmpty();
for (handler in _listeners)
val snapshot = synchronized(_listeners) { _listeners.toList() };
handled = handled || snapshot.isNotEmpty();
for (handler in snapshot)
handler.handler.invoke(value1, value2, value3);
}
return handled;
}
@@ -668,6 +668,9 @@ class StateApp {
if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.seedUiFromDisk(context)
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
@@ -5,6 +5,7 @@ import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers
@@ -14,7 +15,113 @@ import java.io.File
import java.io.InputStream
import java.io.OutputStream
enum class UpdateUiState { NONE, AVAILABLE, DOWNLOADING, READY, FAILED }
class StateUpdate {
@Volatile var uiState: UpdateUiState = UpdateUiState.NONE
private set
@Volatile var uiVersion: Int = 0
private set
@Volatile var uiProgress: Int = 0
private set
@Volatile var uiIndeterminate: Boolean = true
private set
@Volatile var uiApkFile: File? = null
private set
@Volatile var uiError: String? = null
private set
@Volatile var uiDismissed: Boolean = false
private set
val onUiChanged = Event0()
fun setUiAvailable(version: Int) {
val transitioned = uiState != UpdateUiState.AVAILABLE
uiState = UpdateUiState.AVAILABLE
uiVersion = version
uiError = null
if (transitioned) uiDismissed = false
onUiChanged.emit()
}
fun setUiDownloading(version: Int, progress: Int, indeterminate: Boolean) {
val transitioned = uiState != UpdateUiState.DOWNLOADING
uiState = UpdateUiState.DOWNLOADING
uiVersion = version
uiProgress = progress
uiIndeterminate = indeterminate
uiError = null
if (transitioned) uiDismissed = false
onUiChanged.emit()
}
fun setUiReady(version: Int, apkFile: File) {
val transitioned = uiState != UpdateUiState.READY
uiState = UpdateUiState.READY
uiVersion = version
uiApkFile = apkFile
uiError = null
if (transitioned) uiDismissed = false
onUiChanged.emit()
}
fun setUiFailed(version: Int, error: String?) {
val transitioned = uiState != UpdateUiState.FAILED
uiState = UpdateUiState.FAILED
uiVersion = version
uiError = error
if (transitioned) uiDismissed = false
onUiChanged.emit()
}
fun clearUi() {
uiState = UpdateUiState.NONE
uiVersion = 0
uiProgress = 0
uiIndeterminate = true
uiApkFile = null
uiError = null
uiDismissed = false
onUiChanged.emit()
}
fun dismissUi() {
uiDismissed = true
onUiChanged.emit()
}
fun seedUiFromDisk(context: Context) {
if (uiState != UpdateUiState.NONE) return
try {
val dir = File(context.filesDir, "updates")
if (!dir.exists()) return
val abi = try { DESIRED_ABI } catch (t: Throwable) { return }
val prefix = "app-$abi-"
val suffix = ".apk"
val candidates = dir.listFiles { f ->
f.isFile && f.name.startsWith(prefix) && f.name.endsWith(suffix)
} ?: return
var bestVersion = BuildConfig.VERSION_CODE
var bestFile: File? = null
for (f in candidates) {
val versionStr = f.name.removePrefix(prefix).removeSuffix(suffix)
val v = versionStr.toIntOrNull() ?: continue
if (v > bestVersion && f.length() > 0L) {
bestVersion = v
bestFile = f
}
}
val ready = bestFile
if (ready != null) {
Logger.i(TAG, "Seeding UI ready from disk: v=$bestVersion file=${ready.absolutePath}")
setUiReady(bestVersion, ready)
}
} catch (t: Throwable) {
Logger.w(TAG, "Failed to seed UI from disk", t)
}
}
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
@@ -0,0 +1,186 @@
package com.futo.platformplayer.views.announcements
import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.states.UpdateUiState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class UpdateBannerView : LinearLayout {
private val _root: FrameLayout
private val _iconUpdate: ImageView
private val _textTitle: TextView
private val _textBody: TextView
private val _progressBar: ProgressBar
private val _buttonAction: FrameLayout
private val _textAction: TextView
private val _buttonClose: ImageView
private val _scope: CoroutineScope?
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_update_banner, this)
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull
_root = findViewById(R.id.root)
_iconUpdate = findViewById(R.id.icon_update)
_textTitle = findViewById(R.id.text_title)
_textBody = findViewById(R.id.text_body)
_progressBar = findViewById(R.id.update_banner_progress)
_buttonAction = findViewById(R.id.button_action)
_textAction = findViewById(R.id.text_action)
_buttonClose = findViewById(R.id.button_close)
_buttonClose.setOnClickListener {
StateUpdate.instance.dismissUi()
}
_buttonAction.setOnClickListener {
onActionClicked()
}
refresh()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
StateUpdate.instance.onUiChanged.subscribe(this) {
_scope?.launch(Dispatchers.Main) {
refresh()
}
}
refresh()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
StateUpdate.instance.onUiChanged.remove(this)
}
private fun onActionClicked() {
val st = StateUpdate.instance
when (st.uiState) {
UpdateUiState.READY -> {
val apk = st.uiApkFile ?: return
UpdateNotificationManager.cancelAll(context)
UpdateInstaller.startInstall(context, st.uiVersion, apk)
}
UpdateUiState.FAILED -> {
if (st.uiVersion == 0) return
val intent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (t: Throwable) {
Logger.w(TAG, "Retry start service failed", t)
}
}
UpdateUiState.DOWNLOADING -> {
val intent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (t: Throwable) {
Logger.w(TAG, "Cancel start service failed", t)
}
}
UpdateUiState.AVAILABLE -> {
if (st.uiVersion == 0) return
val intent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, st.uiVersion)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (t: Throwable) {
Logger.w(TAG, "Download start service failed", t)
}
}
UpdateUiState.NONE -> {}
}
}
private fun refresh() {
val st = StateUpdate.instance
val gateOpen = Settings.instance.autoUpdate.shouldBackgroundDownload
val visible = gateOpen && !st.uiDismissed && st.uiState != UpdateUiState.NONE
if (!visible) {
_root.visibility = View.GONE
return
}
_root.visibility = View.VISIBLE
when (st.uiState) {
UpdateUiState.AVAILABLE -> {
_textTitle.text = "Update available (v${st.uiVersion})"
_textBody.text = "A new Grayjay version is available."
_textBody.visibility = View.VISIBLE
_progressBar.visibility = View.GONE
_textAction.text = "Download"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.DOWNLOADING -> {
_textTitle.text = "Downloading update (v${st.uiVersion})"
if (st.uiIndeterminate) {
_textBody.text = "Starting download…"
_progressBar.isIndeterminate = true
} else {
_textBody.text = "${st.uiProgress}% downloaded"
_progressBar.isIndeterminate = false
_progressBar.progress = st.uiProgress
}
_textBody.visibility = View.VISIBLE
_progressBar.visibility = View.VISIBLE
_textAction.text = "Cancel"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.READY -> {
_textTitle.text = "Update v${st.uiVersion} ready"
_textBody.text = "Tap install to apply the update."
_textBody.visibility = View.VISIBLE
_progressBar.visibility = View.GONE
_textAction.text = "Install"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.FAILED -> {
_textTitle.text = "Update failed"
val err = st.uiError
_textBody.text = if (err.isNullOrBlank()) "Could not download v${st.uiVersion}." else err
_textBody.visibility = View.VISIBLE
_progressBar.visibility = View.GONE
_textAction.text = "Retry"
_buttonAction.visibility = View.VISIBLE
}
UpdateUiState.NONE -> {
_root.visibility = View.GONE
}
}
}
companion object {
const val TAG = "UpdateBannerView"
}
}
+10 -1
View File
@@ -20,11 +20,20 @@
app:layout_constraintRight_toRightOf="parent"
tools:layout="@layout/fragment_overview_top_bar" />
<com.futo.platformplayer.views.announcements.UpdateBannerView
android:id="@+id/update_banner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_main"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/fragment_top_bar"
app:layout_constraintTop_toBottomOf="@id/update_banner"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/root">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/background_16_round_4dp"
android:paddingLeft="10dp"
android:paddingTop="10dp"
android:paddingRight="10dp"
android:paddingBottom="10dp"
android:layout_margin="10dp">
<ImageView android:id="@+id/icon_update"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_update"
android:layout_marginEnd="8dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="@id/text_title"
app:layout_constraintBottom_toBottomOf="@id/text_title" />
<TextView android:id="@+id/text_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Downloading update v123"
android:fontFamily="@font/inter_semibold"
android:textSize="15sp"
android:textColor="@color/white"
android:layout_marginStart="8dp"
app:layout_constraintLeft_toRightOf="@id/icon_update"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toLeftOf="@id/button_close" />
<ImageView android:id="@+id/button_close"
android:layout_width="32dp"
android:layout_height="32dp"
android:padding="6dp"
android:src="@drawable/ic_close"
android:contentDescription="@string/dismiss"
android:background="?android:attr/selectableItemBackgroundBorderless"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView android:id="@+id/text_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="42% downloaded"
android:fontFamily="@font/inter_light"
android:textSize="14sp"
android:textColor="#9D9D9D"
android:layout_marginTop="2dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/button_close"
app:layout_constraintTop_toBottomOf="@id/text_title" />
<ProgressBar android:id="@+id/update_banner_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:max="100"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_body" />
<FrameLayout android:id="@+id/button_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="@drawable/background_button_primary_round_4dp"
app:layout_constraintTop_toBottomOf="@id/update_banner_progress"
app:layout_constraintRight_toRightOf="parent">
<TextView android:id="@+id/text_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Install"
android:fontFamily="@font/inter_regular"
android:textSize="14sp"
android:textColor="@color/white"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingLeft="20dp"
android:paddingRight="20dp" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>