From bda534e4857bad5fd45c4ead40441908777a2db2 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 4 Dec 2025 11:18:00 +0100 Subject: [PATCH 1/2] Various updates to bg update flow: - Throttled progress updates in notifications resolving the notifications not showing under some conditions. - Properly cancel notifications when interacting with in-app dialogs. - Added install failed notification. - Added install success notification. - Added default behavior for tapping on notifications. - Fixed crash in install receiver. --- .../platformplayer/UpdateDownloadService.kt | 28 +++++++-- .../futo/platformplayer/UpdateInstaller.kt | 32 +++++++--- .../UpdateNotificationManager.kt | 61 ++++++++++++++++++- .../activities/InstallUpdateActivity.kt | 4 +- .../dialogs/AutoUpdateDialog.kt | 5 ++ 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt index 485a9ea8..3147ce62 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt @@ -4,6 +4,7 @@ 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 com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp @@ -23,6 +24,7 @@ class UpdateDownloadService : Service() { private const val MAX_RETRIES = 5 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 } @@ -36,6 +38,8 @@ class UpdateDownloadService : Service() { @Volatile private var cancelRequested: Boolean = false + private var lastProgressUpdateElapsedMs: Long = 0L + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -81,6 +85,16 @@ class UpdateDownloadService : Service() { job.cancel() } + private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) { + val now = SystemClock.elapsedRealtime() + val force = progress == 100 && !indeterminate + + if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) { + lastProgressUpdateElapsedMs = now + UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate) + } + } + private suspend fun downloadApk(version: Int) { val apkFile = StateUpdate.getApkFile(this, version) val partialFile = StateUpdate.getPartialApkFile(this, version) @@ -190,12 +204,18 @@ class UpdateDownloadService : Service() { progress > 100 -> 100 else -> progress } - UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false) + throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false) } } else { - UpdateNotificationManager.updateDownloadProgress(this, version, 0, true) + throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true) } } + + if (!cancelRequested && totalBytes > 0L) { + val finalProgress = 100 + throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false) + } + output.flush() } } @@ -223,12 +243,12 @@ class UpdateDownloadService : Service() { updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground, "Update downloaded", "Would you like to install it now?", null, 0, - UIDialogs.Action("Cancel", { + UIDialogs.Action("Not now", { updateDownloadedDialog = null }, ActionStyle.NONE, true), UIDialogs.Action("Install", { UpdateNotificationManager.cancelAll(ctx) - UpdateInstaller.startInstall(ctx, apkFile) + UpdateInstaller.startInstall(ctx, version, apkFile) }, ActionStyle.PRIMARY, true)); } catch (t: Throwable) { Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) diff --git a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt index 72bdfb0d..b81d5096 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt @@ -26,15 +26,17 @@ object UpdateInstaller { private const val TAG = "UpdateInstaller" @SuppressLint("RequestInstallPackagesPolicy") - fun startInstall(context: Context, apkFile: File) { + fun startInstall(context: Context, version: Int, apkFile: File) { if (!apkFile.exists()) { Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}") UIDialogs.toast(context, "Update file missing") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.") return } if (BuildConfig.IS_PLAYSTORE_BUILD) { UIDialogs.toast(context, "Updates are managed by the Play Store") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.") return } @@ -42,6 +44,7 @@ object UpdateInstaller { val pm = context.packageManager if (!pm.canRequestPackageInstalls()) { UIDialogs.toast(context, "Allow this app to install updates, then try again") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.") val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { data = "package:${context.packageName}".toUri() @@ -72,13 +75,16 @@ object UpdateInstaller { session.fsync(sessionStream) } - val intent = Intent(context, InstallReceiver::class.java) + val intent = Intent(context, InstallReceiver::class.java).apply { + putExtra(UpdateNotificationManager.EXTRA_VERSION, version) + putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath) + } val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT) val statusReceiver = pendingIntent.intentSender InstallReceiver.onReceiveResult.subscribe(this) { message -> InstallReceiver.onReceiveResult.clear(); - onReceiveResult(context, message); + onReceiveResult(context, version, apkFile, message); }; Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}") session.commit(statusReceiver) @@ -88,6 +94,8 @@ object UpdateInstaller { withContext(Dispatchers.Main) { UIDialogs.toast(context, "Failed to install update: ${e.message}") } + + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message) } finally { session?.close() inputStream?.close() @@ -95,10 +103,20 @@ object UpdateInstaller { } } + private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) { + try { + InstallReceiver.onReceiveResult.remove(this) - - private fun onReceiveResult(context: Context, result: String?) { - InstallReceiver.onReceiveResult.remove(this); - UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n" + result); + if (result.isNullOrEmpty()) { + Logger.i(TAG, "Update install finished successfully") + UpdateNotificationManager.showInstallSucceededNotification(context, version) + } else { + Logger.w(TAG, "Update install failed: $result") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result) + UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result") + } + } 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 09a39ee9..b424dcd9 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt @@ -35,6 +35,8 @@ object UpdateNotificationManager { 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 + const val NOTIF_ID_INSTALL_SUCCEEDED = 2005 fun ensureChannel(context: Context) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -49,6 +51,38 @@ object UpdateNotificationManager { } } + fun showInstallSucceededNotification(context: Context, version: Int) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + + ensureChannel(context) + + val launchIntent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + } + + val launchPendingIntent = launchIntent?.let { + PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.foreground) + .setContentTitle("Update installed") + .setContentText("Version $version installed. Tap to open.") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setSilent(true) + + if (launchPendingIntent != null) { + builder.setContentIntent(launchPendingIntent) + builder.addAction(0, "Open app", launchPendingIntent) + } + + 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) { @@ -78,6 +112,7 @@ object UpdateNotificationManager { .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) @@ -104,7 +139,7 @@ object UpdateNotificationManager { .setSmallIcon(R.drawable.foreground) .setContentTitle("Downloading update") .setContentText("Downloading version $version") - .setPriority(NotificationCompat.PRIORITY_LOW) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setOngoing(true) .setSilent(true) .addAction(0, "Cancel", cancelPendingIntent) @@ -141,6 +176,7 @@ object UpdateNotificationManager { .setContentTitle("Update downloaded") .setContentText("Tap to install version $version.") .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(installPendingIntent) .setAutoCancel(true) .setSilent(true) .addAction(0, "Install", installPendingIntent) @@ -166,9 +202,32 @@ object UpdateNotificationManager { NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) } + fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) + return + + ensureChannel(context) + + val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath) + val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.foreground) + .setContentTitle("Failed to install update") + .setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.") + .setAutoCancel(true) + .setSilent(true) + .setContentIntent(installPendingIntent) + .addAction(0, "Install again", installPendingIntent) + + NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build()) + } + 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/activities/InstallUpdateActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt index 24e5299b..48d600e5 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt @@ -15,6 +15,8 @@ class InstallUpdateActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + UpdateNotificationManager.cancelAll(this) + val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH) @@ -32,7 +34,7 @@ class InstallUpdateActivity : AppCompatActivity() { return } - UpdateInstaller.startInstall(this, apkFile) + UpdateInstaller.startInstall(this, version, apkFile) finish() } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt index e5cbb322..ccee6082 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -21,6 +21,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UpdateDownloadService +import com.futo.platformplayer.UpdateNotificationManager import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.logging.Logger @@ -64,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { _buttonShowChangelog = findViewById(R.id.button_show_changelog); _buttonNever.setOnClickListener { + UpdateNotificationManager.cancelAll(context) Settings.instance.autoUpdate.check = 1; Settings.instance.save(); dismiss(); }; _buttonClose.setOnClickListener { + UpdateNotificationManager.cancelAll(context) dismiss(); }; @@ -79,6 +82,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { }; _buttonUpdate.setOnClickListener { + UpdateNotificationManager.cancelAll(context) + if (_updating) { return@setOnClickListener; } From 3a11d0d9d13b5bfe1656f6e9849c07afe9fdc59e Mon Sep 17 00:00:00 2001 From: Koen J Date: Fri, 5 Dec 2025 15:31:31 +0100 Subject: [PATCH 2/2] Fixed HLS downloading for Twitch, DialyMotion, Nebula. --- .../platformplayer/downloads/VideoDownload.kt | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 3b727d21..2efa6ec9 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -589,38 +589,54 @@ class VideoDownload { } private suspend fun combineSegments(context: Context, segmentFiles: List, targetFile: File) = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { continuation -> - val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") - fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) + require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" } + + suspendCancellableCoroutine { continuation -> + val concatInput = buildString { + append("concat:") + append( + segmentFiles.joinToString("|") { file -> + file.absolutePath + } + ) + } + + val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\"" - val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> - //TODO: Show progress? + //No callback } val executorService = Executors.newSingleThreadExecutor() - val session = FFmpegKit.executeAsync(cmd, - { session -> - if (ReturnCode.isSuccess(session.returnCode)) { - fileList.delete() + + val session = FFmpegKit.executeAsync( + cmd, + { completedSession -> + executorService.shutdown() + + if (ReturnCode.isSuccess(completedSession.returnCode)) { continuation.resumeWith(Result.success(Unit)) } else { - val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { + val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) { "Command cancelled" } else { - "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" + "Command failed with state '${completedSession.state}' " + + "and return code ${completedSession.returnCode}, " + + "stack trace ${completedSession.failStackTrace}" } - fileList.delete() continuation.resumeWithException(RuntimeException(errorMessage)) } }, - { Logger.v(TAG, it.message) }, + { log -> + Logger.v(TAG, log.message) + }, statisticsCallback, executorService ) continuation.invokeOnCancellation { session.cancel() + executorService.shutdownNow() } } }