Add auto connection feature, if the BLE beacon is spotted, try to establish a connection.

This commit is contained in:
darken
2022-01-14 20:16:04 +01:00
parent 632cfdd84b
commit 3b4ccd8ed1
16 changed files with 208 additions and 51 deletions
@@ -3,6 +3,7 @@ package eu.darken.capod.common
import android.media.AudioManager
import android.os.SystemClock
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay
@@ -18,18 +19,18 @@ class MediaControl @Inject constructor(
get() = audioManager.isMusicActive
suspend fun sendPlay() {
log(TAG) { "sendPlay()" }
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive) {
log(TAG) { "Music is already playing, not sending play" }
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
}
suspend fun sendPause() {
log(TAG) { "sendPause()" }
log(TAG, INFO) { "sendPause()" }
if (!audioManager.isMusicActive) {
log(TAG) { "Music is not playing, not sending pause" }
log(TAG, INFO) { "Music is not playing, not sending pause" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
@@ -162,10 +162,13 @@ class BluetoothManager2 @Inject constructor(
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)
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
@@ -19,15 +19,20 @@ open class PreferenceStoreMapper(
}
override fun getString(key: String, defValue: String?): String? {
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
val pref = flowPreferences.singleOrNull { it.key == key }
?: throw NotImplementedError("getString(key=$key, defValue=$defValue)")
return pref.let { flowPref ->
flowPref.valueRaw as String?
} ?: throw NotImplementedError("getString(key=$key, defValue=$defValue)")
}
}
override fun putString(key: String, value: String?) {
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
val pref = flowPreferences.singleOrNull { it.key == key }
?: throw NotImplementedError("putString(key=$key, defValue=$value)")
pref.let { flowPref ->
flowPref.valueRaw = value
} ?: throw NotImplementedError("putString(key=$key, defValue=$value)")
}
}
override fun getInt(key: String?, defValue: Int): Int {
@@ -53,6 +53,7 @@ class GeneralSettings @Inject constructor(
scannerMode,
showAll,
minimumSignalQuality,
mainDeviceAddress,
debugSettings.isAutoReportEnabled,
)
}
@@ -0,0 +1,37 @@
package eu.darken.capod.main.ui.settings.general
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.DialogInterface
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.darken.capod.R
class DeviceSelectionDialogFactory constructor(private val context: Context) {
fun create(
devices: List<BluetoothDevice>,
current: BluetoothDevice?,
callback: (BluetoothDevice?) -> Unit
): AlertDialog {
return MaterialAlertDialogBuilder(context).apply {
setTitle(R.string.settings_maindevice_address_label)
val pairing = devices
.map { (it.name ?: "?") to it.address }
.plus(context.getString(R.string.settings_maindevice_address_none) to "")
setSingleChoiceItems(
pairing.map { it.first }.toTypedArray(),
pairing.indexOfFirst { it.second == current?.address ?: "" },
DialogInterface.OnClickListener { dialog, which ->
val selected = devices.firstOrNull { it.address == pairing[which].second }
callback(selected)
dialog.dismiss()
}
)
}.create()
}
}
@@ -1,5 +1,7 @@
package eu.darken.capod.main.ui.settings.general
import android.os.Bundle
import android.view.View
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import androidx.preference.ListPreference
@@ -28,6 +30,7 @@ class GeneralSettingsFragment : PreferenceFragment2() {
private val monitorModePref by lazy { findPreference<ListPreference>(generalSettings.monitorMode.key)!! }
private val scanModePref by lazy { findPreference<ListPreference>(generalSettings.scannerMode.key)!! }
private val mainDeviceAddressPref by lazy { findPreference<Preference>(generalSettings.mainDeviceAddress.key)!! }
override fun onPreferencesCreated() {
monitorModePref.apply {
@@ -41,6 +44,23 @@ class GeneralSettingsFragment : PreferenceFragment2() {
super.onPreferencesCreated()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
vm.bondedDevices.observe2 { devices ->
mainDeviceAddressPref.setOnPreferenceClickListener {
val dialog = DeviceSelectionDialogFactory(requireContext()).create(
devices,
devices.firstOrNull { it.address == generalSettings.mainDeviceAddress.value }
) { selected ->
generalSettings.mainDeviceAddress.value = selected?.address
}
dialog.show()
true
}
}
super.onViewCreated(view, savedInstanceState)
}
override fun onDisplayPreferenceDialog(preference: Preference) {
if (PercentSliderPreference.onDisplayPreferenceDialog(this, preference)) return
@@ -2,17 +2,23 @@ package eu.darken.capod.main.ui.settings.general
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
@HiltViewModel
class GeneralSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
private val bluetoothManager: BluetoothManager2,
) : ViewModel3(dispatcherProvider) {
val bondedDevices = flow {
emit(bluetoothManager.bondedDevices().toList())
}.asLiveData2()
companion object {
private val TAG = logTag("Settings", "General", "VM")
@@ -60,7 +60,7 @@ class MonitorWorker @AssistedInject constructor(
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
doDoWork()
doDoWork()
val duration = System.currentTimeMillis() - start
@@ -121,8 +121,9 @@ class MonitorWorker @AssistedInject constructor(
}
MonitorMode.ALWAYS -> emptyFlow()
MonitorMode.AUTOMATIC -> flow {
if (devices.isNotEmpty()) {
log(TAG) { "Pods are connected, aborting any timeout." }
val mainAddress = generalSettings.mainDeviceAddress.value
if (devices.any { it.address == mainAddress }) {
log(TAG) { "MainDevice is connected ($mainAddress), aborting any timeout." }
} else {
log(TAG) { "No Pods are connected, canceling worker soon." }
delay(30 * 1000)
@@ -4,7 +4,11 @@ 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.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,38 +17,60 @@ import javax.inject.Singleton
class AutoConnect @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val podMonitor: PodMonitor,
private val generalSettings: GeneralSettings,
private val reactionSettings: ReactionSettings
) {
fun monitor(): Flow<Unit> = podMonitor.mainDevice
.distinctUntilChangedBy { it?.identifier }
.onEach { mainPodDevice ->
if (mainPodDevice == null) {
log(TAG) { "MainPodDevice is null" }
return@onEach
.filterNotNull()
.map { podDevice ->
log(TAG) { "mainPodDevice is $podDevice" }
val mainDeviceAddr = generalSettings.mainDeviceAddress.value
if (mainDeviceAddr.isNullOrEmpty()) {
log(TAG) { "mainDeviceAddress is null" }
return@map
} else {
log(TAG) { "mainDeviceAddress is $mainDeviceAddr" }
}
val podName = "AirPod"
val bondedDevice = bluetoothManager.bondedDevices()
.firstOrNull { it.name?.contains("AirPod") ?: false }
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
if (bondedDevice == null) {
log(TAG) { "No bonded device matches $podName" }
return@onEach
log(TAG) { "No bonded device matches $mainDeviceAddr" }
return@map
} 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
val isAlreadyConnected = bluetoothManager.connectedDevices().first().any {
it.address == bondedDevice.address
}
bluetoothManager.nudgeConnection(bondedDevice)
if (isAlreadyConnected) {
log(TAG) { "We are already connected to the target device: $bondedDevice" }
return@map
}
val condition = reactionSettings.autoConnectCondition.value
log(TAG) { "Checking condition $condition" }
val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (podDevice) {
is DualApplePods -> podDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true
}
AutoConnectCondition.IN_EAR -> when (podDevice) {
is HasEarDetection -> podDevice.isBeingWorn
else -> true
}
}
if (!conditionFulfilled) {
log(TAG) { "Auto connect condition ($condition) is not fullfilled." }
return@map
}
val result = bluetoothManager.nudgeConnection(bondedDevice)
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
}
.map { Unit }
.setupCommonEventHandlers(TAG) { "monitor" }
companion object {
@@ -12,14 +12,14 @@ enum class AutoConnectCondition(
) {
@Json(name = "autoconnect.condition.seen") WHEN_SEEN(
"monitor.mode.manual",
R.string.settings_monitor_mode_manual_label
R.string.settings_reaction_autoconnect_whenseen_label
),
@Json(name = "autoconnect.condition.case") CASE_OPEN(
"autoconnect.condition.case",
R.string.settings_monitor_mode_automatic_label
R.string.settings_reaction_autoconnect_caseopen_label
),
@Json(name = "autoconnect.condition.inear") IN_EAR(
"autoconnect.condition.inear",
R.string.settings_monitor_mode_always_label
R.string.settings_reaction_autoconnect_inear_label
),
}
@@ -3,6 +3,7 @@ package eu.darken.capod.reaction.core.playpause
import dagger.Reusable
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -34,24 +35,33 @@ class PlayPause @Inject constructor(
podMonitor.mainDevice
}
}
.setupCommonEventHandlers(TAG) { "monitor" }
.distinctUntilChanged()
.withPrevious()
.onEach { (previous, current) ->
if (previous is HasEarDetection && current is HasEarDetection) {
log(TAG) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
if (previous.identifier == current.identifier && previous.isBeingWorn != current.isBeingWorn) {
if (current.isBeingWorn && reactionSettings.autoPlay.value && !mediaControl.isPlaying) {
mediaControl.sendPlay()
} else if (!current.isBeingWorn && reactionSettings.autoPause.value && mediaControl.isPlaying) {
mediaControl.sendPause()
log(TAG) { "Ear status changed for monitored device." }
if (current.isBeingWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
mediaControl.sendPlay()
} else {
log(TAG) { "autoPlay is disabled" }
}
} else if (!current.isBeingWorn && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
}
}
}
}
.setupCommonEventHandlers(TAG) { "monitor" }
companion object {
private val TAG = logTag("Reactions", "PlayPause")
private val TAG = logTag("Reaction", "PlayPause")
}
}
@@ -5,10 +5,15 @@ import android.view.View
import androidx.annotation.Keep
import androidx.fragment.app.viewModels
import androidx.lifecycle.asLiveData
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.uix.PreferenceFragment2
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.settings.general.DeviceSelectionDialogFactory
import eu.darken.capod.reaction.core.ReactionSettings
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import javax.inject.Inject
@@ -19,6 +24,7 @@ class ReactionSettingsFragment : PreferenceFragment2() {
private val vm: ReactionSettingsFragmentVM by viewModels()
@Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var reactionSettings: ReactionSettings
override val settings: ReactionSettings
@@ -37,10 +43,30 @@ class ReactionSettingsFragment : PreferenceFragment2() {
super.onPreferencesCreated()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
if (preference.key == reactionSettings.autoConnect.key && generalSettings.mainDeviceAddress.value == null) {
preference as CheckBoxPreference
val devices = vm.bondedDevices
DeviceSelectionDialogFactory(requireContext()).create(
devices = devices,
current = devices.firstOrNull { it.address == generalSettings.mainDeviceAddress.value }
) { selected ->
generalSettings.mainDeviceAddress.value = selected?.address
if (selected != null) preference.isChecked = true
}.show()
preference.isChecked = false
return true
}
return super.onPreferenceTreeClick(preference)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
settings.autoConnect.flow.asLiveData().observe2 {
generalSettings.monitorMode.value = MonitorMode.ALWAYS
autoConnectConditionPref.isEnabled = it
}
super.onViewCreated(view, savedInstanceState)
}
@@ -2,6 +2,7 @@ package eu.darken.capod.reaction.ui.settings
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel3
@@ -11,8 +12,10 @@ import javax.inject.Inject
class ReactionSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
private val bluetoothManager: BluetoothManager2,
) : ViewModel3(dispatcherProvider) {
val bondedDevices = bluetoothManager.bondedDevices().toList()
companion object {
private val TAG = logTag("Settings", "Reaction", "VM")
@@ -0,0 +1,11 @@
<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"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M14.24,12.01l2.32,2.32c0.28,-0.72 0.44,-1.51 0.44,-2.33 0,-0.82 -0.16,-1.59 -0.43,-2.31l-2.33,2.32zM19.53,6.71l-1.26,1.26c0.63,1.21 0.98,2.57 0.98,4.02s-0.36,2.82 -0.98,4.02l1.2,1.2c0.97,-1.54 1.54,-3.36 1.54,-5.31 -0.01,-1.89 -0.55,-3.67 -1.48,-5.19zM15.71,7.71L10,2L9,2v7.59L4.41,5 3,6.41 8.59,12 3,17.59 4.41,19 9,14.41L9,22h1l5.71,-5.71 -4.3,-4.29 4.3,-4.29zM11,5.83l1.88,1.88L11,9.59L11,5.83zM12.88,16.29L11,18.17v-3.76l1.88,1.88z" />
</vector>
+11 -5
View File
@@ -61,7 +61,7 @@
<string name="pods_connection_state_hanging_up_label">Hanging up</string>
<string name="pods_unknown_raw_data_label">Raw data</string>
<string name="pods_unknown_label">Unknown device</string>
<string name="pods_unknown_contact_dev">This is an unknown device, but it is using Apple\'s message format. Let\'s add support for it, contact me :).</string>
<string name="pods_unknown_contact_dev">This is an unknown device, but it is using similar message format. Let\'s add support for it, contact me :).</string>
<string name="pods_none_label_short">No device</string>
<string name="pods_none_label">No paired device connected.</string>
<string name="pods_none_description">Connect a paired device or enable the \'Show all\' option.</string>
@@ -103,11 +103,11 @@
<string name="settings_scanner_mode_balanced_label">Balanced</string>
<string name="settings_scanner_mode_lowlatency_label">Low latency</string>
<string name="settings_autopause_label">Auto pause</string>
<string name="settings_autopause_description">Pause music when removing the device from your ear (if supported).</string>
<string name="settings_autopause_description">Pause music when removing the device from your ear.</string>
<string name="settings_showall_label">Show all devices</string>
<string name="settings_showall_description">Show other people\'s devices that are near you.</string>
<string name="settings_autopplay_label">Auto play</string>
<string name="settings_autoplay_description">Start music playback music when wearing the device (if supported).</string>
<string name="settings_autoplay_description">Start music playback music when wearing the device.</string>
<string name="settings_fake_data_label">Fake data</string>
<string name="settings_fake_data_description">Show fake data, i.e. simulate device that don\'t exist.</string>
<string name="settings_debug_label">Debug settings</string>
@@ -118,10 +118,16 @@
<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_description">If Android does not automatically connect, we can ask it too. This will set the monitor mode setting to \'Always\'.</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_autoconnect_condition_description">When should we try to 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>
<string name="settings_maindevice_address_label">Main device</string>
<string name="settings_maindevice_address_description">The paired pod device to which this app should react.</string>
<string name="settings_maindevice_address_none">None</string>
<string name="settings_reaction_autoconnect_whenseen_label">When seen</string>
<string name="settings_reaction_autoconnect_caseopen_label">Case is open</string>
<string name="settings_reaction_autoconnect_inear_label">In ear</string>
</resources>
+6 -5
View File
@@ -29,11 +29,12 @@
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" />
<Preference
android:icon="@drawable/ic_baseline_bluetooth_searching_24"
android:key="core.maindevice.address"
android:summary="@string/settings_maindevice_address_description"
android:title="@string/settings_maindevice_address_label" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_other_label">