Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix bluetooth event handling #1188

Merged
merged 2 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@

<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30"
android:required="false" />
<uses-permission
android:name="android.permission.BLUETOOTH_CONNECT"
android:required="false"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
Expand Down
8 changes: 5 additions & 3 deletions app/src/main/java/org/jellyfin/mobile/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -95,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 {
Expand All @@ -118,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() }

Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
7 changes: 4 additions & 3 deletions app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ 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()
object OpenSettings : ActivityEvent()
object SelectServer : ActivityEvent()
object ExitApp : ActivityEvent()
data object RequestBluetoothPermission : ActivityEvent()
data object OpenSettings : ActivityEvent()
data object SelectServer : ActivityEvent()
data object ExitApp : ActivityEvent()
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ class ActivityEventHandler(
}
}

private suspend fun MainActivity.handleEvent(event: ActivityEvent) {
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun MainActivity.handleEvent(event: ActivityEvent) {
when (event) {
is ActivityEvent.ChangeFullscreen -> {
val fullscreenHelper = PlayerFullscreenHelper(window)
Expand Down Expand Up @@ -72,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
Expand All @@ -88,6 +91,11 @@ class ActivityEventHandler(
},
)
}
ActivityEvent.RequestBluetoothPermission -> {
lifecycleScope.launch {
bluetoothPermissionHelper.requestBluetoothPermissionIfNecessary()
}
}
ActivityEvent.OpenSettings -> {
supportFragmentManager.addFragment<SettingsFragment>()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
1 change: 1 addition & 0 deletions app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PermissionRequestHelper>()
val code = helper.getRequestCode()
Expand Down
4 changes: 1 addition & 3 deletions app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -145,7 +144,6 @@ fun Context.createMediaNotificationChannel(notificationManager: NotificationMana
}
}

@Suppress("DEPRECATION")
fun Context.getDownloadsPaths(): List<String> = ArrayList<String>().apply {
for (directory in getExternalFilesDirs(null)) {
// Ignore currently unavailable shared storage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
<string name="available_servers_title">Available servers</string>
<string name="button_use_different_server">Use a different server</string>

<string name="bluetooth_permission_title">Allow Bluetooth access?</string>
<string name="bluetooth_permission_message">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.</string>
<string name="bluetooth_permission_continue">Continue</string>
<string name="bluetooth_permission_granted">Bluetooth permission was granted</string>
<string name="battery_optimizations_message">Please disable battery optimizations for media playback while the screen is off.</string>
<string name="network_title">Allowed Network Types</string>
<string name="network_message">Do you want to allow the download to run over mobile data or roaming networks? Charges from your provider may apply.</string>
Expand Down