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 @@