diff --git a/app/src/main/java/eu/darken/capod/common/MediaControl.kt b/app/src/main/java/eu/darken/capod/common/MediaControl.kt index 8e2e7a19..8361e548 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -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) diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt index 94faf9ed..100155a5 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt @@ -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) { diff --git a/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt b/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt index 099bac8b..aa03d5a9 100644 --- a/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt +++ b/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt @@ -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 { diff --git a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt index 9b581764..09eef359 100644 --- a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt @@ -53,6 +53,7 @@ class GeneralSettings @Inject constructor( scannerMode, showAll, minimumSignalQuality, + mainDeviceAddress, debugSettings.isAutoReportEnabled, ) } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/DeviceSelectionDialogFactory.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/DeviceSelectionDialogFactory.kt new file mode 100644 index 00000000..b001a386 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/DeviceSelectionDialogFactory.kt @@ -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, + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt index 8a2e796d..75c2f8ad 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt @@ -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(generalSettings.monitorMode.key)!! } private val scanModePref by lazy { findPreference(generalSettings.scannerMode.key)!! } + private val mainDeviceAddressPref by lazy { findPreference(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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragmentVM.kt index 79be3f7f..8f496c1e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragmentVM.kt @@ -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") diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt index 2f01bf9d..3ab89476 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt @@ -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) diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt index 6727f246..ee4d507e 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt @@ -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 = 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 { diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt index 53b8ed2c..80ebd4c7 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt @@ -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 ), } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index 680200f2..6da420ee 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -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") } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragment.kt b/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragment.kt index cccd01cc..b7a2c8a0 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragment.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragment.kt @@ -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) } diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragmentVM.kt b/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragmentVM.kt index e18e39be..a7d2ff2f 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/settings/ReactionSettingsFragmentVM.kt @@ -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") diff --git a/app/src/main/res/drawable/ic_baseline_bluetooth_searching_24.xml b/app/src/main/res/drawable/ic_baseline_bluetooth_searching_24.xml new file mode 100644 index 00000000..72057b55 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_bluetooth_searching_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41960017..ddcd27d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,7 +61,7 @@ Hanging up Raw data Unknown device - This is an unknown device, but it is using Apple\'s message format. Let\'s add support for it, contact me :). + This is an unknown device, but it is using similar message format. Let\'s add support for it, contact me :). No device No paired device connected. Connect a paired device or enable the \'Show all\' option. @@ -103,11 +103,11 @@ Balanced Low latency Auto pause - Pause music when removing the device from your ear (if supported). + Pause music when removing the device from your ear. Show all devices Show other people\'s devices that are near you. Auto play - Start music playback music when wearing the device (if supported). + Start music playback music when wearing the device. Fake data Show fake data, i.e. simulate device that don\'t exist. Debug settings @@ -118,10 +118,16 @@ Minimum signal quality The minimum signal quality that a device needs to have to be considered yours. Auto connect - Seeing the device and being connected is not the same. If Android does not automatically connect, we can ask it too. + If Android does not automatically connect, we can ask it too. This will set the monitor mode setting to \'Always\'. Auto connect condition - When should we auto connect to your device? + When should we try to connect to your device? Reactions React to events and behaviors. Your device + Main device + The paired pod device to which this app should react. + None + When seen + Case is open + In ear \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml index 2378c6d1..ff509dea 100644 --- a/app/src/main/res/xml/preferences_general.xml +++ b/app/src/main/res/xml/preferences_general.xml @@ -29,11 +29,12 @@ android:title="@string/settings_signal_minimum_label" app:pspMax="0.9" app:pspMin="0.1" /> - + +