mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Implemented feedback for update flow.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user