Autoconnection wip2

This commit is contained in:
darken
2022-01-14 09:54:27 +01:00
parent 12aee6e597
commit 6abaeaed37
22 changed files with 329 additions and 101 deletions
@@ -1,17 +0,0 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.os.ParcelUuid
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class BluetoothDevice2(
val device: BluetoothDevice
) : Parcelable {
val name: String?
get() = device.name
fun hasFeature(uuid: ParcelUuid): Boolean = device.hasFeature(uuid)
}
@@ -96,7 +96,7 @@ class BluetoothManager2 @Inject constructor(
}
}
private fun connectedDevicesForProfile(profile: Int): Flow<Set<BluetoothDevice2>> = callbackFlow {
private fun connectedDevicesForProfile(profile: Int): Flow<Set<BluetoothDevice>> = callbackFlow {
log(TAG) { "connectedDevices(profile=$profile) starting" }
trySend(getBluetoothProfile(profile).connectedDevices)
@@ -118,9 +118,7 @@ class BluetoothManager2 @Inject constructor(
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)?.let {
BluetoothDevice2(it)
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
return
@@ -150,7 +148,7 @@ class BluetoothManager2 @Inject constructor(
}
}
fun connectedDevices(): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
fun connectedDevices(): Flow<List<BluetoothDevice>> = isBluetoothEnabled
.flatMapLatest { connectedDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices ->
devices.filter { device ->
@@ -160,14 +158,15 @@ class BluetoothManager2 @Inject constructor(
}
}
fun bondedDevices(): List<BluetoothDevice2> = adapter.bondedDevices
.map { BluetoothDevice2(it) }
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = try {
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
connectMethod.invoke(getBluetoothProfile().profile, device.device)
connectMethod.invoke(getBluetoothProfile().profile, device)
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
log(TAG, WARN) { "BluetoothHeadset.connect(device) is unavailable:\n${e.asLog()}" }
@@ -1,5 +1,6 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile
import java.util.concurrent.atomic.AtomicBoolean
@@ -15,8 +16,8 @@ data class BluetoothProfile2(
return profileProxy
}
val connectedDevices: Set<BluetoothDevice2>
get() = profile.connectedDevices.map { BluetoothDevice2(it) }.toSet()
val connectedDevices: Set<BluetoothDevice>
get() = profile.connectedDevices.toSet()
val isConnected: Boolean
get() = isConnectedAtomic.get()
@@ -10,7 +10,9 @@ import androidx.annotation.MenuRes
import androidx.annotation.XmlRes
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.preference.PreferenceFragmentCompat
import androidx.viewbinding.ViewBinding
import eu.darken.androidstarter.common.preferences.Settings
import eu.darken.capod.main.ui.settings.SettingsFragment
@@ -67,4 +69,17 @@ abstract class PreferenceFragment2
}
}
}
inline fun <T> LiveData<T>.observe2(
crossinline callback: (T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -33,16 +33,6 @@ class GeneralSettings @Inject constructor(
moshi
)
val autoPause = preferences.createFlowPreference(
"core.eardetection.autopause.enabled",
false
)
val autoPlay = preferences.createFlowPreference(
"core.eardetection.autoplay.enabled",
false
)
val showAll = preferences.createFlowPreference(
"core.showall.enabled",
false
@@ -53,11 +43,14 @@ class GeneralSettings @Inject constructor(
0.25f
)
val mainDeviceAddress = preferences.createFlowPreference<String?>(
"core.maindevice.address",
null
)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode,
scannerMode,
autoPause,
autoPlay,
showAll,
minimumSignalQuality,
debugSettings.isAutoReportEnabled,
@@ -7,19 +7,15 @@ import eu.darken.capod.R
@JsonClass(generateAdapter = false)
enum class MonitorMode(
val identifier: String,
@StringRes val labelRes: Int
) {
@Json(name = "monitor.mode.manual") MANUAL(
"monitor.mode.manual",
R.string.settings_monitor_mode_manual_label
),
@Json(name = "monitor.mode.automatic") AUTOMATIC(
"monitor.mode.automatic",
R.string.settings_monitor_mode_automatic_label
),
@Json(name = "monitor.mode.always") ALWAYS(
"monitor.mode.always",
R.string.settings_monitor_mode_always_label
),
}
@@ -20,7 +20,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.*
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.reactions.core.ReactionsHub
import eu.darken.capod.reaction.core.ReactionHub
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
@@ -37,7 +37,7 @@ class MonitorWorker @AssistedInject constructor(
private val generalSettings: GeneralSettings,
private val permissionTool: PermissionTool,
private val podMonitor: PodMonitor,
private val reactionsHub: ReactionsHub,
private val reactionHub: ReactionHub,
private val bluetoothManager: BluetoothManager2,
) : CoroutineWorker(context, params) {
@@ -138,7 +138,7 @@ class MonitorWorker @AssistedInject constructor(
}
.launchIn(workerScope)
reactionsHub.monitor()
reactionHub.monitor()
.catch {
log(TAG, WARN) { "Pod reactions failed:\n${it.asLog()}" }
}
@@ -1,13 +1,16 @@
package eu.darken.capod.reactions.core
package eu.darken.capod.reaction.core
import dagger.Reusable
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
@Reusable
class ReactionsHub @Inject constructor(
class ReactionHub @Inject constructor(
private val playPause: PlayPause,
private val autoConnect: AutoConnect,
) {
@@ -18,8 +21,9 @@ class ReactionsHub @Inject constructor(
) { _, _ ->
Unit
}
.setupCommonEventHandlers(TAG) { "monitor" }
companion object {
private val TAG = logTag("Reactions", "Hub")
private val TAG = logTag("Reaction", "Hub")
}
}
@@ -0,0 +1,51 @@
package eu.darken.capod.reaction.core
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceDataStore
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.androidstarter.common.preferences.Settings
import eu.darken.capod.common.preferences.PreferenceStoreMapper
import eu.darken.capod.common.preferences.createFlowPreference
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ReactionSettings @Inject constructor(
@ApplicationContext private val context: Context,
moshi: Moshi,
) : Settings() {
override val preferences: SharedPreferences =
context.getSharedPreferences("settings_reaction", Context.MODE_PRIVATE)
val autoPause = preferences.createFlowPreference(
"reaction.autopause.enabled",
false
)
val autoPlay = preferences.createFlowPreference(
"reaction.autoplay.enabled",
false
)
val autoConnect = preferences.createFlowPreference(
"reaction.autoconnect.enabled",
false
)
val autoConnectCondition = preferences.createFlowPreference(
"reaction.autoconnect.condition",
AutoConnectCondition.WHEN_SEEN,
moshi
)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
autoPause,
autoPlay,
autoConnect,
autoConnectCondition,
)
}
@@ -0,0 +1,53 @@
package eu.darken.capod.reaction.core.autoconnect
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.PodMonitor
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AutoConnect @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val podMonitor: PodMonitor,
) {
fun monitor(): Flow<Unit> = podMonitor.mainDevice
.distinctUntilChangedBy { it?.identifier }
.onEach { mainPodDevice ->
if (mainPodDevice == null) {
log(TAG) { "MainPodDevice is null" }
return@onEach
}
val podName = "AirPod"
val bondedDevice = bluetoothManager.bondedDevices()
.firstOrNull { it.name?.contains("AirPod") ?: false }
if (bondedDevice == null) {
log(TAG) { "No bonded device matches $podName" }
return@onEach
} else {
log(TAG) { "Found target device: $bondedDevice" }
}
val connectedDevice = bluetoothManager.connectedDevices()
.firstOrNull()
?.firstOrNull { it.name?.contains("podName") ?: false }
if (connectedDevice?.address == bondedDevice.address) {
log(TAG) { "We are already connected to the target device: $connectedDevice" }
return@onEach
}
bluetoothManager.nudgeConnection(bondedDevice)
}
.map { Unit }
.setupCommonEventHandlers(TAG) { "monitor" }
companion object {
private val TAG = logTag("Reaction", "AutoConnect")
}
}
@@ -0,0 +1,25 @@
package eu.darken.capod.reaction.core.autoconnect
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
@JsonClass(generateAdapter = false)
enum class AutoConnectCondition(
val identifier: String,
@StringRes val labelRes: Int
) {
@Json(name = "autoconnect.condition.seen") WHEN_SEEN(
"monitor.mode.manual",
R.string.settings_monitor_mode_manual_label
),
@Json(name = "autoconnect.condition.case") CASE_OPEN(
"autoconnect.condition.case",
R.string.settings_monitor_mode_automatic_label
),
@Json(name = "autoconnect.condition.inear") IN_EAR(
"autoconnect.condition.inear",
R.string.settings_monitor_mode_always_label
),
}
@@ -1,4 +1,4 @@
package eu.darken.capod.reactions.core
package eu.darken.capod.reaction.core.playpause
import dagger.Reusable
import eu.darken.capod.common.MediaControl
@@ -7,9 +7,9 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
@@ -20,7 +20,7 @@ import javax.inject.Inject
class PlayPause @Inject constructor(
private val podMonitor: PodMonitor,
private val bluetoothManager: BluetoothManager2,
private val generalSettings: GeneralSettings,
private val reactionSettings: ReactionSettings,
private val mediaControl: MediaControl,
) {
@@ -34,7 +34,7 @@ class PlayPause @Inject constructor(
podMonitor.mainDevice
}
}
.setupCommonEventHandlers(TAG) { "podReactions" }
.setupCommonEventHandlers(TAG) { "monitor" }
.distinctUntilChanged()
.withPrevious()
.onEach { (previous, current) ->
@@ -42,9 +42,9 @@ class PlayPause @Inject constructor(
log(TAG) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
if (previous.identifier == current.identifier && previous.isBeingWorn != current.isBeingWorn) {
if (current.isBeingWorn && generalSettings.autoPlay.value && !mediaControl.isPlaying) {
if (current.isBeingWorn && reactionSettings.autoPlay.value && !mediaControl.isPlaying) {
mediaControl.sendPlay()
} else if (!current.isBeingWorn && generalSettings.autoPause.value && mediaControl.isPlaying) {
} else if (!current.isBeingWorn && reactionSettings.autoPause.value && mediaControl.isPlaying) {
mediaControl.sendPause()
}
}
@@ -0,0 +1,47 @@
package eu.darken.capod.reaction.ui.settings
import android.os.Bundle
import android.view.View
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import androidx.lifecycle.asLiveData
import androidx.preference.ListPreference
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.uix.PreferenceFragment2
import eu.darken.capod.reaction.core.ReactionSettings
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import javax.inject.Inject
@Keep
@AndroidEntryPoint
class ReactionSettingsFragment : PreferenceFragment2() {
private val vm: ReactionSettingsFragmentVM by viewModels()
@Inject lateinit var reactionSettings: ReactionSettings
override val settings: ReactionSettings
get() = reactionSettings
override val preferenceFile: Int = R.xml.preferences_reactions
private val autoConnectConditionPref by lazy { findPreference<ListPreference>(settings.autoConnectCondition.key)!! }
override fun onPreferencesCreated() {
autoConnectConditionPref.apply {
entries = AutoConnectCondition.values().map { getString(it.labelRes) }.toTypedArray()
entryValues = AutoConnectCondition.values().map { settings.autoConnectCondition.rawWriter(it) as String }
.toTypedArray()
}
super.onPreferencesCreated()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
settings.autoConnect.flow.asLiveData().observe2 {
autoConnectConditionPref.isEnabled = it
}
super.onViewCreated(view, savedInstanceState)
}
}
@@ -0,0 +1,20 @@
package eu.darken.capod.reaction.ui.settings
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
import javax.inject.Inject
@HiltViewModel
class ReactionSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
) : ViewModel3(dispatcherProvider) {
companion object {
private val TAG = logTag("Settings", "Reaction", "VM")
}
}
@@ -1,26 +0,0 @@
package eu.darken.capod.reactions.core
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.monitor.core.PodMonitor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AutoConnect @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val podMonitor: PodMonitor,
) {
fun monitor(): Flow<Unit> = podMonitor.mainDevice
.map {
val podDevice = bluetoothManager2.bondedDevices().firstOrNull { it.name?.contains("AirPod") ?: false }
bluetoothManager2.nudgeConnection(podDevice!!)
}
companion object {
private val TAG = logTag("Reactions", "AutoConnect")
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M7,12l-2,-2 -2,2 2,2 2,-2zM17.71,7.71L12,2h-1v7.59L6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 11,14.41L11,22h1l5.71,-5.71 -4.3,-4.29 4.3,-4.29zM13,5.83l1.88,1.88L13,9.59L13,5.83zM14.88,16.29L13,18.17v-3.76l1.88,1.88zM19,10l-2,2 2,2 2,-2 -2,-2z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M13,13v8h8v-8h-8zM3,21h8v-8L3,13v8zM3,3v8h8L11,3L3,3zM16.66,1.69L11,7.34 16.66,13l5.66,-5.66 -5.66,-5.65z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,13c-2.2,0 -4,1.8 -4,4s1.8,4 4,4s4,-1.8 4,-4S8.2,13 6,13zM12,3C9.8,3 8,4.8 8,7s1.8,4 4,4s4,-1.8 4,-4S14.2,3 12,3zM18,13c-2.2,0 -4,1.8 -4,4s1.8,4 4,4s4,-1.8 4,-4S20.2,13 18,13z" />
</vector>
+7
View File
@@ -117,4 +117,11 @@
<string name="pods_microphone_label">Microphone</string>
<string name="settings_signal_minimum_label">Minimum signal quality</string>
<string name="settings_signal_minimum_description">The minimum signal quality that a device needs to have to be considered yours.</string>
<string name="settings_autoconnect_label">Auto connect</string>
<string name="settings_autoconnect_description">Seeing the device and being connected is not the same. If Android does not automatically connect, we can ask it too.</string>
<string name="settings_autoconnect_condition_label">Auto connect condition</string>
<string name="settings_autoconnect_condition_description">When should we auto connect to your device?</string>
<string name="settings_reaction_label">Reactions</string>
<string name="settings_reaction_description">React to events and behaviors.</string>
<string name="settings_category_yourdevice_label">Your device</string>
</resources>
+15 -19
View File
@@ -14,31 +14,27 @@
android:summary="@string/settings_scanner_mode_description"
android:title="@string/settings_scanner_mode_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_play_circle_24"
android:key="core.eardetection.autoplay.enabled"
android:summary="@string/settings_autoplay_description"
android:title="@string/settings_autopplay_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_pause_circle_24"
android:key="core.eardetection.autopause.enabled"
android:summary="@string/settings_autopause_description"
android:title="@string/settings_autopause_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_devices_other_24"
android:key="core.showall.enabled"
android:summary="@string/settings_showall_description"
android:title="@string/settings_showall_label" />
<eu.darken.capod.common.preferences.PercentSliderPreference
android:icon="@drawable/ic_baseline_signal_cellular_alt_24"
android:key="core.signal.minimum"
android:summary="@string/settings_signal_minimum_description"
android:title="@string/settings_signal_minimum_label"
app:pspMax="0.9"
app:pspMin="0.1" />
<PreferenceCategory android:title="@string/settings_category_yourdevice_label">
<eu.darken.capod.common.preferences.PercentSliderPreference
android:icon="@drawable/ic_baseline_signal_cellular_alt_24"
android:key="core.signal.minimum"
android:summary="@string/settings_signal_minimum_description"
android:title="@string/settings_signal_minimum_label"
app:pspMax="0.9"
app:pspMin="0.1" />
<ListPreference
android:icon="@drawable/ic_baseline_settings_bluetooth_24"
android:key="core.scanner.mode"
android:summary="@string/settings_scanner_mode_description"
android:title="@string/settings_scanner_mode_label" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_other_label">
@@ -7,6 +7,12 @@
app:summary="@string/settings_general_description"
app:title="@string/settings_general_label" />
<Preference
android:icon="@drawable/ic_baseline_widgets_24"
app:fragment="eu.darken.capod.reaction.ui.settings.ReactionSettingsFragment"
app:summary="@string/settings_reaction_description"
app:title="@string/settings_reaction_label" />
<Preference
android:icon="@drawable/ic_baseline_support_agent_24"
app:fragment="eu.darken.capod.main.ui.settings.support.SupportFragment"
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<CheckBoxPreference
android:icon="@drawable/ic_baseline_play_circle_24"
android:key="reaction.autoplay.enabled"
android:summary="@string/settings_autoplay_description"
android:title="@string/settings_autopplay_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_pause_circle_24"
android:key="reaction.autopause.enabled"
android:summary="@string/settings_autopause_description"
android:title="@string/settings_autopause_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_bluetooth_connected_24"
android:key="reaction.autoconnect.enabled"
android:summary="@string/settings_autoconnect_description"
android:title="@string/settings_autoconnect_label" />
<ListPreference
android:icon="@drawable/ic_baseline_workspaces_24"
android:key="reaction.autoconnect.condition"
android:summary="@string/settings_autoconnect_condition_description"
android:title="@string/settings_autoconnect_condition_label" />
</PreferenceScreen>