diff --git a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt index 3050a154..89f2e9a2 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt @@ -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) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateCheckWorker.kt b/app/src/main/java/com/futo/platformplayer/UpdateCheckWorker.kt index 549ab71b..d2053f0d 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateCheckWorker.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateCheckWorker.kt @@ -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) - } catch (t: Throwable) { - Logger.w(TAG, "Failed to show in-app update dialog from worker", t) - } - } + try { + 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 start UpdateDownloadService", t) + StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message) } Result.success() diff --git a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt index 7195580c..fbd7edc5 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt @@ -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) + StateUpdate.Companion.instance.setUiReady(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)); - - 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", { - 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 - } - } + try { + 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) { + Logger.w(TAG, "Failed to register install announcement", ex) } } } diff --git a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt index b81d5096..61ce0a7f 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt @@ -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) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt index b424dcd9..43c1ede8 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt @@ -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) - } } diff --git a/app/src/main/java/com/futo/platformplayer/constructs/Event.kt b/app/src/main/java/com/futo/platformplayer/constructs/Event.kt index d3c1d001..2ea16645 100644 --- a/app/src/main/java/com/futo/platformplayer/constructs/Event.kt +++ b/app/src/main/java/com/futo/platformplayer/constructs/Event.kt @@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() { fun emit() : Boolean { var handled = false; - synchronized(_conditionalListeners) { - for (conditional in _conditionalListeners) - handled = handled || conditional.handler.invoke(); - } + 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) - handler.handler.invoke(); - } + 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() : 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) - handler.handler.invoke(value); - } + 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() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() { fun emit(value1 : T1, value2 : T2): Boolean { var handled = false; - synchronized(_conditionalListeners) { - for (conditional in _conditionalListeners) - handled = handled || conditional.handler.invoke(value1, value2); - } + 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) - handler.handler.invoke(value1, value2); - } + 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() : 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) - handled = handled || conditional.handler.invoke(value1, value2, value3); - } + 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) - handler.handler.invoke(value1, value2, value3); - } + val snapshot = synchronized(_listeners) { _listeners.toList() }; + handled = handled || snapshot.isNotEmpty(); + for (handler in snapshot) + handler.handler.invoke(value1, value2, value3); return handled; } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index ac7adbe5..f00ecd2a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -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(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt b/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt index 61fb36d6..fc346e7a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt @@ -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(); diff --git a/app/src/main/java/com/futo/platformplayer/views/announcements/UpdateBannerView.kt b/app/src/main/java/com/futo/platformplayer/views/announcements/UpdateBannerView.kt new file mode 100644 index 00000000..c3d05f05 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/announcements/UpdateBannerView.kt @@ -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" + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 9adf1cd4..774e5496 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -20,11 +20,20 @@ app:layout_constraintRight_toRightOf="parent" tools:layout="@layout/fragment_overview_top_bar" /> + + + + + + + + + + + + + + + + + + + + + +