diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f22adf15..9cafddaa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -252,5 +252,14 @@
android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
+
+
+
+
diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt
index 59917e75..6090aa56 100644
--- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt
+++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt
@@ -403,13 +403,6 @@ class UIDialogs {
dialog.setMaxVersion(lastVersion);
}
- fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
- val dialog = AutoUpdateDialog(context);
- registerDialogOpened(dialog);
- dialog.setOnDismissListener { registerDialogClosed(dialog) };
- dialog.showPredownloaded(apkFile);
- }
-
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
if(!store.hasMissingReconstructions())
onConcluded();
diff --git a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt
new file mode 100644
index 00000000..42f452f0
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt
@@ -0,0 +1,90 @@
+package com.futo.platformplayer
+
+import android.content.BroadcastReceiver
+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.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)
+ UpdateNotificationManager.ACTION_INSTALL_NOW -> handleInstallNow(context, intent)
+ }
+ }
+
+ private fun handleUpdateYes(context: Context, intent: Intent) {
+ val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
+ if (version == 0) {
+ return
+ }
+
+ NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
+
+ if (Settings.instance.autoUpdate.backgroundDownload == 1) {
+ val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
+ putExtra(UpdateDownloadService.EXTRA_VERSION, version)
+ }
+ ContextCompat.startForegroundService(context, serviceIntent)
+ } else {
+ if (StateApp.instance.isMainActive) {
+ StateApp.withContext { ctx ->
+ UIDialogs.showUpdateAvailableDialog(ctx, version, false)
+ }
+ } else {
+ val startIntent = Intent(context, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ putExtra("SHOW_UPDATE_DIALOG_VERSION", version)
+ }
+ context.startActivity(startIntent)
+ }
+ }
+ }
+
+ private fun handleUpdateNo(context: Context) {
+ NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
+ }
+
+ private fun handleUpdateNever(context: Context) {
+ 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)
+
+ val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
+ putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
+ putExtra(UpdateDownloadService.EXTRA_VERSION, version)
+ }
+ ContextCompat.startForegroundService(context, cancelIntent)
+
+ NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
+ }
+
+ private fun handleInstallNow(context: Context, intent: Intent) {
+ val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
+ val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
+
+ if (version == 0 || apkPath.isNullOrEmpty()) {
+ return
+ }
+
+ val apkFile = File(apkPath)
+ if (!apkFile.exists()) {
+ return
+ }
+
+ UpdateNotificationManager.cancelAll(context)
+ UpdateInstaller.startInstall(context, apkFile)
+ }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/UpdateCheckWorker.kt b/app/src/main/java/com/futo/platformplayer/UpdateCheckWorker.kt
new file mode 100644
index 00000000..549ab71b
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/UpdateCheckWorker.kt
@@ -0,0 +1,64 @@
+package com.futo.platformplayer
+
+import android.content.Context
+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
+
+class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result {
+ if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
+ Logger.i(TAG, "Auto-update disabled, skipping worker run")
+ return Result.success()
+ }
+
+ return withContext(Dispatchers.IO) {
+ try {
+ val client = ManagedHttpClient()
+ val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
+
+ if (latestVersion == null) {
+ Logger.w(TAG, "Failed to fetch latest version in worker")
+ return@withContext Result.retry()
+ }
+
+ val currentVersion = BuildConfig.VERSION_CODE
+ Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
+
+ if (latestVersion <= currentVersion) {
+ return@withContext Result.success()
+ }
+
+ UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, 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)
+ }
+ }
+ }
+ }
+
+ Result.success()
+ } catch (t: Throwable) {
+ Logger.w(TAG, "Exception in UpdateCheckWorker", t)
+ Result.retry()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "UpdateCheckWorker"
+ const val UNIQUE_WORK_NAME = "updateCheck"
+ }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt
new file mode 100644
index 00000000..fe01051a
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt
@@ -0,0 +1,230 @@
+package com.futo.platformplayer
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.states.StateApp
+import com.futo.platformplayer.states.StateUpdate
+import kotlinx.coroutines.*
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+class UpdateDownloadService : Service() {
+
+ companion object {
+ private const val TAG = "UpdateDownloadService"
+ const val EXTRA_VERSION = "version"
+ const val EXTRA_CANCEL = "cancel"
+ private const val MAX_RETRIES = 5
+ private const val INITIAL_BACKOFF_MS = 5_000L
+ private const val BUFFER_SIZE = 8 * 1024
+ }
+
+ private val job = SupervisorJob()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+
+ @Volatile
+ private var isDownloading: Boolean = false
+
+ @Volatile
+ private var cancelRequested: Boolean = false
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent == null) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
+ cancelRequested = true
+ Logger.i(TAG, "Download cancel requested")
+ stopForeground(Service.STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ val version = intent.getIntExtra(EXTRA_VERSION, 0)
+ if (version == 0) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ if (isDownloading) {
+ Logger.i(TAG, "Download already in progress, ignoring new start")
+ return START_STICKY
+ }
+
+ isDownloading = true
+ cancelRequested = false
+
+ val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
+ startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
+
+ scope.launch {
+ downloadApk(version)
+ }
+
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ job.cancel()
+ }
+
+ private suspend fun downloadApk(version: Int) {
+ val apkFile = StateUpdate.getApkFile(this, version)
+ val partialFile = StateUpdate.getPartialApkFile(this, version)
+
+ try {
+ if (apkFile.exists() && apkFile.length() > 0L) {
+ Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
+ onDownloadComplete(version, apkFile)
+ return
+ }
+
+ var backoffMs = INITIAL_BACKOFF_MS
+
+ for (attempt in 0 until MAX_RETRIES) {
+ if (cancelRequested) {
+ Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
+ break
+ }
+
+ try {
+ performDownload(StateUpdate.APK_URL, partialFile, version)
+
+ if (!cancelRequested) {
+ if (apkFile.exists()) {
+ apkFile.delete()
+ }
+ if (!partialFile.renameTo(apkFile)) {
+ throw IllegalStateException("Failed to rename partial APK file")
+ }
+ onDownloadComplete(version, apkFile)
+ }
+ break
+ } catch (t: Throwable) {
+ if (cancelRequested) {
+ Logger.i(TAG, "Download cancelled by user", t)
+ break
+ }
+
+ if (attempt == MAX_RETRIES - 1) {
+ Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
+ UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
+ break
+ } else {
+ Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
+ delay(backoffMs)
+ backoffMs *= 2
+ }
+ }
+ }
+ } finally {
+ isDownloading = false
+ cancelRequested = false
+ stopForeground(Service.STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+
+ private fun performDownload(url: String, partialFile: File, version: Int) {
+ var startOffset = if (partialFile.exists()) partialFile.length() else 0L
+ Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
+
+ var connection: HttpURLConnection? = null
+ try {
+ connection = (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15_000
+ readTimeout = 30_000
+ if (startOffset > 0L) {
+ setRequestProperty("Range", "bytes=$startOffset-")
+ }
+ }
+
+ connection.connect()
+ val responseCode = connection.responseCode
+
+ if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
+ Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
+ partialFile.delete()
+ startOffset = 0L
+ } else if (responseCode != HttpURLConnection.HTTP_OK &&
+ responseCode != HttpURLConnection.HTTP_PARTIAL) {
+ throw IllegalStateException("Unexpected HTTP response code $responseCode")
+ }
+
+ val contentLength = connection.contentLengthLong
+ val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
+
+ val buffer = ByteArray(BUFFER_SIZE)
+ var downloaded = 0L
+ var lastProgress = -1
+
+ connection.inputStream.use { input ->
+ FileOutputStream(partialFile, startOffset > 0L).use { output ->
+ while (!cancelRequested) {
+ val read = input.read(buffer)
+ if (read == -1) {
+ break
+ }
+ output.write(buffer, 0, read)
+ downloaded += read
+
+ if (totalBytes > 0L) {
+ val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
+ if (progress != lastProgress) {
+ lastProgress = progress
+ val safeProgress = when {
+ progress < 0 -> 0
+ progress > 100 -> 100
+ else -> progress
+ }
+ UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false)
+ }
+ } else {
+ UpdateNotificationManager.updateDownloadProgress(this, version, 0, true)
+ }
+ }
+ output.flush()
+ }
+ }
+
+ if (cancelRequested) {
+ throw CancellationException("Download cancelled")
+ }
+
+ if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
+ throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
+ }
+ } finally {
+ connection?.disconnect()
+ }
+ }
+
+ 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 {
+ UIDialogs.showConfirmationDialog(ctx, "Update downloaded, press confirm to install", {
+ UpdateNotificationManager.cancelAll(ctx)
+ UpdateInstaller.startInstall(ctx, apkFile)
+ }, {})
+ } 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
new file mode 100644
index 00000000..4f45ed0a
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt
@@ -0,0 +1,89 @@
+package com.futo.platformplayer
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent.FLAG_MUTABLE
+import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.app.PendingIntent.getBroadcast
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.provider.Settings
+import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.receivers.InstallReceiver
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.InputStream
+import androidx.core.net.toUri
+
+object UpdateInstaller {
+ private const val TAG = "UpdateInstaller"
+
+ @SuppressLint("RequestInstallPackagesPolicy")
+ fun startInstall(context: Context, apkFile: File) {
+ if (!apkFile.exists()) {
+ Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
+ UIDialogs.toast(context, "Update file missing")
+ return
+ }
+
+ if (BuildConfig.IS_PLAYSTORE_BUILD) {
+ UIDialogs.toast(context, "Updates are managed by the Play Store")
+ return
+ }
+
+ try {
+ val pm = context.packageManager
+ if (!pm.canRequestPackageInstalls()) {
+ UIDialogs.toast(context, "Allow this app to install updates, then try again")
+
+ val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
+ data = "package:${context.packageName}".toUri()
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ return
+ }
+ } catch (t: Throwable) {
+ Logger.e(TAG, "Failed to check unknown sources permission", t)
+ }
+
+ GlobalScope.launch(Dispatchers.IO) {
+ var inputStream: InputStream? = null
+ var session: PackageInstaller.Session? = null
+
+ try {
+ val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ val sessionId = packageInstaller.createSession(params)
+ session = packageInstaller.openSession(sessionId)
+
+ inputStream = apkFile.inputStream()
+ val dataLength = apkFile.length()
+
+ session.openWrite("package", 0, dataLength).use { sessionStream ->
+ inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
+ session.fsync(sessionStream)
+ }
+
+ val intent = Intent(context, InstallReceiver::class.java)
+ val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
+ val statusReceiver = pendingIntent.intentSender
+
+ Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
+ session.commit(statusReceiver)
+ } catch (e: Throwable) {
+ Logger.w(TAG, "Exception while installing update", e)
+ session?.abandon()
+ withContext(Dispatchers.Main) {
+ UIDialogs.toast(context, "Failed to install update: ${e.message}")
+ }
+ } finally {
+ session?.close()
+ inputStream?.close()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt
new file mode 100644
index 00000000..aafeeec4
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt
@@ -0,0 +1,171 @@
+package com.futo.platformplayer
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent.FLAG_MUTABLE
+import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.app.PendingIntent.getBroadcast
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import java.io.File
+
+object UpdateNotificationManager {
+ private const val CHANNEL_ID = "app_updates"
+ 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"
+
+ 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
+
+ fun ensureChannel(context: Context) {
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (manager.getNotificationChannel(CHANNEL_ID) == null) {
+ val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
+ channel.description = CHANNEL_DESCRIPTION
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ 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)
+ .addAction(0, "Download", yesPendingIntent)
+ .addAction(0, "Not now", noPendingIntent)
+ .addAction(0, "Never", neverPendingIntent)
+
+ NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
+ }
+
+ fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
+ ensureChannel(context)
+
+ val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
+ action = ACTION_DOWNLOAD_CANCEL
+ putExtra(EXTRA_VERSION, version)
+ }
+ val cancelPendingIntent = getBroadcast(
+ context,
+ 3,
+ cancelIntent,
+ FLAG_MUTABLE or FLAG_UPDATE_CURRENT
+ )
+
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.foreground)
+ .setContentTitle("Downloading update")
+ .setContentText("Downloading version $version")
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .addAction(0, "Cancel", cancelPendingIntent)
+
+ if (indeterminate) {
+ builder.setProgress(0, 0, true)
+ } else {
+ builder.setProgress(100, progress, false)
+ }
+
+ return builder.build()
+ }
+
+ fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+ val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
+ NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
+ }
+
+
+ fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+ ensureChannel(context)
+
+ val installIntent = Intent(context, UpdateActionReceiver::class.java).apply {
+ action = ACTION_INSTALL_NOW
+ putExtra(EXTRA_VERSION, version)
+ putExtra(EXTRA_APK_PATH, apkFile.absolutePath)
+ }
+ val installPendingIntent = getBroadcast(
+ context,
+ 4,
+ installIntent,
+ FLAG_MUTABLE or FLAG_UPDATE_CURRENT
+ )
+
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.foreground)
+ .setContentTitle("Update downloaded")
+ .setContentText("Tap to install version $version.")
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setAutoCancel(true)
+ .addAction(0, "Install", installPendingIntent)
+
+ NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
+ }
+
+
+ fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ return
+ }
+ ensureChannel(context)
+
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.foreground)
+ .setContentTitle("Failed to download update")
+ .setContentText(error?.message ?: "Unknown error")
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setAutoCancel(true)
+
+ NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, 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)
+ }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
index 00d39ac9..b9ecf3f4 100644
--- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
+++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
@@ -245,19 +245,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
}
- private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
- private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
- if (isGranted)
- UIDialogs.toast(this, "Notification permission granted");
- else
- UIDialogs.toast(this, "Notification permission denied");
- };
-
-
-
- fun requestNotificationPermissions() {
- _notificationPermissionLauncher?.launch(_notifPermission);
- }
val mainId = UUID.randomUUID().toString().substring(0, 5)
@@ -631,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
+ requestNotificationPermissions("Grayjay uses notifications to inform you when a new app update is available.");
+ }
+
val submissionStatus = FragmentedStorage.get("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
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 5e2b72de..fbca0f6b 100644
--- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt
@@ -16,9 +16,11 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
+import androidx.core.content.ContextCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
+import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
@@ -46,7 +48,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0;
private var _updating: Boolean = false;
- private var _apkFile: File? = null;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -80,14 +81,19 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
return@setOnClickListener;
}
- _updating = true;
- update();
+ if (Settings.instance.autoUpdate.backgroundDownload == 1) {
+ val ctx = context.applicationContext;
+ val intent = Intent(ctx, UpdateDownloadService::class.java);
+ intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
+ ContextCompat.startForegroundService(ctx, intent);
+ UIDialogs.toast(context, "Downloading update in background");
+ dismiss();
+ } else {
+ _updating = true;
+ update();
+ }
};
- }
- fun showPredownloaded(apkFile: File) {
- _apkFile = apkFile;
- super.show()
}
override fun dismiss() {
@@ -118,21 +124,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null;
try {
- val apkFile = _apkFile;
- if (apkFile != null) {
- inputStream = apkFile.inputStream();
- val dataLength = apkFile.length();
+ val client = ManagedHttpClient();
+ val response = client.get(StateUpdate.APK_URL);
+ if (response.isOk && response.body != null) {
+ inputStream = response.body.byteStream();
+ val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
- val client = ManagedHttpClient();
- val response = client.get(StateUpdate.APK_URL);
- if (response.isOk && response.body != null) {
- inputStream = response.body.byteStream();
- val dataLength = response.body.contentLength();
- install(inputStream, dataLength);
- } else {
- throw Exception("Failed to download latest version of app.");
- }
+ throw Exception("Failed to download latest version of app.");
}
} catch (e: Throwable) {
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
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 3912f625..7ac860e3 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt
@@ -572,30 +572,39 @@ class StateApp {
DownloadService.getOrCreateService(context);
}
- Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
- val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
- val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
- val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
- when {
- //Background download
- autoUpdateEnabled && shouldDownload && backgroundDownload -> {
- StateUpdate.instance.setShouldBackgroundUpdate(true);
- }
+ if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
+ if (Settings.instance.autoUpdate.backgroundDownload == 1) {
+ Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build();
- autoUpdateEnabled && !shouldDownload && backgroundDownload -> {
- Logger.i(TAG, "Auto update skipped due to wrong network state");
- }
+ val periodicRequest = PeriodicWorkRequest.Builder(
+ UpdateCheckWorker::class.java,
+ 12, TimeUnit.HOURS
+ )
+ .setConstraints(constraints)
+ .build();
- //Foreground download
- autoUpdateEnabled -> {
+ val wm = WorkManager.getInstance(context);
+ wm.enqueueUniquePeriodicWork(
+ UpdateCheckWorker.UNIQUE_WORK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ periodicRequest
+ );
+
+ val oneTimeRequest = OneTimeWorkRequest.Builder(UpdateCheckWorker::class.java)
+ .setConstraints(constraints)
+ .build();
+ wm.enqueue(oneTimeRequest);
+ } else {
+ Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false)
}
}
-
- else -> {
- Logger.i(TAG, "Auto update disabled");
- }
+ } else {
+ Logger.i(TAG, "AutoUpdate disabled");
}
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
@@ -781,24 +790,20 @@ class StateApp {
Logger.i("StateApp", "No AutoBackup configured");
}
-
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
try {
val wm = WorkManager.getInstance(context);
- if(active) {
- if(BuildConfig.DEBUG)
+ if (active) {
+ if (BuildConfig.DEBUG)
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
- .setConstraints(Constraints.Builder()
- .setRequiredNetworkType(NetworkType.UNMETERED)
- .build())
- .build();
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
+ } else {
+ wm.cancelUniqueWork("backgroundSubscriptions");
}
- else
- wm.cancelAllWork();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to schedule background subscription updates.", e)
UIDialogs.toast(context, "Background subscription update failed: " + e.message)
@@ -806,6 +811,7 @@ class StateApp {
}
+
private suspend fun migrateStores(context: Context, managedStores: List>, index: Int) {
if(managedStores.size <= index)
return;
@@ -903,15 +909,6 @@ class StateApp {
try {
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
StateDownloads.instance.checkForDownloadsTodos();
-
- val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
- val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
- val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
- if (autoUpdateEnabled && shouldDownload && backgroundDownload) {
- StateUpdate.instance.setShouldBackgroundUpdate(true);
- } else {
- StateUpdate.instance.setShouldBackgroundUpdate(false);
- }
} catch(ex: Throwable) {
Logger.w(TAG, "Failed to handle capabilities changed event", ex);
}
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 7ef370c3..2a39bb1b 100644
--- a/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt
+++ b/app/src/main/java/com/futo/platformplayer/states/StateUpdate.kt
@@ -15,146 +15,6 @@ import java.io.InputStream
import java.io.OutputStream
class StateUpdate {
- private var _backgroundUpdateFinished = false;
- private var _gettingOrDownloadingLastApk = false;
- private var _shouldBackgroundUpdate = false;
- private val _lockObject = Object();
-
- private fun getOrDownloadLastApkFile(filesDir: File): File? {
- try {
- Logger.i(TAG, "Started getting or downloading latest APK file.");
-
- if (!_shouldBackgroundUpdate) {
- Logger.i(TAG, "Update download cancelled 1.");
- return null;
- }
-
- Logger.i(TAG, "Started background update download.");
- val client = ManagedHttpClient();
- val latestVersion = downloadVersionCode(client);
- if (!_shouldBackgroundUpdate) {
- Logger.i(TAG, "Update download cancelled 2.");
- return null;
- }
-
- if (latestVersion != null) {
- val currentVersion = BuildConfig.VERSION_CODE;
- Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
-
- if (latestVersion <= currentVersion) {
- Logger.i(TAG, "Already up to date.");
- _backgroundUpdateFinished = true;
- return null;
- }
-
- val outputDirectory = File(filesDir, "autoupdate");
- if (!outputDirectory.exists()) {
- outputDirectory.mkdirs();
- }
-
- if (!_shouldBackgroundUpdate) {
- Logger.i(TAG, "Update download cancelled 3.");
- return null;
- }
-
- val apkOutputFile = File(outputDirectory, "last_version.apk");
- val versionOutputFile = File(outputDirectory, "last_version.txt");
-
- var cachedVersionInvalid = false;
- if (!versionOutputFile.exists() || !apkOutputFile.exists()) {
- Logger.i(TAG, "No downloaded version exists.");
- cachedVersionInvalid = true;
- } else {
- try {
- val downloadedVersion = versionOutputFile.readText().toInt();
- Logger.i(TAG, "Downloaded version is $downloadedVersion.");
- if (downloadedVersion != latestVersion) {
- Logger.i(TAG, "Downloaded version is not newest version.");
- cachedVersionInvalid = true;
- }
- }
- catch(ex: Throwable) {
- Logger.w(TAG, "Deleted version file as it was inaccessible");
- versionOutputFile.delete();
- cachedVersionInvalid = true;
- }
- }
-
- if (!_shouldBackgroundUpdate) {
- Logger.i(TAG, "Update download cancelled 4.");
- return null;
- }
-
- if (cachedVersionInvalid) {
- Logger.i(TAG, "Downloading new APK to '${apkOutputFile.path}'...");
- downloadApkToFile(client, apkOutputFile) { !_shouldBackgroundUpdate };
- versionOutputFile.writeText(latestVersion.toString());
-
- Logger.i(TAG, "Downloaded APK to '${apkOutputFile.path}'.");
- } else {
- Logger.i(TAG, "Latest APK is already downloaded in '${apkOutputFile.path}'...");
- }
-
- if (!_shouldBackgroundUpdate) {
- Logger.i(TAG, "Update download cancelled 5.");
- return null;
- }
-
- return apkOutputFile;
- } else {
- Logger.w(TAG, "Failed to retrieve version from version URL.");
- return null;
- }
- } catch (e: Throwable) {
- Logger.e(TAG, "Failed to download APK.", e);
- return null;
- } finally {
- _gettingOrDownloadingLastApk = false;
- }
- }
-
- fun setShouldBackgroundUpdate(shouldBackgroundUpdate: Boolean) {
- synchronized (_lockObject) {
- if (_backgroundUpdateFinished) {
- _shouldBackgroundUpdate = false;
- return;
- }
-
- _shouldBackgroundUpdate = shouldBackgroundUpdate;
- if (shouldBackgroundUpdate && !_gettingOrDownloadingLastApk) {
- Logger.i(TAG, "Auto Updating in Background");
-
- _gettingOrDownloadingLastApk = true;
- StateApp.withContext { context ->
- StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
- try {
- val file = getOrDownloadLastApkFile(context.filesDir);
- if (file == null) {
- Logger.i(TAG, "Failed to get or download update.");
- return@launch;
- }
-
- withContext(Dispatchers.Main) {
- try {
- context.let { c ->
- _backgroundUpdateFinished = true;
- UIDialogs.showInstallDownloadedUpdateDialog(c, file);
- };
- Logger.i(TAG, "Showing install dialog for '${file.path}'.");
- } catch (e: Throwable) {
- context.let { c -> UIDialogs.toast(c, "Failed to show update dialog"); };
- Logger.w(TAG, "Error occurred in update dialog.", e);
- }
- }
- } catch (e: Throwable) {
- Logger.e(TAG, "Failed to get last downloaded APK file.", e)
- }
- }
- }
- }
- }
- }
-
suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient();
@@ -196,25 +56,6 @@ class StateUpdate {
}
}
- private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
- var apkStream: InputStream? = null;
- var outputStream: OutputStream? = null;
-
- try {
- val response = client.get(APK_URL);
- if (response.isOk && response.body != null) {
- apkStream = response.body.byteStream();
- outputStream = destinationFile.outputStream();
- apkStream.copyToOutputStream(outputStream, isCancelled);
- apkStream.close();
- outputStream.close();
- }
- } finally {
- apkStream?.close();
- outputStream?.close();
- }
- }
-
fun downloadVersionCode(client: ManagedHttpClient): Int? {
val response = client.get(VERSION_URL);
if (!response.isOk || response.body == null) {
@@ -267,6 +108,22 @@ class StateUpdate {
}
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs";
+ fun getApkFile(context: Context, version: Int): File {
+ val dir = File(context.filesDir, "updates");
+ if (!dir.exists()) {
+ dir.mkdirs();
+ }
+ return File(dir, "app-${DESIRED_ABI}-${version}.apk");
+ }
+
+ fun getPartialApkFile(context: Context, version: Int): File {
+ val dir = File(context.filesDir, "updates");
+ if (!dir.exists()) {
+ dir.mkdirs();
+ }
+ return File(dir, "app-${DESIRED_ABI}-${version}.apk.part");
+ }
+
fun finish() {
_instance?.let {
_instance = null;
diff --git a/app/src/main/res/layout/dialog_update.xml b/app/src/main/res/layout/dialog_update.xml
index 9dceccd4..fd42e3f8 100644
--- a/app/src/main/res/layout/dialog_update.xml
+++ b/app/src/main/res/layout/dialog_update.xml
@@ -106,7 +106,7 @@