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/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 5b00bbb9..b154cb67 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -5,8 +5,6 @@ import android.content.Context import android.content.Intent import android.content.res.Resources import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.icu.util.Output import android.os.Build import android.os.Looper import android.os.OperationCanceledException @@ -44,6 +42,9 @@ import java.util.* import java.util.concurrent.ThreadLocalRandom import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream +import androidx.core.graphics.scale +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; fun getRandomString(sizeOfRandomString: Int): String { @@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co it.flush(); }; -fun loadBitmap(url: String): Bitmap { - try { - val client = ManagedHttpClient(); - val response = client.get(url); - if (response.isOk && response.body != null) { - val bitmapStream = response.body.byteStream(); - val bitmap = BitmapFactory.decodeStream(bitmapStream); - return bitmap; - } else { - throw Exception("Failed to find data at URL."); - } - } catch (e: Throwable) { - Logger.w("Utility", "Exception thrown while downloading bitmap.", e); - throw e; - } -} - fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) { this.movementMethod = PlatformLinkMovementMethod(context); } @@ -458,4 +442,17 @@ fun addressScore(addr: InetAddress): Int { } } -fun Enumeration.toList(): List = Collections.list(this) \ No newline at end of file +fun Enumeration.toList(): List = Collections.list(this) + +fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920, useCenterCrop: Boolean = false): RequestBuilder { + var builder = this + .downsample(DownsampleStrategy.AT_MOST) + .override(maxSizePx, maxSizePx) + builder = if (useCenterCrop) { + builder.centerCrop() + } else { + builder.fitCenter() + } + + return builder +} \ No newline at end of file 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/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index a85e5951..d49ae1e0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -55,7 +55,7 @@ abstract class FeedView : L protected val _toolbarContentView: LinearLayout; protected val _bottomContentView: LinearLayout; - private var _loading: Boolean = true; + private var _loading: Boolean = false; private val _pagerLock = Object(); private var _cache: ItemCache? = null; @@ -180,10 +180,9 @@ abstract class FeedView : L val visibleItemCount = _recyclerResults.childCount; val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition() - //Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount") + //Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}") - if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) { - //Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}") + if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) { loadNextPage(); } } @@ -197,57 +196,44 @@ abstract class FeedView : L } private fun ensureEnoughContentVisible(filteredResults: List) { - val canScroll = if (recyclerData.results.isEmpty()) false else { - val height = resources.displayMetrics.heightPixels; + _recyclerResults.post { + val canScroll = _recyclerResults.canScrollVertically(1) + Logger.i( + TAG, + "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter" + ) + if (!canScroll || filteredResults.isEmpty()) { + _automaticNextPageCounter++ + if (_automaticNextPageCounter < _automaticBackoff.size) { + if (_automaticNextPageCounter > 0) { + val automaticNextPageCounterSaved = _automaticNextPageCounter; + fragment.lifecycleScope.launch(Dispatchers.Default) { + val backoff = _automaticBackoff[Math.min( + _automaticBackoff.size - 1, + _automaticNextPageCounter + )]; - val layoutManager = recyclerData.layoutManager - val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() - val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; - val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); - val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; - val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1; - val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows; - if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { - false; - } - else if (firstVisibleItemView != null && height != null && rowsHeight < height) { - false; - } else { - true; - } - } - - Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter") - if (!canScroll || filteredResults.isEmpty()) { - _automaticNextPageCounter++ - if(_automaticNextPageCounter < _automaticBackoff.size) { - if(_automaticNextPageCounter > 0) { - val automaticNextPageCounterSaved = _automaticNextPageCounter; - fragment.lifecycleScope.launch(Dispatchers.Default) { - val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)]; - - withContext(Dispatchers.Main) { - setLoading(true); - } - delay(backoff.toLong()); - if(automaticNextPageCounterSaved == _automaticNextPageCounter) { withContext(Dispatchers.Main) { - loadNextPage(); + setLoading(true); + } + delay(backoff.toLong()); + if (automaticNextPageCounterSaved == _automaticNextPageCounter) { + withContext(Dispatchers.Main) { + loadNextPage(); + } + } else { + withContext(Dispatchers.Main) { + setLoading(false); + } } } - else { - withContext(Dispatchers.Main) { - setLoading(false); - } - } - } + } else + loadNextPage(); } - else - loadNextPage(); + } else { + Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset"); + _automaticNextPageCounter = 0; } - } else { - Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset"); - _automaticNextPageCounter = 0; } } fun resetAutomaticNextPageCounter(){ @@ -484,6 +470,7 @@ abstract class FeedView : L recyclerData.resultsUnfiltered.addAll(toAdd); recyclerData.adapter.notifyDataSetChanged(); recyclerData.loadedFeedStyle = feedStyle; + setLoading(false) if(pager.hasMorePages()) ensureEnoughContentVisible(filteredResults) } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt index 43ca3700..0a9f8bf7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryVideosFragment.kt @@ -96,7 +96,6 @@ class LibraryVideosFragment : MainFragment() { fun onShown() { val initialAlbums = StateLibrary.instance.getAlbums(); Logger.i(TAG, "Initial album count: " + initialAlbums.size); - val buckets = StateLibrary.instance.getVideoBucketNames(); setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets)); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt index 349b4fb0..0b3a0d6b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/RemotePlaylistFragment.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.* import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails @@ -363,6 +364,7 @@ class RemotePlaylistFragment : MainFragment() { _imagePlaylistThumbnail.let { Glide.with(it) .load(video.thumbnails.getHQThumbnail()) + .withMaxSizePx() .placeholder(R.drawable.placeholder_video_thumbnail) .crossfade() .into(it); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt index e3a0a655..753fcb81 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ShortView.kt @@ -2,7 +2,9 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.WindowManager @@ -13,10 +15,15 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.util.UnstableApi +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -71,6 +78,7 @@ import com.futo.platformplayer.views.video.FutoShortPlayer import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS +import com.futo.platformplayer.withMaxSizePx import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Models @@ -851,9 +859,8 @@ class ShortView : FrameLayout { } val thumbnail = videoDetails.thumbnails.getHQThumbnail() - /* if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap() - .load(thumbnail).into(object : CustomTarget() { + .load(thumbnail).withMaxSizePx().into(object : CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { player.setArtwork(resource.toDrawable(resources)) } @@ -863,7 +870,6 @@ class ShortView : FrameLayout { } }) else player.setArtwork(null) - */ fragment.lifecycleScope.launch(Dispatchers.Main) { try { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index d3558c07..ca5af1f9 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -42,6 +42,7 @@ import androidx.media3.datasource.HttpDataSource import androidx.media3.ui.PlayerControlView import androidx.media3.ui.TimeBar import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.BuildConfig @@ -161,6 +162,7 @@ import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.video.FutoVideoPlayer import com.futo.platformplayer.views.video.FutoVideoPlayerBase import com.futo.platformplayer.views.videometa.UpNextView +import com.futo.platformplayer.withMaxSizePx import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Models @@ -552,12 +554,12 @@ class VideoDetailView : ConstraintLayout { _buttonMore = buttonMore; updateMoreButtons(); - val handleLoaderGameVisibilityChanged = { b: Boolean -> + val handleLoaderGameVisibilityChanged: (Boolean) -> Unit = { b: Boolean -> _loaderGameVisible = b fragment.lifecycleScope.launch(Dispatchers.Main) { onShouldEnterPictureInPictureChanged.emit() + updateResumeVisibilityFor(lastPositionMilliseconds) } - updateResumeVisibilityFor(lastPositionMilliseconds) } _player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged) _cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged) @@ -2049,7 +2051,7 @@ class VideoDetailView : ConstraintLayout { } else { val thumbnail = video.thumbnails.getHQThumbnail(); if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode - Glide.with(context).asBitmap().load(thumbnail) + Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx() .into(object: CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { _player.setArtwork(BitmapDrawable(resources, resource)); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index ae890d94..830df866 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -14,6 +14,7 @@ import android.widget.TextView import androidx.core.view.isVisible import androidx.core.view.setPadding import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays @@ -28,6 +29,7 @@ import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.lists.VideoListEditorView +import com.futo.platformplayer.withMaxSizePx abstract class VideoListEditorView : LinearLayout { private var _videoListEditorView: VideoListEditorView; @@ -211,6 +213,7 @@ abstract class VideoListEditorView : LinearLayout { _imagePlaylistThumbnail.let { Glide.with(it) .load(video.thumbnails.getHQThumbnail()) + .withMaxSizePx() .placeholder(R.drawable.placeholder_video_thumbnail) .crossfade() .into(it); diff --git a/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt index 4b98cdeb..842fd5c6 100644 --- a/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/images/GlideHelper.kt @@ -4,8 +4,10 @@ import android.graphics.drawable.Drawable import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.withMaxSizePx class GlideHelper { @@ -14,7 +16,7 @@ class GlideHelper { fun ImageView.loadThumbnails(thumbnails: Thumbnails, isHQ: Boolean = true, continuation: ((RequestBuilder) -> Unit)? = null) { val url = if(isHQ) thumbnails.getHQThumbnail() ?: thumbnails.getLQThumbnail() else thumbnails.getLQThumbnail(); - val req = Glide.with(this).load(url); + val req = Glide.with(this).load(url).withMaxSizePx() if (thumbnails.hasMultiple() && false) { //TODO: Resolve issue where fallback triggered on second loads? val fallbackUrl = if (isHQ) thumbnails.getLQThumbnail() else thumbnails.getHQThumbnail(); diff --git a/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt index 9112b6d5..2105a4a9 100644 --- a/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.receivers +import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -26,14 +27,24 @@ class InstallReceiver : BroadcastReceiver() { val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) } else { + @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT) } if (activityIntent == null) { Logger.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.") + onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken)) return; } - context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + + activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + context.startActivity(activityIntent) + } catch (e: ActivityNotFoundException) { + Logger.e(TAG, "System installer cannot handle CONFIRM_INSTALL intent. ROM is broken; falling back / reporting error.", e) + onReceiveResult.emit(context.getString(R.string.install_failed_device_installer_broken)) + } } PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit(null); PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit(context.getString(R.string.general_failure)); @@ -45,6 +56,7 @@ class InstallReceiver : BroadcastReceiver() { PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit(context.getString(R.string.not_enough_storage)); else -> { val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + Logger.w(TAG, "Received unknown install status $status, message=$msg") onReceiveResult.emit(msg) } } diff --git a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt index f866975f..0ad801bc 100644 --- a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt @@ -26,6 +26,7 @@ import android.util.Log import android.view.KeyEvent import androidx.core.app.NotificationCompat import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R @@ -38,6 +39,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.withMaxSizePx class MediaPlaybackService : Service() { private val TAG = "MediaPlaybackService"; @@ -172,21 +174,26 @@ class MediaPlaybackService : Service() { } fun closeMediaSession() { - Logger.v(TAG, "closeMediaSession"); - stopForeground(STOP_FOREGROUND_REMOVE); + Logger.v(TAG, "closeMediaSession") + stopForeground(STOP_FOREGROUND_REMOVE) abandonAudioFocus() - val notifManager = _notificationManager; - Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})"); - notifManager?.cancel(MEDIA_NOTIF_ID); - _notif_last_video = null; - _notif_last_bitmap = null; - _mediaSession = null; + val notifManager = _notificationManager + Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})") + notifManager?.cancel(MEDIA_NOTIF_ID) - if(_instance == this) - _instance = null; - this.stopSelf(); + _notif_last_video = null + _notif_last_bitmap = null + + _mediaSession?.isActive = false + _mediaSession?.release() + _mediaSession = null + + if (_instance == this) + _instance = null + + stopSelf() } fun updateMediaSession(videoUpdated: IPlatformVideo?) { @@ -206,37 +213,37 @@ class MediaPlaybackService : Service() { if(_notificationChannel == null || _mediaSession == null) setupNotificationRequirements(); - _mediaSession?.setMetadata( - MediaMetadataCompat.Builder() - .putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name) - .putString(MediaMetadata.METADATA_KEY_TITLE, video.name) - .putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000) - .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, lastBitmap) - .build()); + updateMediaMetadata(video, lastBitmap) val thumbnail = video.thumbnails.getHQThumbnail(); _notif_last_video = video; if(isUpdating) - notifyMediaSession(video, _notif_last_bitmap); + notifyMediaSession(video, _notif_last_bitmap?.takeIf { !it.isRecycled }); else if(thumbnail != null) { notifyMediaSession(video, null); val tag = video; Glide.with(this).asBitmap() .load(thumbnail) + .withMaxSizePx() .into(object: CustomTarget() { override fun onResourceReady(resource: Bitmap,transition: Transition?) { - if(tag == _notif_last_video) { - notifyMediaSession(video, resource) - _mediaSession?.setMetadata( - MediaMetadataCompat.Builder() - .putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name) - .putString(MediaMetadata.METADATA_KEY_TITLE, video.name) - .putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000) - .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, resource) - .build()); + if (tag != _notif_last_video) return + if (resource.isRecycled) { + notifyMediaSession(video, null) + return } + + val albumArt = resource.copy( + resource.config ?: Bitmap.Config.ARGB_8888, + false + ) + + _notif_last_bitmap = albumArt + + notifyMediaSession(video, albumArt) + updateMediaMetadata(video, albumArt) } override fun onLoadCleared(placeholder: Drawable?) { if(tag == _notif_last_video) @@ -247,6 +254,19 @@ class MediaPlaybackService : Service() { else notifyMediaSession(video, null); } + private fun updateMediaMetadata(video: IPlatformVideo, bitmap: Bitmap?) { + val builder = MediaMetadataCompat.Builder() + .putString(MediaMetadata.METADATA_KEY_ARTIST, video.author.name) + .putString(MediaMetadata.METADATA_KEY_TITLE, video.name) + .putLong(MediaMetadata.METADATA_KEY_DURATION, video.duration * 1000) + + val safeBitmap = bitmap?.takeIf { !it.isRecycled } + if (safeBitmap != null) { + builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, safeBitmap) + } + + _mediaSession?.setMetadata(builder.build()) + } private fun generateMediaAction(icon: Int, title: String, intent: PendingIntent) : NotificationCompat.Action { return NotificationCompat.Action.Builder(icon, title, intent).build(); } 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/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index 2a5c4c8e..9176b8ae 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -1,10 +1,12 @@ package com.futo.platformplayer.states +import android.content.ContentResolver import android.content.ContentUris import android.content.Intent import android.database.Cursor import android.net.Uri import android.os.Build +import android.os.Bundle import android.provider.MediaStore import android.provider.MediaStore.Audio.Artists import android.webkit.MimeTypeMap @@ -154,34 +156,101 @@ class StateLibrary { fun getArtist(id: Long): Artist? { return Artist.getArtist(id); } + fun getVideos( + buckets: List? = null, + pageSize: Int = 20 + ): IPager { + val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return EmptyPager() + val selection: String? + val selectionArgs: Array? - fun getVideos(buckets: List? = null): IPager { - var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null; - val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO, - query, - null, - MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager(); + if (!buckets.isNullOrEmpty()) { + val placeholders = buckets.joinToString(",") { "?" } + selection = "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN ($placeholders)" + selectionArgs = buckets.toTypedArray() + } else { + selection = null + selectionArgs = null + } - //Ongoing usage of cursor..todo disposal - //return cursor.use { - cursor.moveToFirst(); - val list = mutableListOf() - while(!cursor.isAfterLast && list.size < 10) { - list.add(videoFromCursor(cursor)); - cursor.moveToNext(); + val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + } else { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } + + var nextPageIndex = 0 + fun loadPage(pageIndex: Int): List { + Logger.i(TAG, "loadPage $pageIndex") + val offset = pageIndex * pageSize + + val queryArgs = Bundle().apply { + selection?.let { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, it) + } + selectionArgs?.let { + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it) + } + + putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf( + MediaStore.Video.Media.DATE_ADDED, + MediaStore.Video.Media._ID + ) + ) + putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + ContentResolver.QUERY_SORT_DIRECTION_DESCENDING + ) + + putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize) + putInt(ContentResolver.QUERY_ARG_OFFSET, offset) } - return AdhocPager({ - val list = mutableListOf() - while(!cursor.isAfterLast && list.size < 10) { - list.add(videoFromCursor(cursor)); - cursor.moveToNext(); + val cursor = resolver.query( + collectionUri, + PROJECTION_VIDEO, + queryArgs, + null + ) + + if (cursor == null) { + Logger.i(TAG, "loadPage $pageIndex null, returning empty list") + return emptyList() + } + + cursor.use { c -> + if (!c.moveToFirst()) { + Logger.i(TAG, "loadPage $pageIndex moveToFirst failed, returning empty list") + return emptyList() } - Logger.i(TAG, "Videos nextPage: ${list.size}") - return@AdhocPager list; - }, list); - //} + + val list = ArrayList(pageSize) + do { + list.add(videoFromCursor(c)) + } while (c.moveToNext() && list.size < pageSize) + + Logger.i(TAG, "loadPage $pageIndex found ${list.size} items") + return list + } + } + + val firstPage = loadPage(0) + if (firstPage.isEmpty()) { + return EmptyPager() + } + nextPageIndex = 1 + + return AdhocPager({ + val page = loadPage(nextPageIndex) + nextPageIndex++ + + Logger.i(TAG, "loadPage nextPage: ${page.size}") + page + }, firstPage) } + fun getRecentVideos(buckets: List? = null, count: Int = 20): List { val videoPager = getVideos(buckets); val items = mutableListOf(); @@ -193,48 +262,80 @@ class StateLibrary { return items; } - private var _cacheBucketNames: List? = null; - fun getVideoBucketNames(): List { - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) - return listOf(); - if(_cacheBucketNames != null) - return _cacheBucketNames ?: listOf(); - try { - val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf( - MediaStore.Video.Media.BUCKET_ID, - MediaStore.Video.Media.BUCKET_DISPLAY_NAME, - ), null, null, null - ) ?: return listOf(); + @Volatile + private var _cachedVideoBuckets: List? = null + private val _bucketCacheLock = Any() - return cur.use { - val buckets = mutableListOf(); - val list = HashSet(); - if (cur.moveToFirst()) { - var id: Long; - var bucket: String - do { - try { - id = cur.getLong(0); - bucket = cur.getStringOrNull(1) ?: continue; - if (!list.contains(id)) { - list.add(id); - buckets.add(Bucket(id, bucket)); - } - } catch (ex: Throwable) { - Logger.e(TAG, "Failed to parse bucket due to ${ex.message}", ex); - } - } while (cur.moveToNext()) + fun getVideoBucketNames(forceRefresh: Boolean = false): List { + if (!forceRefresh) { + _cachedVideoBuckets?.let { return it } + } + + val resolver = StateApp.instance.contextOrNull?.contentResolver + ?: return emptyList() + + val projection = arrayOf( + MediaStore.Video.VideoColumns.BUCKET_ID, + MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME + ) + + val sortOrder = "${MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC" + val loadedBuckets: List = try { + resolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + projection, + null, + null, + sortOrder + )?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use emptyList() } - _cacheBucketNames = buckets.toList() - return@use _cacheBucketNames ?: listOf(); + + val idxId = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_ID) + val idxName = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME) + val seenIds = HashSet() + val buckets = ArrayList() + + do { + try { + val id = cursor.getLong(idxId) + if (!seenIds.add(id)) { + continue + } + + val name = cursor.getStringOrNull(idxName) ?: continue + buckets.add(Bucket(id, name)) + } catch (e: Exception) { + Logger.e(TAG, "Failed to parse video bucket row: ${e.message}", e) + } + } while (cursor.moveToNext()) + + buckets + } ?: emptyList() + } catch (e: Exception) { + Logger.e(TAG, "Buckets loading failed, returning empty: ${e.message}", e) + emptyList() + } + + if (loadedBuckets.isEmpty()) { + if (!forceRefresh) { + _cachedVideoBuckets?.let { return it } } + return emptyList() } - catch(ex: Throwable) { - Logger.e(TAG, "Buckets loading failed, returning empty"); - return listOf(); + + synchronized(_bucketCacheLock) { + if (!forceRefresh) { + _cachedVideoBuckets?.let { return it } + } + _cachedVideoBuckets = loadedBuckets + return loadedBuckets } } + fun invalidateVideoBucketNamesCache() { + _cachedVideoBuckets = null + } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt index e5f01a84..11b9719f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt @@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable import android.os.Build import androidx.core.app.NotificationCompat import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.activities.MainActivity @@ -22,6 +23,7 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNowDiffStringMinDay +import com.futo.platformplayer.withMaxSizePx import java.time.OffsetDateTime class StateNotifications { @@ -96,6 +98,7 @@ class StateNotifications { if(thumbnail != null) Glide.with(context).asBitmap() .load(thumbnail) + .withMaxSizePx() .into(object: CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { notifyNewContent(context, manager, notificationChannel, id, content, resource); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index fa2fb58b..2e4845fb 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -247,17 +247,29 @@ class StatePlayer { } private fun createShuffledQueue() { - val currentItem = getCurrentQueueItem(); - if (_queuePosition == -1 || currentItem == null) { - _queueShuffled = _queue.shuffled().toMutableList() - return; + if (_queue.isEmpty()) { + _queueShuffled = mutableListOf() + return } - val nextItems = _queue.subList(Math.min(_queuePosition + 1, _queue.size - 1), _queue.size).shuffled(); - val previousItems = _queue.subList(0, _queuePosition).shuffled(); - _queueShuffled = (previousItems + currentItem + nextItems).toMutableList(); + val currentItem = getCurrentQueueItem() + if (currentItem == null || _queuePosition !in _queue.indices) { + _queueShuffled = _queue.shuffled().toMutableList() + return + } + + val previousItems = _queue + .take(_queuePosition) + .shuffled() + + val nextItems = _queue + .drop(_queuePosition + 1) + .shuffled() + + _queueShuffled = (previousItems + currentItem + nextItems).toMutableList() } + private fun addToShuffledQueue(video: IPlatformVideo) { val isLastVideo = _queuePosition + 1 >= _queue.size; if (isLastVideo) { 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/java/com/futo/platformplayer/views/adapters/PlaylistsViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsViewHolder.kt index e7fc852a..35d01f13 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistsViewHolder.kt @@ -7,10 +7,12 @@ import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.withMaxSizePx class PlaylistsViewHolder : ViewHolder { private val _root: ConstraintLayout; @@ -44,6 +46,7 @@ class PlaylistsViewHolder : ViewHolder { if (p.videos.isNotEmpty()) { Glide.with(_imageThumbnail) .load(p.videos[0].thumbnails.getMinimumThumbnail(380)) + .withMaxSizePx() .placeholder(R.drawable.placeholder_video_thumbnail) .crossfade() .into(_imageThumbnail); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 469ce702..61e840d6 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -12,6 +12,7 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 @@ -23,6 +24,7 @@ import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.withMaxSizePx class VideoListEditorViewHolder : ViewHolder { private val _root: ConstraintLayout; @@ -89,6 +91,7 @@ class VideoListEditorViewHolder : ViewHolder { fun bind(v: IPlatformVideo, canEdit: Boolean) { Glide.with(_imageThumbnail) .load(v.thumbnails.getHQThumbnail()) + .withMaxSizePx() .placeholder(R.drawable.placeholder_video_thumbnail) .crossfade() .into(_imageThumbnail); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt index 1adee422..609220f8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/LocalVideoTileViewHolder.kt @@ -7,6 +7,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.core.view.isVisible import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.IPlatformVideo @@ -16,6 +17,7 @@ import com.futo.platformplayer.states.Artist import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.adapters.AnyAdapter +import com.futo.platformplayer.withMaxSizePx import com.google.android.material.imageview.ShapeableImageView @@ -49,6 +51,7 @@ class LocalVideoTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHo Glide.with(it) .load(content.thumbnails.getHQThumbnail()) .placeholder(R.drawable.unknown_music) + .withMaxSizePx() .into(it) else Glide.with(it).load(R.drawable.unknown_music).into(it); diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt index f3bbc7b8..26123083 100644 --- a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.views.buttons import android.content.Context import android.graphics.Bitmap +import android.os.Looper import android.util.AttributeSet import android.util.TypedValue import android.view.View @@ -98,46 +99,58 @@ open class BigButton : LinearLayout { return this; } - fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton { + private fun applyIcon(resourceId: Int, rounded: Boolean) { if (resourceId != -1) { - _icon.visibility = View.VISIBLE; - _icon.setImageResource(resourceId); - } else - _icon.visibility = View.GONE; - - if (rounded) { - val shapeAppearanceModel = ShapeAppearanceModel().toBuilder() - .setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics)) - .build(); - - _icon.scaleType = ImageView.ScaleType.FIT_CENTER; - _icon.shapeAppearanceModel = shapeAppearanceModel; + _icon.visibility = View.VISIBLE + _icon.setImageResource(resourceId) } else { - _icon.scaleType = ImageView.ScaleType.CENTER_CROP; - _icon.shapeAppearanceModel = ShapeAppearanceModel(); + _icon.visibility = View.GONE } - - return this; + applyRounded(rounded) } + fun withIcon(resourceId: Int, rounded: Boolean = false): BigButton { + if (Looper.myLooper() == Looper.getMainLooper()) { + applyIcon(resourceId, rounded) + } else { + post { applyIcon(resourceId, rounded) } + } + return this + } fun withIcon(bitmap: Bitmap, rounded: Boolean = false): BigButton { - _icon.visibility = View.VISIBLE; - _icon.setImageBitmap(bitmap); - - if (rounded) { - val shapeAppearanceModel = ShapeAppearanceModel().toBuilder() - .setAllCornerSizes(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16.0f, context.resources.displayMetrics)) - .build(); - - _icon.scaleType = ImageView.ScaleType.FIT_CENTER; - _icon.shapeAppearanceModel = shapeAppearanceModel; + if (Looper.myLooper() == Looper.getMainLooper()) { + applyIcon(bitmap, rounded) } else { - _icon.scaleType = ImageView.ScaleType.CENTER_CROP; - _icon.shapeAppearanceModel = ShapeAppearanceModel(); + post { applyIcon(bitmap, rounded) } } + return this + } - return this; + private fun applyRounded(rounded: Boolean) { + if (rounded) { + val radiusPx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 16.0f, + context.resources.displayMetrics + ) + val shapeAppearanceModel = ShapeAppearanceModel() + .toBuilder() + .setAllCornerSizes(radiusPx) + .build() + + _icon.scaleType = ImageView.ScaleType.FIT_CENTER + _icon.shapeAppearanceModel = shapeAppearanceModel + } else { + _icon.scaleType = ImageView.ScaleType.CENTER_CROP + _icon.shapeAppearanceModel = ShapeAppearanceModel() + } + } + + private fun applyIcon(bitmap: Bitmap, rounded: Boolean) { + _icon.visibility = View.VISIBLE + _icon.setImageBitmap(bitmap) + applyRounded(rounded) } fun withBackground(resourceId: Int): BigButton { diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index fe55714e..7095eca2 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -18,6 +18,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.TimeBar import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.chapters.IChapter @@ -32,6 +33,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView +import com.futo.platformplayer.withMaxSizePx import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -306,6 +308,7 @@ class CastView : ConstraintLayout { Glide.with(_thumbnail) .load(video.thumbnails.getHQThumbnail()) .placeholder(R.drawable.placeholder_video_thumbnail) + .withMaxSizePx() .into(_thumbnail); _textPosition.text = (position * 1000).formatDuration(); _textDuration.text = (video.duration * 1000).formatDuration(); diff --git a/app/src/main/java/com/futo/platformplayer/views/items/ActiveDownloadItem.kt b/app/src/main/java/com/futo/platformplayer/views/items/ActiveDownloadItem.kt index fa393dd9..a45bfdeb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/items/ActiveDownloadItem.kt +++ b/app/src/main/java/com/futo/platformplayer/views/items/ActiveDownloadItem.kt @@ -6,6 +6,7 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.* import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.images.GlideHelper.Companion.crossfade @@ -61,6 +62,7 @@ class ActiveDownloadItem: LinearLayout { Glide.with(_videoImage) .load(download.thumbnail) + .withMaxSizePx() .crossfade() .into(_videoImage); diff --git a/app/src/main/java/com/futo/platformplayer/views/items/PlaylistDownloadItem.kt b/app/src/main/java/com/futo/platformplayer/views/items/PlaylistDownloadItem.kt index 8a5a43f2..a9d2d830 100644 --- a/app/src/main/java/com/futo/platformplayer/views/items/PlaylistDownloadItem.kt +++ b/app/src/main/java/com/futo/platformplayer/views/items/PlaylistDownloadItem.kt @@ -5,9 +5,11 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.R import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.models.PlaylistDownloaded +import com.futo.platformplayer.withMaxSizePx class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumbnail: String?, val obj: Any): LinearLayout(context) { init { inflate(context, R.layout.list_downloaded_playlist, this) } @@ -19,6 +21,7 @@ class PlaylistDownloadItem(context: Context, playlistName: String, playlistThumb imageText.text = playlistName; Glide.with(imageView) .load(playlistThumbnail) + .withMaxSizePx() .crossfade() .into(imageView); } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt index 80175ab8..3a6457fd 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt @@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerView import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.futo.platformplayer.R @@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.video.PlayerManager +import com.futo.platformplayer.withMaxSizePx class FutoThumbnailPlayer : FutoVideoPlayerBase { @@ -135,7 +137,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase { if (videoSource == null && audioSource != null) { val thumbnail = video.thumbnails.getHQThumbnail(); if (!thumbnail.isNullOrBlank()) { - Glide.with(videoView).asBitmap().load(thumbnail).into(_loadArtwork); + Glide.with(videoView).asBitmap().load(thumbnail).withMaxSizePx().into(_loadArtwork); } else { Glide.with(videoView).clear(_loadArtwork); setArtwork(null); diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index 52fa0c63..af666ca9 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -54,6 +54,7 @@ import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.TargetTapLoaderView import com.futo.platformplayer.views.behavior.GestureControlView import com.futo.platformplayer.views.others.ProgressBar +import com.futo.platformplayer.withMaxSizePx import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -928,11 +929,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase { override fun switchToAudioMode(video: IPlatformVideoDetails?) { super.switchToAudioMode(video) - //This causes issues, and is in general confusing, needs improvements - /* val thumbnail = video?.thumbnails?.getHQThumbnail() if (!thumbnail.isNullOrBlank()) { - Glide.with(context).asBitmap().load(thumbnail) + Glide.with(context).asBitmap().load(thumbnail).withMaxSizePx() .into(object : CustomTarget() { override fun onResourceReady( resource: Bitmap, @@ -946,6 +945,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } }) } - */ } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/videometa/UpNextView.kt b/app/src/main/java/com/futo/platformplayer/views/videometa/UpNextView.kt index 26a908c6..0b1ac688 100644 --- a/app/src/main/java/com/futo/platformplayer/views/videometa/UpNextView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/videometa/UpNextView.kt @@ -11,11 +11,13 @@ import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.withMaxSizePx class UpNextView : LinearLayout { private val _layoutContainer: LinearLayout; @@ -160,6 +162,7 @@ class UpNextView : LinearLayout { _textChannelName.text = nextItem.author.name; Glide.with(_imageThumbnail) .load(nextItem.thumbnails.getHQThumbnail()) + .withMaxSizePx() .placeholder(R.drawable.placeholder_video_thumbnail) .into(_imageThumbnail); Glide.with(_imageChannelThumbnail) 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 @@ Subscriptions Loading Retry + Failed to start system installer. Your device’s ROM is not compatible with automatic updates. Cancel Failed to retrieve data, are you connected? Settings