Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay

This commit is contained in:
Kelvin K
2025-12-11 14:16:07 -06:00
6 changed files with 146 additions and 26 deletions
@@ -4,6 +4,7 @@ import android.app.Dialog
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -23,6 +24,7 @@ class UpdateDownloadService : Service() {
private const val MAX_RETRIES = 5 private const val MAX_RETRIES = 5
private const val INITIAL_BACKOFF_MS = 5_000L private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024 private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null var updateDownloadedDialog: Dialog? = null
} }
@@ -36,6 +38,8 @@ class UpdateDownloadService : Service() {
@Volatile @Volatile
private var cancelRequested: Boolean = false private var cancelRequested: Boolean = false
private var lastProgressUpdateElapsedMs: Long = 0L
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -81,6 +85,16 @@ class UpdateDownloadService : Service() {
job.cancel() 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) { private suspend fun downloadApk(version: Int) {
val apkFile = StateUpdate.getApkFile(this, version) val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version) val partialFile = StateUpdate.getPartialApkFile(this, version)
@@ -190,12 +204,18 @@ class UpdateDownloadService : Service() {
progress > 100 -> 100 progress > 100 -> 100
else -> progress else -> progress
} }
UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false) throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false)
} }
} else { } 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() output.flush()
} }
} }
@@ -223,12 +243,12 @@ class UpdateDownloadService : Service() {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground, updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded", "Update downloaded",
"Would you like to install it now?", null, 0, "Would you like to install it now?", null, 0,
UIDialogs.Action("Cancel", { UIDialogs.Action("Not now", {
updateDownloadedDialog = null updateDownloadedDialog = null
}, ActionStyle.NONE, true), }, ActionStyle.NONE, true),
UIDialogs.Action("Install", { UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx) UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, apkFile) UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true)); }, ActionStyle.PRIMARY, true));
} catch (t: Throwable) { } catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
@@ -26,15 +26,17 @@ object UpdateInstaller {
private const val TAG = "UpdateInstaller" private const val TAG = "UpdateInstaller"
@SuppressLint("RequestInstallPackagesPolicy") @SuppressLint("RequestInstallPackagesPolicy")
fun startInstall(context: Context, apkFile: File) { fun startInstall(context: Context, version: Int, apkFile: File) {
if (!apkFile.exists()) { if (!apkFile.exists()) {
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}") Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
UIDialogs.toast(context, "Update file missing") UIDialogs.toast(context, "Update file missing")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
return return
} }
if (BuildConfig.IS_PLAYSTORE_BUILD) { if (BuildConfig.IS_PLAYSTORE_BUILD) {
UIDialogs.toast(context, "Updates are managed by the Play Store") UIDialogs.toast(context, "Updates are managed by the Play Store")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
return return
} }
@@ -42,6 +44,7 @@ object UpdateInstaller {
val pm = context.packageManager val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) { if (!pm.canRequestPackageInstalls()) {
UIDialogs.toast(context, "Allow this app to install updates, then try again") 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 { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri() data = "package:${context.packageName}".toUri()
@@ -72,13 +75,16 @@ object UpdateInstaller {
session.fsync(sessionStream) 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 pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val statusReceiver = pendingIntent.intentSender val statusReceiver = pendingIntent.intentSender
InstallReceiver.onReceiveResult.subscribe(this) { message -> InstallReceiver.onReceiveResult.subscribe(this) { message ->
InstallReceiver.onReceiveResult.clear(); InstallReceiver.onReceiveResult.clear();
onReceiveResult(context, message); onReceiveResult(context, version, apkFile, message);
}; };
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}") Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver) session.commit(statusReceiver)
@@ -88,6 +94,8 @@ object UpdateInstaller {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}") UIDialogs.toast(context, "Failed to install update: ${e.message}")
} }
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
} finally { } finally {
session?.close() session?.close()
inputStream?.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)
if (result.isNullOrEmpty()) {
private fun onReceiveResult(context: Context, result: String?) { Logger.i(TAG, "Update install finished successfully")
InstallReceiver.onReceiveResult.remove(this); UpdateNotificationManager.showInstallSucceededNotification(context, version)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n" + result); } 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)
}
} }
} }
@@ -35,6 +35,8 @@ object UpdateNotificationManager {
const val NOTIF_ID_AVAILABLE = 2001 const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002 const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003 const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
fun ensureChannel(context: Context) { fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 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) { fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
@@ -78,6 +112,7 @@ object UpdateNotificationManager {
.setContentText("A new version ($version) is available.") .setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true) .setSilent(true)
.addAction(0, "Never", neverPendingIntent) .addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent) .addAction(0, "Not now", noPendingIntent)
@@ -104,7 +139,7 @@ object UpdateNotificationManager {
.setSmallIcon(R.drawable.foreground) .setSmallIcon(R.drawable.foreground)
.setContentTitle("Downloading update") .setContentTitle("Downloading update")
.setContentText("Downloading version $version") .setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true) .setOngoing(true)
.setSilent(true) .setSilent(true)
.addAction(0, "Cancel", cancelPendingIntent) .addAction(0, "Cancel", cancelPendingIntent)
@@ -141,6 +176,7 @@ object UpdateNotificationManager {
.setContentTitle("Update downloaded") .setContentTitle("Update downloaded")
.setContentText("Tap to install version $version.") .setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(installPendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
.setSilent(true) .setSilent(true)
.addAction(0, "Install", installPendingIntent) .addAction(0, "Install", installPendingIntent)
@@ -166,9 +202,32 @@ object UpdateNotificationManager {
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) 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) { fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE) NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING) NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY) NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
} }
} }
@@ -15,6 +15,8 @@ class InstallUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
UpdateNotificationManager.cancelAll(this)
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH) val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
@@ -32,7 +34,7 @@ class InstallUpdateActivity : AppCompatActivity() {
return return
} }
UpdateInstaller.startInstall(this, apkFile) UpdateInstaller.startInstall(this, version, apkFile)
finish() finish()
} }
@@ -21,6 +21,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -64,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonShowChangelog = findViewById(R.id.button_show_changelog); _buttonShowChangelog = findViewById(R.id.button_show_changelog);
_buttonNever.setOnClickListener { _buttonNever.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
Settings.instance.autoUpdate.check = 1; Settings.instance.autoUpdate.check = 1;
Settings.instance.save(); Settings.instance.save();
dismiss(); dismiss();
}; };
_buttonClose.setOnClickListener { _buttonClose.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
dismiss(); dismiss();
}; };
@@ -79,6 +82,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
}; };
_buttonUpdate.setOnClickListener { _buttonUpdate.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
if (_updating) { if (_updating) {
return@setOnClickListener; return@setOnClickListener;
} }
@@ -600,38 +600,54 @@ class VideoDownload {
} }
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) 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 { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //No callback
} }
val executorService = Executors.newSingleThreadExecutor() val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session -> val session = FFmpegKit.executeAsync(
if (ReturnCode.isSuccess(session.returnCode)) { cmd,
fileList.delete() { completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
continuation.resumeWith(Result.success(Unit)) continuation.resumeWith(Result.success(Unit))
} else { } else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
"Command cancelled" "Command cancelled"
} else { } 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)) continuation.resumeWithException(RuntimeException(errorMessage))
} }
}, },
{ Logger.v(TAG, it.message) }, { log ->
Logger.v(TAG, log.message)
},
statisticsCallback, statisticsCallback,
executorService executorService
) )
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
session.cancel() session.cancel()
executorService.shutdownNow()
} }
} }
} }