diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9cafddaa..5c5fb27d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -261,5 +261,12 @@ android:name=".UpdateActionReceiver" android:exported="false" /> + + diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c5456bc4..b01e6a94 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -875,9 +875,9 @@ class Settings : FragmentedStorageFileJson() { @DropdownFieldOptionsId(R.array.auto_update_when_array) var check: Int = 0; - @FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1) - @DropdownFieldOptionsId(R.array.background_download) - var backgroundDownload: Int = 0; + @FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1) + //@DropdownFieldOptionsId(R.array.background_download) + var shouldBackgroundDownload: Boolean = false; @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2) @DropdownFieldOptionsId(R.array.when_download) @@ -1052,6 +1052,8 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7) var polycentricLocalCache: Boolean = true; + + var showPrivacyModeDialog: Boolean = true; } @FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index 6090aa56..6c1e134d 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -370,17 +370,19 @@ class UIDialogs { } - fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) { + fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog { val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) - showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) + return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply { + setOnDismissListener { dismissAction?.invoke() } + } } - fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) { + fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog { val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE) - showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) + return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) } fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) { diff --git a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt index 42f452f0..3050a154 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateActionReceiver.kt @@ -6,6 +6,7 @@ import android.content.Intent import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.dialogs.AutoUpdateDialog import com.futo.platformplayer.states.StateApp import java.io.File @@ -16,11 +17,12 @@ class UpdateActionReceiver : BroadcastReceiver() { 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) { + AutoUpdateDialog.currentDialog?.dismiss() + val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) if (version == 0) { return @@ -28,31 +30,19 @@ class UpdateActionReceiver : BroadcastReceiver() { 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) - } + val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply { + putExtra(UpdateDownloadService.EXTRA_VERSION, version) } + ContextCompat.startForegroundService(context, serviceIntent) } private fun handleUpdateNo(context: Context) { + AutoUpdateDialog.currentDialog?.dismiss() NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE) } private fun handleUpdateNever(context: Context) { + AutoUpdateDialog.currentDialog?.dismiss() Settings.instance.autoUpdate.check = 1 Settings.instance.save() @@ -70,21 +60,4 @@ class UpdateActionReceiver : BroadcastReceiver() { 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/UpdateDownloadService.kt b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt index fe01051a..3147ce62 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateDownloadService.kt @@ -1,8 +1,11 @@ package com.futo.platformplayer +import android.app.Dialog import android.app.Service import android.content.Intent import android.os.IBinder +import android.os.SystemClock +import com.futo.platformplayer.UIDialogs.ActionStyle import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateUpdate @@ -21,6 +24,9 @@ class UpdateDownloadService : Service() { private const val MAX_RETRIES = 5 private const val INITIAL_BACKOFF_MS = 5_000L private const val BUFFER_SIZE = 8 * 1024 + private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L + + var updateDownloadedDialog: Dialog? = null } private val job = SupervisorJob() @@ -32,6 +38,8 @@ class UpdateDownloadService : Service() { @Volatile private var cancelRequested: Boolean = false + private var lastProgressUpdateElapsedMs: Long = 0L + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -77,6 +85,16 @@ class UpdateDownloadService : Service() { job.cancel() } + private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean) { + val now = SystemClock.elapsedRealtime() + val force = progress == 100 && !indeterminate + + if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) { + lastProgressUpdateElapsedMs = now + UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate) + } + } + private suspend fun downloadApk(version: Int) { val apkFile = StateUpdate.getApkFile(this, version) val partialFile = StateUpdate.getPartialApkFile(this, version) @@ -186,12 +204,18 @@ class UpdateDownloadService : Service() { progress > 100 -> 100 else -> progress } - UpdateNotificationManager.updateDownloadProgress(this, version, safeProgress, false) + throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false) } } else { - UpdateNotificationManager.updateDownloadProgress(this, version, 0, true) + throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true) } } + + if (!cancelRequested && totalBytes > 0L) { + val finalProgress = 100 + throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false) + } + output.flush() } } @@ -216,12 +240,19 @@ class UpdateDownloadService : Service() { 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) - }, {}) + updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground, + "Update downloaded", + "Would you like to install it now?", null, 0, + UIDialogs.Action("Not now", { + updateDownloadedDialog = null + }, ActionStyle.NONE, true), + UIDialogs.Action("Install", { + UpdateNotificationManager.cancelAll(ctx) + UpdateInstaller.startInstall(ctx, version, apkFile) + }, ActionStyle.PRIMARY, true)); } catch (t: Throwable) { Logger.w(TAG, "Failed to show in-app update downloaded dialog", t) + updateDownloadedDialog = null } } } diff --git a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt index 4f45ed0a..b81d5096 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateInstaller.kt @@ -7,7 +7,9 @@ import android.app.PendingIntent.getBroadcast import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller +import android.graphics.drawable.Animatable import android.provider.Settings +import android.view.View import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.receivers.InstallReceiver import kotlinx.coroutines.Dispatchers @@ -17,20 +19,24 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream import androidx.core.net.toUri +import com.futo.platformplayer.dialogs.AutoUpdateDialog +import com.futo.platformplayer.states.StateApp object UpdateInstaller { private const val TAG = "UpdateInstaller" @SuppressLint("RequestInstallPackagesPolicy") - fun startInstall(context: Context, apkFile: File) { + fun startInstall(context: Context, version: Int, apkFile: File) { if (!apkFile.exists()) { Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}") UIDialogs.toast(context, "Update file missing") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.") return } if (BuildConfig.IS_PLAYSTORE_BUILD) { UIDialogs.toast(context, "Updates are managed by the Play Store") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.") return } @@ -38,6 +44,7 @@ object UpdateInstaller { val pm = context.packageManager if (!pm.canRequestPackageInstalls()) { UIDialogs.toast(context, "Allow this app to install updates, then try again") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.") val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { data = "package:${context.packageName}".toUri() @@ -53,8 +60,8 @@ object UpdateInstaller { 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) @@ -68,10 +75,17 @@ object UpdateInstaller { session.fsync(sessionStream) } - val intent = Intent(context, InstallReceiver::class.java) + val intent = Intent(context, InstallReceiver::class.java).apply { + putExtra(UpdateNotificationManager.EXTRA_VERSION, version) + putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath) + } val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT) val statusReceiver = pendingIntent.intentSender + InstallReceiver.onReceiveResult.subscribe(this) { message -> + InstallReceiver.onReceiveResult.clear(); + onReceiveResult(context, version, apkFile, message); + }; Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}") session.commit(statusReceiver) } catch (e: Throwable) { @@ -80,10 +94,29 @@ object UpdateInstaller { withContext(Dispatchers.Main) { UIDialogs.toast(context, "Failed to install update: ${e.message}") } + + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message) } finally { session?.close() inputStream?.close() } } } + + private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) { + try { + InstallReceiver.onReceiveResult.remove(this) + + if (result.isNullOrEmpty()) { + Logger.i(TAG, "Update install finished successfully") + UpdateNotificationManager.showInstallSucceededNotification(context, version) + } else { + Logger.w(TAG, "Update install failed: $result") + UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result) + UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result") + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to handle install result", e) + } + } } diff --git a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt index aafeeec4..b424dcd9 100644 --- a/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt +++ b/app/src/main/java/com/futo/platformplayer/UpdateNotificationManager.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.PendingIntent.FLAG_MUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.getBroadcast @@ -13,6 +14,7 @@ import android.content.pm.PackageManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import com.futo.platformplayer.activities.InstallUpdateActivity import java.io.File object UpdateNotificationManager { @@ -25,6 +27,7 @@ object UpdateNotificationManager { 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" + private const val REQUEST_CODE_INSTALL = 1001 const val EXTRA_VERSION = "version" const val EXTRA_APK_PATH = "apk_path" @@ -32,16 +35,55 @@ object UpdateNotificationManager { const val NOTIF_ID_AVAILABLE = 2001 const val NOTIF_ID_DOWNLOADING = 2002 const val NOTIF_ID_READY = 2003 + const val NOTIF_ID_INSTALL_FAILED = 2004 + const val NOTIF_ID_INSTALL_SUCCEEDED = 2005 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 + val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply { + description = CHANNEL_DESCRIPTION + enableVibration(false) + enableLights(false) + setSound(null, null) + } manager.createNotificationChannel(channel) } } + fun showInstallSucceededNotification(context: Context, version: Int) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } + + ensureChannel(context) + + val launchIntent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + } + + val launchPendingIntent = launchIntent?.let { + PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.foreground) + .setContentTitle("Update installed") + .setContentText("Version $version installed. Tap to open.") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setSilent(true) + + if (launchPendingIntent != null) { + builder.setContentIntent(launchPendingIntent) + builder.addAction(0, "Open app", launchPendingIntent) + } + + NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build()) + } + fun showUpdateAvailableNotification(context: Context, version: Int) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { return @@ -70,9 +112,11 @@ object UpdateNotificationManager { .setContentText("A new version ($version) is available.") .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) - .addAction(0, "Download", yesPendingIntent) - .addAction(0, "Not now", noPendingIntent) + .setContentIntent(yesPendingIntent) + .setSilent(true) .addAction(0, "Never", neverPendingIntent) + .addAction(0, "Not now", noPendingIntent) + .addAction(0, "Download", yesPendingIntent) NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build()) } @@ -95,8 +139,9 @@ object UpdateNotificationManager { .setSmallIcon(R.drawable.foreground) .setContentTitle("Downloading update") .setContentText("Downloading version $version") - .setPriority(NotificationCompat.PRIORITY_LOW) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setOngoing(true) + .setSilent(true) .addAction(0, "Cancel", cancelPendingIntent) if (indeterminate) { @@ -123,24 +168,17 @@ object UpdateNotificationManager { } 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 installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath) + val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.foreground) .setContentTitle("Update downloaded") .setContentText("Tap to install version $version.") .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(installPendingIntent) .setAutoCancel(true) + .setSilent(true) .addAction(0, "Install", installPendingIntent) NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) @@ -159,13 +197,37 @@ object UpdateNotificationManager { .setContentText(error?.message ?: "Unknown error") .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) + .setSilent(true) NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build()) } + fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) + return + + ensureChannel(context) + + val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath) + val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.foreground) + .setContentTitle("Failed to install update") + .setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.") + .setAutoCancel(true) + .setSilent(true) + .setContentIntent(installPendingIntent) + .addAction(0, "Install again", installPendingIntent) + + NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build()) + } + fun cancelAll(context: Context) { NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE) NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING) NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY) + NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED) + NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED) + } } diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index b154cb67..c41be6d0 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -444,15 +444,9 @@ fun addressScore(addr: InetAddress): Int { 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 +fun RequestBuilder.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder { + return this; + //.downsample(DownsampleStrategy.AT_MOST) + //.override(maxSizePx, maxSizePx) + //.centerInside() } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt new file mode 100644 index 00000000..48d600e5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/InstallUpdateActivity.kt @@ -0,0 +1,49 @@ +package com.futo.platformplayer.activities + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UpdateInstaller +import com.futo.platformplayer.UpdateNotificationManager +import com.futo.platformplayer.logging.Logger +import java.io.File + +class InstallUpdateActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + UpdateNotificationManager.cancelAll(this) + + val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0) + val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH) + + if (version == 0 || apkPath.isNullOrEmpty()) { + Logger.w("InstallUpdateActivity", "Missing version or apkPath") + finish() + return + } + + val apkFile = File(apkPath) + if (!apkFile.exists()) { + Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath") + UIDialogs.Companion.toast(this, "Update file missing") + finish() + return + } + + UpdateInstaller.startInstall(this, version, apkFile) + finish() + } + + companion object { + fun createIntent(context: Context, version: Int, apkPath: String): Intent = + Intent(context, InstallUpdateActivity::class.java).apply { + putExtra(UpdateNotificationManager.EXTRA_VERSION, version) + putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } +} 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 b9ecf3f4..85e4c47d 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -618,8 +618,8 @@ 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."); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) { + requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available."); } val submissionStatus = FragmentedStorage.get("subscriptionSubmissionStatus") @@ -1299,11 +1299,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { navigate(last.first, last.second, false, true); } else { if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { + Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed"); finish(); } else { + //UIDialogs.toast("Grayjay continues in background because of an open video.") + if(Settings.instance.playback.isBackgroundPictureInPicture()) { + try { + _fragVideoDetail._viewDetail?.startPictureInPicture(); + _fragVideoDetail?.forcePictureInPicture(); + } catch (ex: Throwable) { + } //Fail silently + } + else + moveTaskToBack(false); + /* UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", { finish(); }) + */ } } } diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt new file mode 100644 index 00000000..63745991 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpContentUriHandler.kt @@ -0,0 +1,318 @@ +package com.futo.platformplayer.api.http.server.handlers + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import android.provider.OpenableColumns +import com.futo.platformplayer.api.http.server.HttpContext +import com.futo.platformplayer.api.http.server.HttpHeaders +import com.futo.platformplayer.logging.Logger +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.* + +class HttpContentUriHandler( + method: String, + path: String, + private val contentResolver: ContentResolver, + private val uri: Uri, + private val explicitContentType: String? = null +) : HttpHandler(method, path) { + + override fun handle(httpContext: HttpContext) { + val resolver = contentResolver + val requestHeaders = httpContext.headers + val responseHeaders = this.headers.clone() + + val meta = try { + queryMetadata(resolver, uri) + } catch (e: Exception) { + Logger.e(TAG, "Failed to query metadata for $uri", e) + httpContext.respondCode(404, responseHeaders) + return + } + + val contentType = explicitContentType + ?: resolver.getType(uri) + ?: "application/octet-stream" + responseHeaders["Content-Type"] = contentType + + meta.lastModifiedMillis?.let { lastModified -> + responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified)) + + val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"] + if (ifModifiedSinceHeader != null) { + val ifModifiedSince = try { + httpDateFormat.parse(ifModifiedSinceHeader) + } catch (_: Exception) { + null + } + + if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) { + httpContext.respondCode(304, responseHeaders) + return + } + } + } + + val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"") + responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\"" + + val length = meta.size + if (length == null) { + Logger.i(TAG, "Streaming $uri with unknown length; Range not supported") + responseHeaders.remove("Content-Length") + responseHeaders.remove("Content-Range") + responseHeaders.remove("Accept-Ranges") + + stream( + httpContext = httpContext, + resolver = resolver, + uri = uri, + statusCode = 200, + headers = responseHeaders, + start = null, + length = null + ) + return + } + + responseHeaders["Accept-Ranges"] = "bytes" + + val rangeHeader = requestHeaders["Range"] + if (rangeHeader.isNullOrBlank()) { + responseHeaders["Content-Length"] = length.toString() + Logger.i(TAG, "Sending full content for $uri, length=$length") + + stream( + httpContext = httpContext, + resolver = resolver, + uri = uri, + statusCode = 200, + headers = responseHeaders, + start = 0L, + length = length + ) + return + } + + val range = parseRange(rangeHeader, length) + if (range == null) { + Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)") + responseHeaders["Content-Range"] = "bytes */$length" + httpContext.respondCode(416, responseHeaders) + return + } + + val start = range.first + val endInclusive = range.last + val bytesToSend = endInclusive - start + 1 + + responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length" + responseHeaders["Content-Length"] = bytesToSend.toString() + Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri") + + stream( + httpContext = httpContext, + resolver = resolver, + uri = uri, + statusCode = 206, + headers = responseHeaders, + start = start, + length = bytesToSend + ) + } + + data class ContentMeta( + val displayName: String?, + val size: Long?, + val lastModifiedMillis: Long? + ) + + private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta { + var displayName: String? = null + var size: Long? = null + var lastModifiedMillis: Long? = null + + resolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && !cursor.isNull(nameIndex)) { + displayName = cursor.getString(nameIndex) + } + + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) { + val s = cursor.getLong(sizeIndex) + if (s >= 0) size = s // -1 means unknown + } + + val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) { + val seconds = cursor.getLong(dateModifiedIndex) + if (seconds > 0) { + lastModifiedMillis = seconds * 1000L + } + } + + if (lastModifiedMillis == null) { + val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED) + if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) { + val seconds = cursor.getLong(dateAddedIndex) + if (seconds > 0) { + lastModifiedMillis = seconds * 1000L + } + } + } + } + } + + if (displayName == null) { + displayName = uri.lastPathSegment + } + + if (size == null) { + try { + resolver.openAssetFileDescriptor(uri, "r")?.use { afd -> + val assetLen = afd.length + if (assetLen >= 0) { + size = assetLen + } + } + } catch (_: Exception) { } + } + + return ContentMeta( + displayName = displayName, + size = size, + lastModifiedMillis = lastModifiedMillis + ) + } + + private fun parseRange(header: String, totalLength: Long): LongRange? { + if (totalLength <= 0L) return null + + val prefix = "bytes=" + if (!header.startsWith(prefix, ignoreCase = true)) return null + + val spec = header.substring(prefix.length).trim() + if (spec.isEmpty()) return null + + if (spec.contains(",")) return null + + val dashIndex = spec.indexOf('-') + if (dashIndex < 0) return null + + val startPart = spec.substring(0, dashIndex).trim() + val endPart = spec.substring(dashIndex + 1).trim() + + return when { + startPart.isNotEmpty() -> { + val start = startPart.toLongOrNull() ?: return null + if (start < 0 || start >= totalLength) return null + + val end = if (endPart.isNotEmpty()) { + val rawEnd = endPart.toLongOrNull() ?: return null + if (rawEnd < start) return null + rawEnd.coerceAtMost(totalLength - 1) + } else { + totalLength - 1 + } + + start..end + } + + endPart.isNotEmpty() -> { + val suffixLen = endPart.toLongOrNull() ?: return null + if (suffixLen <= 0L) return null + + if (suffixLen >= totalLength) { + 0L..(totalLength - 1) + } else { + val start = totalLength - suffixLen + val end = totalLength - 1 + start..end + } + } + + else -> null + } + } + + private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) { + try { + val input = resolver.openInputStream(uri) + if (input == null) { + Logger.w(TAG, "Content not found: $uri") + httpContext.respondCode(404, headers) + return + } + + input.use { inputStream -> + httpContext.respond(statusCode, headers) { outputStream -> + try { + val offset = start ?: 0L + if (offset > 0L) { + skipFully(inputStream, offset) + } + copyStream(inputStream, outputStream, length) + outputStream.flush() + } catch (e: Exception) { + Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e) + } + } + } + } catch (e: FileNotFoundException) { + Logger.w(TAG, "Content not found: $uri", e) + httpContext.respondCode(404, headers) + } catch (e: Exception) { + Logger.e(TAG, "Failed to open stream for $uri", e) + httpContext.respondCode(500, headers) + } + } + + private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) { + val buffer = ByteArray(8192) + if (limit == null) { + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + } + } else { + var remaining = limit + while (remaining > 0L) { + val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt() + val read = input.read(buffer, 0, toRead) + if (read < 0) break + output.write(buffer, 0, read) + remaining -= read.toLong() + } + } + } + + private fun skipFully(input: InputStream, bytesToSkip: Long) { + var remaining = bytesToSkip + while (remaining > 0L) { + val skipped = input.skip(remaining) + if (skipped <= 0L) { + val b = input.read() + if (b == -1) break + remaining -= 1L + } else { + remaining -= skipped + } + } + } + + companion object { + private const val TAG = "HttpContentUriHandler" + + private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + } +} diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt index 52659b46..98dc3524 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/LocalPlatformVideoDetails.kt @@ -73,10 +73,10 @@ open class LocalVideoDetails( override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false) (LocalVideoUnMuxedSourceDescriptor( arrayOf(), - arrayOf(LocalAudioContentSource(url, mimeType ?: "", name)) + arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration)) )) else (LocalVideoMuxedSourceDescriptor( - LocalVideoContentSource(url, mimeType ?: "", name) + LocalVideoContentSource(url, mimeType ?: "", name, duration) )) ); override val preview: ISerializedVideoSourceDescriptor? = null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt index ed428790..5a41d0a2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt @@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.invokeV8 import com.futo.platformplayer.invokeV8Void import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDeveloper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import java.util.Base64 -class JSRequestExecutor { +class JSRequestExecutor: AutoCloseable { private val _plugin: JSClient; private val _config: IV8PluginConfig; private var _executor: V8ValueObject; @@ -29,6 +32,9 @@ class JSRequestExecutor { private val hasCleanup: Boolean; + private var _cleanLock = Any(); + private var _cleaned: Boolean = false; + constructor(plugin: JSClient, executor: V8ValueObject) { this._plugin = plugin; this._executor = executor; @@ -102,8 +108,12 @@ class JSRequestExecutor { open fun cleanup() { - if (!hasCleanup || _executor.isClosed) - return; + synchronized(_cleanLock) { + if (!hasCleanup || _executor.isClosed || _cleaned) + return; + _cleaned = true; + } + Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested"); _plugin.busy { if(_plugin is DevJSClient) StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") { @@ -125,9 +135,25 @@ class JSRequestExecutor { } } - protected fun finalize() { + override fun close() { cleanup(); } + + fun closeAsync() { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ + try { + close(); + } + catch(ex: Throwable) { + Logger.e("JSRequestExecutor", "Cleanup failed"); + } + } + } + + /* + protected fun finalize() { + cleanup(); + }*/ } //TODO: are these available..? diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt index 06f1c50c..23f268f2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalAudioContentSource.kt @@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource { var contentUrl: String; - constructor(contentUrl: String, mime: String, name: String? = null) { + constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) { this.name = name ?: "File"; container = mime; - duration = 0; + this.duration = duration; this.contentUrl = contentUrl; } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt index d8507fab..e8b37364 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoContentSource.kt @@ -22,12 +22,12 @@ class LocalVideoContentSource: IVideoSource { var contentUrl: String; - constructor(contentUrl: String, mime: String, name: String? = null) { + constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) { this.name = name ?: "File"; width = 0; height = 0; container = mime; - duration = 0; + this.duration = duration; this.contentUrl = contentUrl; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt index 1560eff1..84d96e02 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDeviceExp.kt @@ -239,7 +239,7 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() { } DeviceConnectionState.Disconnected -> { - connectionState = CastConnectionState.CONNECTING + connectionState = CastConnectionState.DISCONNECTED onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED) } } @@ -268,4 +268,4 @@ class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() { companion object { private val TAG = "CastingDeviceExp" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 269a73b8..5c38f12f 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -6,6 +6,7 @@ import android.content.Context import android.os.Looper import android.util.Log import androidx.annotation.OptIn +import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R import com.futo.platformplayer.Settings @@ -14,6 +15,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler +import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler @@ -34,6 +36,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource import com.futo.platformplayer.awaitCancelConverted import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.models.CastingDeviceInfo @@ -235,9 +239,9 @@ abstract class StateCasting { Logger.i(TAG, "Connect to device ${device.name}") } - fun metadataFromVideo(video: IPlatformVideoDetails): Metadata { + fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata { return Metadata( - title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail() + title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail() ) } @@ -371,6 +375,12 @@ abstract class StateCasting { } else if (audioSource is LocalAudioSource) { Logger.i(TAG, "Casting as local audio"); castLocalAudio(video, audioSource, resumePosition, speed); + } else if (videoSource is LocalVideoContentSource) { + Logger.i(TAG, "Casting as local video"); + castLocalVideo(contentResolver, video, videoSource, resumePosition, speed); + } else if (audioSource is LocalAudioContentSource) { + Logger.i(TAG, "Casting as local audio"); + castLocalAudio(contentResolver, video, audioSource, resumePosition, speed); } else if (videoSource is JSDashManifestRawSource) { Logger.i(TAG, "Casting as JSDashManifestRawSource video"); castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading); @@ -461,6 +471,65 @@ abstract class StateCasting { } return true; } + + private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List { + val ad = activeDevice ?: return listOf(); + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + val videoPath = "/video-${id}" + val videoUrl = url + videoPath; + val thumbnailPath = "/thumbnail-${id}" + val thumbnailUrl = url + thumbnailPath; + val thumbnailContentUrl = video.thumbnails.getHQThumbnail() + + if (thumbnailContentUrl != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null)); + + return listOf(videoUrl); + } + + private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List { + val ad = activeDevice ?: return listOf(); + + val url = getLocalUrl(ad); + val id = UUID.randomUUID(); + val audioPath = "/audio-${id}" + val audioUrl = url + audioPath; + val thumbnailPath = "/thumbnail-${id}" + val thumbnailUrl = url + thumbnailPath; + val thumbnailContentUrl = video.thumbnails.getHQThumbnail() + + if (thumbnailContentUrl != null) { + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + } + + _castServer.addHandlerWithAllowAllOptions( + HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri()) + .withHeader("Access-Control-Allow-Origin", "*"), true + ).withTag("cast"); + + Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null)); + + return listOf(audioUrl); + } + private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); @@ -1280,10 +1349,14 @@ abstract class StateCasting { } if (audioSource != null && audioSource.hasRequestExecutor) { + val oldExecutor = _audioExecutor; + oldExecutor?.closeAsync(); _audioExecutor = audioSource.getRequestExecutor() } if (videoSource != null && videoSource.hasRequestExecutor) { + val oldExecutor = _videoExecutor; + oldExecutor?.closeAsync(); _videoExecutor = videoSource.getRequestExecutor() } 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 fbca0f6b..ccee6082 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt @@ -21,6 +21,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UpdateDownloadService +import com.futo.platformplayer.UpdateNotificationManager import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.logging.Logger @@ -36,6 +37,8 @@ import java.io.InputStream class AutoUpdateDialog(context: Context?) : AlertDialog(context) { companion object { private val TAG = "AutoUpdateDialog"; + + var currentDialog: AutoUpdateDialog? = null } private lateinit var _buttonNever: Button; @@ -62,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { _buttonShowChangelog = findViewById(R.id.button_show_changelog); _buttonNever.setOnClickListener { + UpdateNotificationManager.cancelAll(context) Settings.instance.autoUpdate.check = 1; Settings.instance.save(); dismiss(); }; _buttonClose.setOnClickListener { + UpdateNotificationManager.cancelAll(context) dismiss(); }; @@ -77,11 +82,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { }; _buttonUpdate.setOnClickListener { + UpdateNotificationManager.cancelAll(context) + if (_updating) { return@setOnClickListener; } - if (Settings.instance.autoUpdate.backgroundDownload == 1) { + if (Settings.instance.autoUpdate.shouldBackgroundDownload) { val ctx = context.applicationContext; val intent = Intent(ctx, UpdateDownloadService::class.java); intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion); @@ -94,11 +101,13 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) { } }; + currentDialog = this } override fun dismiss() { super.dismiss() InstallReceiver.onReceiveResult.clear(); + currentDialog = null Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.") } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 0881bb6e..3b727d21 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -865,6 +865,7 @@ class VideoDownload { val sourceLength: Long?; val fileStream = FileOutputStream(targetFile); + var executor: JSRequestExecutor? = null; try{ var manifest = source.manifest; if(source.hasGenerate) @@ -881,7 +882,7 @@ class VideoDownload { if(foundCues.count() <= 0) throw IllegalStateException("No Cues found in manifest (unsupported dash?)"); - val executor = if(source is JSSource && source.hasRequestExecutor) + executor = if(source is JSSource && source.hasRequestExecutor) source.getRequestExecutor(); else null; @@ -940,6 +941,7 @@ class VideoDownload { } finally { fileStream.close(); + executor?.closeAsync() } return sourceLength!!; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt index a51e7620..3e176ece 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt @@ -154,6 +154,17 @@ class MenuBottomBarFragment : MainActivityFragment() { else { StateApp.instance.setPrivacyMode(true); UIDialogs.appToast("Privacy mode enabled"); + + if(Settings.instance.other.showPrivacyModeDialog) + UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode", + "All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0, + UIDialogs.Action("Don't show again", { + Settings.instance.other.showPrivacyModeDialog = false; + Settings.instance.save(); + }, UIDialogs.ActionStyle.NONE), + UIDialogs.Action("Understood", { + + }, UIDialogs.ActionStyle.PRIMARY)); } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 91e6aaa3..c95238aa 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.PlatformID @@ -55,6 +56,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.subscriptions.SubscribeButton +import com.futo.platformplayer.withTimestamp import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.toURLInfoSystemLinkUrl @@ -198,8 +200,12 @@ class ChannelFragment : MainFragment() { adapter.onContentClicked.subscribe { v, _ -> when (v) { is IPlatformVideo -> { - StatePlayer.instance.clearQueue() - fragment.navigate(v).maximizeVideoDetail() + //StatePlayer.instance.clearQueue() + if (StatePlayer.instance.hasQueue) { + StatePlayer.instance.insertToQueue(v, true); + } else { + fragment.navigate(v).maximizeVideoDetail(); + } } is IPlatformPlaylist -> { @@ -244,7 +250,7 @@ class ChannelFragment : MainFragment() { adapter.onContentUrlClicked.subscribe { url, contentType -> when (contentType) { ContentType.MEDIA -> { - StatePlayer.instance.clearQueue() + StatePlayer.instance.clearQueue(); fragment.navigate(url).maximizeVideoDetail() } @@ -403,7 +409,7 @@ class ChannelFragment : MainFragment() { _fragment.topBar?.onShown(channel) val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { - UIDialogs.showConfirmationDialog(context, + val dialog = UIDialogs.showConfirmationDialog(context, context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist) .replace("{channelName}", channel.name), { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt index 86cb4cc4..9b05816c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LoginFragment.kt @@ -55,7 +55,7 @@ class LoginFragment : MainFragment() { fun showLogin(config: SourcePluginConfig, callback: ((SourceAuth?) -> Unit)? = null) { if(_callback != null) _callback?.invoke(null); _callback = callback; - StateApp.instance.activity?.navigate(config, false); + StateApp.instance.activity?.navigate(config, true); } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 26a2577b..4c693230 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -50,7 +50,7 @@ class VideoDetailFragment() : MainFragment() { private var _isActive: Boolean = false; - private var _viewDetail : VideoDetailView? = null; + var _viewDetail : VideoDetailView? = null; private var _view : SingleViewTouchableMotionLayout? = null; var isFullscreen : Boolean = false; @@ -372,14 +372,18 @@ class VideoDetailFragment() : MainFragment() { onMinimize.emit(); } else if (state != State.MAXIMIZED && progress > 0.9) { + state = State.MAXIMIZED; + onMaximized.emit(); + /* if (_isInitialMaximize) { - state = State.CLOSED; + //state = State.CLOSED; Causes issues? might no longer be needed _isInitialMaximize = false; } else { state = State.MAXIMIZED; onMaximized.emit(); } + */ } if (isTransitioning && (progress > 0.6 || progress < 0.4)) { @@ -450,7 +454,8 @@ class VideoDetailFragment() : MainFragment() { if (viewDetail.shouldEnterPictureInPicture) { _leavingPiP = false } - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) { + val shouldPiP = Settings.instance.playback.isBackgroundPictureInPicture() + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && shouldPiP && !viewDetail.isAudioOnlyUserAction) { val params = _viewDetail?.getPictureInPictureParams(); if(params != null) { Logger.i(TAG, "enterPictureInPictureMode") diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt index 43f9dc26..a496c668 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/GeneralTopBarFragment.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.fragment.mainactivity.topbar +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -49,7 +50,11 @@ class GeneralTopBarFragment : TopFragment() { } else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) { navigate(SuggestionsFragmentData("", SearchType.PLAYLIST)); } else if (currentMain is LibraryFragment) { - navigate(); + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + UIDialogs.toast("Your Android version is too old for Mediastore search", true); + } + else + navigate(); } else { navigate(SuggestionsFragmentData("", SearchType.VIDEO)); } 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 7ac860e3..ad58ffbb 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -573,7 +573,7 @@ class StateApp { } if (Settings.instance.autoUpdate.isAutoUpdateEnabled()) { - if (Settings.instance.autoUpdate.backgroundDownload == 1) { + if (Settings.instance.autoUpdate.shouldBackgroundDownload) { Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate Background]"); val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) 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 9176b8ae..b2d4149b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -344,7 +344,8 @@ class StateLibrary { MediaStore.Video.Media.DISPLAY_NAME, MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.MIME_TYPE, - MediaStore.Video.Media.BUCKET_DISPLAY_NAME + MediaStore.Video.Media.BUCKET_DISPLAY_NAME, + MediaStore.Video.Media.DURATION ); val PROJECTION_MEDIA = arrayOf( MediaStore.Audio.Media._ID, //0 @@ -487,9 +488,10 @@ class StateLibrary { ""; - val albumContentUrl = if(albumId > 0) - ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() - else null; + val albumArtBase = Uri.parse("content://media/external/audio/albumart") + val albumContentUrl = if (albumId > 0) + ContentUris.withAppendedId(albumArtBase, albumId).toString() + else null val dateObj = if(date > 0) OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC) @@ -515,6 +517,8 @@ class StateLibrary { val date = cursor.getLong(2); val contentType = cursor.getString(3); val category = cursor.getString(4); + val durationMs = cursor.getLong(5) + val duration = if (durationMs > 0) durationMs / 1000 else -1 val idLong = id.toLongOrNull(); val contentUrl = if(idLong != null ) @@ -534,7 +538,7 @@ class StateLibrary { PlatformID("FILE", contentUrl, null, 0, -1), displayName, Thumbnails(arrayOf( Thumbnail(contentUrl, 0) - )), authorObj, contentUrl, -1, contentType, dateObj); + )), authorObj, contentUrl, duration, contentType, dateObj); } private var _instance : StateLibrary? = null; @@ -622,11 +626,12 @@ class Artist { val numTracks = cursor.getInt(2); val numAlbums = cursor.getInt(3); - val idLong = id.toLongOrNull(); - val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; + val idLong = id.toLongOrNull() + val uri = if (idLong != null) + ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, idLong) + else null - return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()); - } + return Artist(artist, numTracks, numAlbums, null, id, uri?.toString()) } fun getArtist(id: Long): Artist? { val resolver = StateApp.instance.contextOrNull?.contentResolver; @@ -730,9 +735,10 @@ class Album { val numTracks = cursor.getInt(2); val artist = cursor.getString(3); - val idLong = id.toLongOrNull(); - val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null; - return Album(album, numTracks, artist, id, uri?.toString()); + val idLong = id.toLongOrNull() + val albumArtBase = Uri.parse("content://media/external/audio/albumart") + val uri = if (idLong != null) ContentUris.withAppendedId(albumArtBase, idLong) else null + return Album(album, numTracks, artist, id, uri?.toString()) } fun getAlbumTracks(albumId: Long): List { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index cb853f07..38473023 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -169,6 +169,9 @@ class StatePlugins { return false; LoginFragment.showLogin(config) {//LoginActivity.showLogin(context, config) { + + if(it == null) + return@showLogin; try { StatePlugins.instance.setPluginAuth(config.id, it); } catch (e: Throwable) { diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 67be4058..7bc7dffe 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -77,7 +77,7 @@ class VideoListEditorView : FrameLayout { executeDelete() }, cancelAction = { - }, doNotAskAgainAction = { + }, dismissAction = {}, doNotAskAgainAction = { Settings.instance.other.playlistDeleteConfirmation = false Settings.instance.save() }) 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 af666ca9..b62c8f50 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 @@ -489,7 +489,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase { StatePlayer.instance.onQueueChanged.subscribe(this) { CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { - setLoopVisible(!StatePlayer.instance.hasQueue) + //setLoopVisible(!StatePlayer.instance.hasQueue) updateNextPrevious(); } } @@ -886,12 +886,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } fun updateLoopVideoUI() { if(StatePlayer.instance.loopVideo) { - _control_loop.setImageResource(R.drawable.ic_loop_active); - _control_loop_fullscreen.setImageResource(R.drawable.ic_loop_active); + _control_loop.setImageResource(R.drawable.ic_repeat_one_active); + _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one_active); } else { - _control_loop.setImageResource(R.drawable.ic_loop); - _control_loop_fullscreen.setImageResource(R.drawable.ic_loop); + _control_loop.setImageResource(R.drawable.ic_repeat_one); + _control_loop_fullscreen.setImageResource(R.drawable.ic_repeat_one); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java index 859c1b2f..9c106a9a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java +++ b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java @@ -111,6 +111,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { * @return This factory. */ public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) { + JSRequestExecutor oldExecutor = this.requestExecutor; + if(oldExecutor != null) { + oldExecutor.closeAsync(); + } this.requestExecutor = requestExecutor; return this; } @@ -123,6 +127,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { * @return This factory. */ public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) { + JSRequestExecutor oldExecutor = this.requestExecutor2; + if(oldExecutor != null) { + oldExecutor.closeAsync(); + } this.requestExecutor2 = requestExecutor; return this; } @@ -508,6 +516,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { @Override public void close() throws HttpDataSourceException { + if(requestExecutor != null) + requestExecutor.closeAsync(); + if(requestExecutor2 != null) + requestExecutor2.closeAsync(); try { @Nullable InputStream inputStream = this.inputStream; if (inputStream != null) { diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml new file mode 100644 index 00000000..48a96fbe --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_one.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_repeat_one_active.xml b/app/src/main/res/drawable/ic_repeat_one_active.xml new file mode 100644 index 00000000..4556487e --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_one_active.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/incognito.png b/app/src/main/res/drawable/incognito.png new file mode 100644 index 00000000..7321cdc5 Binary files /dev/null and b/app/src/main/res/drawable/incognito.png differ diff --git a/app/src/main/res/drawable/incognito_purple.png b/app/src/main/res/drawable/incognito_purple.png new file mode 100644 index 00000000..b74d9215 Binary files /dev/null and b/app/src/main/res/drawable/incognito_purple.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index df3abc69..9adf1cd4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -77,12 +77,13 @@ android:layout_width="50dp" android:layout_height="50dp" android:contentDescription="@string/cd_incognito_button" - android:src="@drawable/ic_disabled_visible_purple" + android:src="@drawable/incognito_purple" android:background="@drawable/background_button_round_black" android:scaleType="fitCenter" android:visibility="visible" android:layout_marginLeft="10dp" android:layout_marginBottom="10dp" + android:padding="8dp" android:elevation="50dp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintBottom_toTopOf="@id/toast_view" /> diff --git a/app/src/main/res/layout/fragment_overview_bottom_bar.xml b/app/src/main/res/layout/fragment_overview_bottom_bar.xml index 4fde34a4..070130bf 100644 --- a/app/src/main/res/layout/fragment_overview_bottom_bar.xml +++ b/app/src/main/res/layout/fragment_overview_bottom_bar.xml @@ -75,7 +75,7 @@ + android:src="@drawable/incognito" /> diff --git a/app/src/main/res/layout/video_player_ui.xml b/app/src/main/res/layout/video_player_ui.xml index 486936a6..8e31747c 100644 --- a/app/src/main/res/layout/video_player_ui.xml +++ b/app/src/main/res/layout/video_player_ui.xml @@ -65,7 +65,7 @@ android:scaleType="fitCenter" android:clickable="true" android:padding="12dp" - app:srcCompat="@drawable/ic_loop" /> + app:srcCompat="@drawable/ic_repeat_one" /> + app:srcCompat="@drawable/ic_repeat_one" /> @font/inter_regular + + \ No newline at end of file