BG update initial impl.

This commit is contained in:
Koen
2025-12-02 17:31:23 +00:00
parent 56bdae9ff1
commit 8ac18f053c
12 changed files with 726 additions and 236 deletions
+9
View File
@@ -252,5 +252,14 @@
android:name=".activities.QRCodeFullscreenActivity" android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<service
android:name=".UpdateDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".UpdateActionReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>
@@ -403,13 +403,6 @@ class UIDialogs {
dialog.setMaxVersion(lastVersion); 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) { fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
if(!store.hasMissingReconstructions()) if(!store.hasMissingReconstructions())
onConcluded(); onConcluded();
@@ -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)
}
}
@@ -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"
}
}
@@ -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)
}
}
}
}
}
}
@@ -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()
}
}
}
}
@@ -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)
}
}
@@ -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) val mainId = UUID.randomUUID().toString().substring(0, 5)
@@ -631,6 +618,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply() 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<StringStorage>("subscriptionSubmissionStatus") val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount() val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
@@ -16,9 +16,11 @@ import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import com.futo.platformplayer.R 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.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
@@ -46,7 +48,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0; private var _maxVersion: Int = 0;
private var _updating: Boolean = false; private var _updating: Boolean = false;
private var _apkFile: File? = null;
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -80,14 +81,19 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
return@setOnClickListener; return@setOnClickListener;
} }
_updating = true; if (Settings.instance.autoUpdate.backgroundDownload == 1) {
update(); 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() { override fun dismiss() {
@@ -118,21 +124,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null; var inputStream: InputStream? = null;
try { try {
val apkFile = _apkFile; val client = ManagedHttpClient();
if (apkFile != null) { val response = client.get(StateUpdate.APK_URL);
inputStream = apkFile.inputStream(); if (response.isOk && response.body != null) {
val dataLength = apkFile.length(); inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength); install(inputStream, dataLength);
} else { } else {
val client = ManagedHttpClient(); throw Exception("Failed to download latest version of app.");
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.");
}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e); Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
@@ -572,30 +572,39 @@ class StateApp {
DownloadService.getOrCreateService(context); DownloadService.getOrCreateService(context);
} }
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]"); if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); if (Settings.instance.autoUpdate.backgroundDownload == 1) {
val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]");
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1; val constraints = Constraints.Builder()
when { .setRequiredNetworkType(NetworkType.CONNECTED)
//Background download .build();
autoUpdateEnabled && shouldDownload && backgroundDownload -> {
StateUpdate.instance.setShouldBackgroundUpdate(true);
}
autoUpdateEnabled && !shouldDownload && backgroundDownload -> { val periodicRequest = PeriodicWorkRequest.Builder(
Logger.i(TAG, "Auto update skipped due to wrong network state"); UpdateCheckWorker::class.java,
} 12, TimeUnit.HOURS
)
.setConstraints(constraints)
.build();
//Foreground download val wm = WorkManager.getInstance(context);
autoUpdateEnabled -> { 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) { scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false) StateUpdate.instance.checkForUpdates(context, false)
} }
} }
} else {
else -> { Logger.i(TAG, "AutoUpdate disabled");
Logger.i(TAG, "Auto update disabled");
}
} }
Logger.i(TAG, "MainApp Started: Initialize [Noisy]"); Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
@@ -781,24 +790,20 @@ class StateApp {
Logger.i("StateApp", "No AutoBackup configured"); Logger.i("StateApp", "No AutoBackup configured");
} }
fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) { fun scheduleBackgroundWork(context: Context, active: Boolean = true, intervalMinutes: Int = 60 * 12) {
try { try {
val wm = WorkManager.getInstance(context); val wm = WorkManager.getInstance(context);
if(active) { if (active) {
if(BuildConfig.DEBUG) if (BuildConfig.DEBUG)
UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes"); UIDialogs.toast(context, "Scheduling background every ${intervalMinutes} minutes");
val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES) val req = PeriodicWorkRequest.Builder(BackgroundWorker::class.java, intervalMinutes.toLong(), TimeUnit.MINUTES, 5, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder() .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).build()).build();
.setRequiredNetworkType(NetworkType.UNMETERED)
.build())
.build();
wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req); wm.enqueueUniquePeriodicWork("backgroundSubscriptions", ExistingPeriodicWorkPolicy.UPDATE, req);
} else {
wm.cancelUniqueWork("backgroundSubscriptions");
} }
else
wm.cancelAllWork();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to schedule background subscription updates.", e) Logger.e(TAG, "Failed to schedule background subscription updates.", e)
UIDialogs.toast(context, "Background subscription update failed: " + e.message) UIDialogs.toast(context, "Background subscription update failed: " + e.message)
@@ -806,6 +811,7 @@ class StateApp {
} }
private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) { private suspend fun migrateStores(context: Context, managedStores: List<ManagedStore<*>>, index: Int) {
if(managedStores.size <= index) if(managedStores.size <= index)
return; return;
@@ -903,15 +909,6 @@ class StateApp {
try { try {
if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload()) if(FragmentedStorage.isInitialized && Settings.instance.downloads.shouldDownload())
StateDownloads.instance.checkForDownloadsTodos(); 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) { } catch(ex: Throwable) {
Logger.w(TAG, "Failed to handle capabilities changed event", ex); Logger.w(TAG, "Failed to handle capabilities changed event", ex);
} }
@@ -15,146 +15,6 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
class StateUpdate { 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) { suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
try { try {
val client = ManagedHttpClient(); 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? { fun downloadVersionCode(client: ManagedHttpClient): Int? {
val response = client.get(VERSION_URL); val response = client.get(VERSION_URL);
if (!response.isOk || response.body == null) { if (!response.isOk || response.body == null) {
@@ -267,6 +108,22 @@ class StateUpdate {
} }
val CHANGELOG_BASE_URL = "https://releases.grayjay.app/changelogs"; 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() { fun finish() {
_instance?.let { _instance?.let {
_instance = null; _instance = null;
+1 -1
View File
@@ -106,7 +106,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/update" android:text="@string/download"
android:textSize="14dp" android:textSize="14dp"
android:textColor="@color/white" android:textColor="@color/white"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"