From 42c87f6c3848ce3b04d73c1d86409ef2ae35e4c8 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Mon, 4 Sep 2023 18:58:28 +0200 Subject: [PATCH 1/2] Request bluetooth permission to pause on device disconnect Since Android S, a new permission is required to be able to receive the relevant broadcast events. --- app/src/main/AndroidManifest.xml | 10 ++- .../java/org/jellyfin/mobile/MainActivity.kt | 2 + .../org/jellyfin/mobile/app/AppPreferences.kt | 8 +++ .../jellyfin/mobile/bridge/NativeInterface.kt | 3 + .../jellyfin/mobile/events/ActivityEvent.kt | 1 + .../mobile/events/ActivityEventHandler.kt | 6 ++ .../mobile/utils/BluetoothPermissionHelper.kt | 67 +++++++++++++++++++ .../org/jellyfin/mobile/utils/Constants.kt | 1 + app/src/main/res/values/strings.xml | 4 ++ 9 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/utils/BluetoothPermissionHelper.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 16f9b9640..64263b690 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,15 @@ - + + diff --git a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt index 9406b9696..fa0cbd6c1 100644 --- a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt +++ b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt @@ -26,6 +26,7 @@ import org.jellyfin.mobile.player.ui.PlayerFragment import org.jellyfin.mobile.setup.ConnectFragment import org.jellyfin.mobile.utils.AndroidVersion import org.jellyfin.mobile.utils.BackPressInterceptor +import org.jellyfin.mobile.utils.BluetoothPermissionHelper import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.PermissionRequestHelper import org.jellyfin.mobile.utils.SmartOrientationListener @@ -41,6 +42,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : AppCompatActivity() { private val activityEventHandler: ActivityEventHandler = get() val mainViewModel: MainViewModel by viewModel() + val bluetoothPermissionHelper: BluetoothPermissionHelper = BluetoothPermissionHelper(this, get()) val chromecast: IChromecast = Chromecast() private val permissionRequestHelper: PermissionRequestHelper by inject() diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt index 3da19f944..2ac38b25b 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt @@ -46,6 +46,14 @@ class AppPreferences(context: Context) { } } + var ignoreBluetoothPermission: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, false) + set(value) { + sharedPreferences.edit { + putBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, value) + } + } + var downloadMethod: Int? get() = sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1).takeIf { it >= 0 } set(value) { diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt index 99f0f75ce..c85ad0f3d 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt @@ -99,6 +99,9 @@ class NativeInterface(private val context: Context) : KoinComponent { putExtra(EXTRA_IS_PAUSED, options.optBoolean(EXTRA_IS_PAUSED, true)) } context.startService(intent) + + // We may need to request bluetooth permission to react to bluetooth disconnect events + activityEventHandler.emit(ActivityEvent.RequestBluetoothPermission) return true } diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt index c3ec0b01f..afec2b9af 100644 --- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt @@ -10,6 +10,7 @@ sealed class ActivityEvent { class OpenUrl(val uri: String) : ActivityEvent() class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent() class CastMessage(val action: String, val args: JSONArray) : ActivityEvent() + data object RequestBluetoothPermission : ActivityEvent() object OpenSettings : ActivityEvent() object SelectServer : ActivityEvent() object ExitApp : ActivityEvent() diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt index adcc45e6a..dd464fe46 100644 --- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt @@ -41,6 +41,7 @@ class ActivityEventHandler( } } + @Suppress("CyclomaticComplexMethod", "LongMethod") private suspend fun MainActivity.handleEvent(event: ActivityEvent) { when (event) { is ActivityEvent.ChangeFullscreen -> { @@ -88,6 +89,11 @@ class ActivityEventHandler( }, ) } + ActivityEvent.RequestBluetoothPermission -> { + lifecycleScope.launch { + bluetoothPermissionHelper.requestBluetoothPermissionIfNecessary() + } + } ActivityEvent.OpenSettings -> { supportFragmentManager.addFragment() } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/BluetoothPermissionHelper.kt b/app/src/main/java/org/jellyfin/mobile/utils/BluetoothPermissionHelper.kt new file mode 100644 index 000000000..9532d090b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/BluetoothPermissionHelper.kt @@ -0,0 +1,67 @@ +package org.jellyfin.mobile.utils + +import android.Manifest.permission.BLUETOOTH_CONNECT +import android.app.AlertDialog +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED +import kotlinx.coroutines.suspendCancellableCoroutine +import org.jellyfin.mobile.MainActivity +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import kotlin.coroutines.resume + +class BluetoothPermissionHelper( + private val activity: MainActivity, + private val appPreferences: AppPreferences, +) { + /** + * This is used to prevent the dialog from showing multiple times in a single session (activity creation). + * Otherwise, the package manager and permission would need to be queried on every media event. + */ + private var wasDialogShowThisSession = false + + @Suppress("ComplexCondition") + suspend fun requestBluetoothPermissionIfNecessary() { + // Check conditions by increasing complexity + if ( + !AndroidVersion.isAtLeastS || + wasDialogShowThisSession || + activity.checkSelfPermission(BLUETOOTH_CONNECT) == PERMISSION_GRANTED || + appPreferences.ignoreBluetoothPermission || + !activity.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH) + ) { + return + } + + wasDialogShowThisSession = true + + val shouldRequestPermission = suspendCancellableCoroutine { continuation -> + AlertDialog.Builder(activity) + .setTitle(R.string.bluetooth_permission_title) + .setMessage(R.string.bluetooth_permission_message) + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + continuation.resume(false) + } + .setPositiveButton(R.string.bluetooth_permission_continue) { dialog, _ -> + dialog.dismiss() + continuation.resume(true) + } + .setOnCancelListener { + continuation.resume(false) + } + .show() + } + + if (!shouldRequestPermission) { + appPreferences.ignoreBluetoothPermission = true + return + } + + activity.requestPermission(BLUETOOTH_CONNECT) { requestPermissionsResult -> + if (requestPermissionsResult[BLUETOOTH_CONNECT] == PERMISSION_GRANTED) { + activity.toast(R.string.bluetooth_permission_granted) + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt index 3b2b747ba..57158181c 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt @@ -29,6 +29,7 @@ object Constants { const val PREF_INSTANCE_URL = "pref_instance_url" const val PREF_IGNORE_BATTERY_OPTIMIZATIONS = "pref_ignore_battery_optimizations" const val PREF_IGNORE_WEBVIEW_CHECKS = "pref_ignore_webview_checks" + const val PREF_IGNORE_BLUETOOTH_PERMISSION = "pref_ignore_bluetooth_permission" const val PREF_DOWNLOAD_METHOD = "pref_download_method" const val PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE = "pref_music_notification_always_dismissible" const val PREF_VIDEO_PLAYER_TYPE = "pref_video_player_type" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6d1b4884..1591aba3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,10 @@ Available servers Use a different server + Allow Bluetooth access? + To detect when wireless headphones or speakers are disconnected, the app requires Bluetooth access. On Android, this permission is described as access to nearby devices, and warns that Bluetooth devices can be used to infer the current location. However, the Jellyfin app will never use this permission to detect your location. + Continue + Bluetooth permission was granted Please disable battery optimizations for media playback while the screen is off. Allowed Network Types Do you want to allow the download to run over mobile data or roaming networks? Charges from your provider may apply. From 2b1977353b0e4c38d4317355a2e3a85a9e56b6c3 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Mon, 4 Sep 2023 19:00:12 +0200 Subject: [PATCH 2/2] Cleanup code and fix warnings --- app/src/main/java/org/jellyfin/mobile/MainActivity.kt | 6 +++--- .../main/java/org/jellyfin/mobile/events/ActivityEvent.kt | 6 +++--- .../java/org/jellyfin/mobile/events/ActivityEventHandler.kt | 6 ++++-- .../org/jellyfin/mobile/utils/PermissionRequestHelper.kt | 2 +- app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt | 4 +--- .../java/org/jellyfin/mobile/webapp/RemotePlayerService.kt | 4 +++- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt index fa0cbd6c1..8c36e6462 100644 --- a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt +++ b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt @@ -97,9 +97,6 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - // Bind player service - bindService(Intent(this, RemotePlayerService::class.java), serviceConnection, Service.BIND_AUTO_CREATE) - // Check WebView support if (!isWebViewSupported()) { AlertDialog.Builder(this).apply { @@ -120,6 +117,9 @@ class MainActivity : AppCompatActivity() { return } + // Bind player service + bindService(Intent(this, RemotePlayerService::class.java), serviceConnection, Service.BIND_AUTO_CREATE) + // Subscribe to activity events with(activityEventHandler) { subscribe() } diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt index afec2b9af..e5a9ec5ca 100644 --- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt @@ -11,7 +11,7 @@ sealed class ActivityEvent { class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent() class CastMessage(val action: String, val args: JSONArray) : ActivityEvent() data object RequestBluetoothPermission : ActivityEvent() - object OpenSettings : ActivityEvent() - object SelectServer : ActivityEvent() - object ExitApp : ActivityEvent() + data object OpenSettings : ActivityEvent() + data object SelectServer : ActivityEvent() + data object ExitApp : ActivityEvent() } diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt index dd464fe46..349751b00 100644 --- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt @@ -42,7 +42,7 @@ class ActivityEventHandler( } @Suppress("CyclomaticComplexMethod", "LongMethod") - private suspend fun MainActivity.handleEvent(event: ActivityEvent) { + private fun MainActivity.handleEvent(event: ActivityEvent) { when (event) { is ActivityEvent.ChangeFullscreen -> { val fullscreenHelper = PlayerFullscreenHelper(window) @@ -73,7 +73,9 @@ class ActivityEventHandler( } } is ActivityEvent.DownloadFile -> { - with(event) { requestDownload(uri, title, filename) } + lifecycleScope.launch { + with(event) { requestDownload(uri, title, filename) } + } } is ActivityEvent.CastMessage -> { val action = event.action diff --git a/app/src/main/java/org/jellyfin/mobile/utils/PermissionRequestHelper.kt b/app/src/main/java/org/jellyfin/mobile/utils/PermissionRequestHelper.kt index 9fb43301a..1a5a4d67c 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/PermissionRequestHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/PermissionRequestHelper.kt @@ -45,7 +45,7 @@ fun Activity.requestPermission(vararg permissions: String, callback: PermissionR } if (skipRequest) { - callback(permissions.map { Pair(it, PackageManager.PERMISSION_GRANTED) }.toMap()) + callback(permissions.associateWith { PackageManager.PERMISSION_GRANTED }) } else { val helper = getKoin().get() val code = helper.getRequestCode() diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt index bd52463d3..f3b0c3301 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt @@ -17,7 +17,6 @@ import android.os.Environment import android.os.PowerManager import android.provider.Settings import android.provider.Settings.System.ACCELEROMETER_ROTATION -import androidx.appcompat.app.AppCompatActivity import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.getSystemService import com.google.android.material.snackbar.Snackbar @@ -37,7 +36,7 @@ import kotlin.coroutines.suspendCoroutine fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) { if (AndroidVersion.isAtLeastM) { - val powerManager: PowerManager = requireContext().getSystemService(AppCompatActivity.POWER_SERVICE) as PowerManager + val powerManager: PowerManager = requireContext().getSystemService(Activity.POWER_SERVICE) as PowerManager if ( !appPreferences.ignoreBatteryOptimizations && !powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) @@ -145,7 +144,6 @@ fun Context.createMediaNotificationChannel(notificationManager: NotificationMana } } -@Suppress("DEPRECATION") fun Context.getDownloadsPaths(): List = ArrayList().apply { for (directory in getExternalFilesDirs(null)) { // Ignore currently unavailable shared storage diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt b/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt index 895ace8f8..5f0f5dbf0 100644 --- a/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt +++ b/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt @@ -166,7 +166,9 @@ class RemotePlayerService : Service(), CoroutineScope { } private fun handleIntent(intent: Intent?) { - if (intent == null || intent.action == null) return + if (intent?.action == null) { + return + } val action = intent.action if (action == Constants.ACTION_REPORT) { notify(intent)