diff --git a/app-common/src/main/java/eu/darken/capod/common/bluetooth/BluetoothDevice2.kt b/app-common/src/main/java/eu/darken/capod/common/bluetooth/BluetoothDevice2.kt new file mode 100644 index 00000000..a4ff2fcd --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/common/bluetooth/BluetoothDevice2.kt @@ -0,0 +1,15 @@ +package eu.darken.capod.common.bluetooth + +import android.bluetooth.BluetoothDevice +import java.time.Instant + +data class BluetoothDevice2( + internal val internal: BluetoothDevice, + val seenFirstAt: Instant, +) { + val address: String + get() = internal.address + + val name: String? + get() = internal.name +} diff --git a/app-common/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt b/app-common/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt index 5da8d40c..babb8d56 100644 --- a/app-common/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt +++ b/app-common/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt @@ -1,6 +1,10 @@ package eu.darken.capod.common.bluetooth -import android.bluetooth.* +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile import android.bluetooth.le.BluetoothLeScanner import android.content.BroadcastReceiver import android.content.Context @@ -12,14 +16,24 @@ import android.os.ParcelUuid import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.debug.Bugs -import eu.darken.capod.common.debug.logging.Logging.Priority.* +import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.IOException +import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -74,7 +88,7 @@ class BluetoothManager2 @Inject constructor( } override fun onServiceDisconnected(profile: Int) { - log(TAG, WARN) { "onServiceDisconnected(profile=$profile" } + log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" } close(IOException("BluetoothProfile service disconnected (profile=$profile)")) } @@ -128,6 +142,7 @@ class BluetoothManager2 @Inject constructor( log(TAG) { "Adding $device to current devices $currentDevices" } trySend(currentDevices.plus(device)) } + BluetoothDevice.ACTION_ACL_DISCONNECTED -> { log(TAG) { "Removing $device from current devices $currentDevices" } trySend(currentDevices.minus(device)) @@ -145,23 +160,54 @@ class BluetoothManager2 @Inject constructor( } } + private val seenDevicesLock = Mutex() + private val seenDevicesCache = mutableMapOf() + fun connectedDevices( featureFilter: Set = ContinuityProtocol.BLE_FEATURE_UUIDS - ): Flow> = isBluetoothEnabled + ): Flow> = isBluetoothEnabled .flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) } .map { devices -> - devices.filter { device -> - featureFilter.any { feature -> - device.hasFeature(feature) - } + val currentAddresses = devices.map { it.address } + + seenDevicesLock.withLock { + val cleanedCache = seenDevicesCache.filterKeys { currentAddresses.contains(it) } + seenDevicesCache.clear() + seenDevicesCache.putAll(cleanedCache) } + + devices + .filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } } + .map { device -> + BluetoothDevice2( + internal = device, + seenFirstAt = seenDevicesLock.withLock { + seenDevicesCache[device.address] ?: Instant.now().also { + seenDevicesCache[device.address] = it + } + } + ) + } } - fun bondedDevices(): Flow> = flow { - emit(adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")) + fun bondedDevices(): Flow> = flow { + val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable") + val wrappedDevices = rawDevices.map { device -> + + BluetoothDevice2( + internal = device, + seenFirstAt = seenDevicesLock.withLock { + seenDevicesCache[device.address] ?: Instant.now().also { + seenDevicesCache[device.address] = it + } + } + ) + + }.toSet() + emit(wrappedDevices) } - suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile -> + suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile -> try { log(TAG) { "Nudging Android connection to $device" } @@ -169,7 +215,7 @@ class BluetoothManager2 @Inject constructor( "connect", BluetoothDevice::class.java ).apply { isAccessible = true } - connectMethod.invoke(bluetoothProfile.proxy, device) + connectMethod.invoke(bluetoothProfile.proxy, device.internal) log(TAG) { "Nudged connection to $device" } true diff --git a/app-common/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt b/app-common/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt index cc758076..11f75eb2 100644 --- a/app-common/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt +++ b/app-common/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt @@ -47,6 +47,11 @@ class ReactionSettings @Inject constructor( false ) + val showPopUpOnConnection = preferences.createFlowPreference( + "reaction.popup.connected", + false + ) + val onePodMode = preferences.createFlowPreference( "reaction.onepod.enabled", false @@ -58,6 +63,7 @@ class ReactionSettings @Inject constructor( autoConnect, autoConnectCondition, showPopUpOnCaseOpen, + showPopUpOnConnection, onePodMode, ) } \ No newline at end of file diff --git a/app-common/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt b/app-common/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt index 015470f5..7b6c6c94 100644 --- a/app-common/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt +++ b/app-common/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt @@ -1,15 +1,26 @@ package eu.darken.capod.reaction.core.popup -import eu.darken.capod.common.debug.logging.Logging.Priority.* +import eu.darken.capod.common.bluetooth.BluetoothManager2 +import eu.darken.capod.common.debug.logging.Logging.Priority.INFO +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN 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.PodDevice import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.reaction.core.ReactionSettings -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge import java.time.Duration import java.time.Instant import javax.inject.Inject @@ -19,11 +30,13 @@ import javax.inject.Singleton class PopUpReaction @Inject constructor( private val podMonitor: PodMonitor, private val reactionSettings: ReactionSettings, + private val generalSettings: GeneralSettings, + private val bluetoothManager: BluetoothManager2, ) { - private val coolDowns = mutableMapOf() + private val caseCoolDowns = mutableMapOf() - fun monitor(): Flow = reactionSettings.showPopUpOnCaseOpen.flow + private fun monitorCase(): Flow = reactionSettings.showPopUpOnCaseOpen.flow .flatMapLatest { isEnabled -> if (isEnabled) { podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex } @@ -32,7 +45,7 @@ class PopUpReaction @Inject constructor( } } .withPrevious() - .setupCommonEventHandlers(TAG) { "monitor" } + .setupCommonEventHandlers(TAG) { "popUpCase" } .mapNotNull { (previous, current) -> if (previous !is DualApplePods? || current !is DualApplePods) { return@mapNotNull null @@ -54,35 +67,37 @@ class PopUpReaction @Inject constructor( } log(TAG) { "Case lid status changed for monitored device." } - tryPopWindow(current) + throttleCasePopUps(current) } - private suspend fun tryPopWindow(current: DualApplePods): Event? = when { + private fun throttleCasePopUps(current: DualApplePods): Event? = when { current.caseLidState == DualApplePods.LidState.OPEN -> { log(TAG, INFO) { "Show popup" } val now = Instant.now() - val lastShown = coolDowns[current.identifier] ?: Instant.MIN + val lastShown = caseCoolDowns[current.identifier] ?: Instant.MIN val sinceLastPop = Duration.between(lastShown, now) - log(TAG) { "Time since last popup: $sinceLastPop" } + log(TAG) { "Time since last case popup: $sinceLastPop" } if (sinceLastPop >= Duration.ofSeconds(10)) { - coolDowns[current.identifier] = Instant.now() + caseCoolDowns[current.identifier] = Instant.now() Event.PopupShow(device = current) } else { - log(TAG, INFO) { "Popup is still on cooldown: $sinceLastPop" } + log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" } null } } + current.caseLidState != DualApplePods.LidState.OPEN -> { when (current.caseLidState) { DualApplePods.LidState.CLOSED -> { log(TAG, INFO) { "Lid was actively closed, resetting cooldown." } - coolDowns.remove(current.identifier) + caseCoolDowns.remove(current.identifier) } + else -> { log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." } - coolDowns[current.identifier] = Instant.now() + caseCoolDowns[current.identifier] = Instant.now() } } @@ -90,9 +105,78 @@ class PopUpReaction @Inject constructor( Event.PopupHide() } + else -> null } + private val connectionCoolDowns = mutableMapOf() + + private fun monitorConnection(): Flow = reactionSettings.showPopUpOnConnection.flow + .flatMapLatest { isEnabled -> + if (!isEnabled) return@flatMapLatest emptyFlow() + + combine( + generalSettings.mainDeviceAddress.flow, + bluetoothManager.connectedDevices().distinctUntilChanged(), + podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }, + ) { targetAddress, devices, broadcast -> + log(TAG) { "$targetAddress $broadcast $devices " } + val direct = devices.singleOrNull { it.address == targetAddress }.also { + log(TAG, VERBOSE) { "Connected main device is $it" } + } + if (direct == null) { + connectionCoolDowns.remove(targetAddress).also { + if (it != null) log(TAG) { "Cleared connection cooldown for $targetAddress due to disconect" } + } + } + if (direct != null && broadcast != null) direct to broadcast else null + } + } + .withPrevious() + .mapNotNull { (previouss, currents) -> + val previousConnected = previouss?.first + log(TAG, VERBOSE) { "previousConnected: $previousConnected" } + val previousBroadcasted = previouss?.second + log(TAG, VERBOSE) { "previousBroadcasted: $previousBroadcasted" } + val currentConnected = currents?.first + log(TAG, VERBOSE) { "currentConnected: $currentConnected" } + val currentBroadcasted = currents?.second + log(TAG, VERBOSE) { "currentBroadcasted: $currentBroadcasted" } + + if (previousConnected != null && previousBroadcasted != null && currentConnected == null) { + return@mapNotNull Event.PopupHide() + } + + if (currentConnected == null || currentBroadcasted == null) { + // We need an active connection + return@mapNotNull null + } + + val ageOfBroadcastedDevice = Duration.between(Instant.now(), currentBroadcasted.seenFirstAt) + val ageOfConnectedDevice = Duration.between(Instant.now(), currentConnected.seenFirstAt) + if (ageOfBroadcastedDevice > (ageOfConnectedDevice + Duration.ofSeconds(30))) { + // This is likely a false positive, some random nearby device + // We expect the first broadcasts to not be much older than the first connection + log(TAG, VERBOSE) { "Current broadcasted main device is probably a false-positive" } + return@mapNotNull null + } + + val now = Instant.now() + val lastShown = connectionCoolDowns[currentConnected.address] + val sinceLastPop = lastShown?.let { Duration.between(it, now) } + log(TAG) { "Time since last connection popup: ${sinceLastPop?.seconds}s" } + + if (lastShown == null) { + connectionCoolDowns[currentConnected.address] = Instant.now() + Event.PopupShow(device = currentBroadcasted) + } else { + log(TAG) { "Connection popup is still on cooldown: $sinceLastPop" } + null + } + } + .setupCommonEventHandlers(TAG) { "popUpConnection" } + + fun monitor(): Flow = merge(monitorCase(), monitorConnection()) sealed class Event { data class PopupShow( 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 index b001a386..1230bc91 100644 --- 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 @@ -1,19 +1,19 @@ 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 +import eu.darken.capod.common.bluetooth.BluetoothDevice2 class DeviceSelectionDialogFactory constructor(private val context: Context) { fun create( - devices: List, - current: BluetoothDevice?, - callback: (BluetoothDevice?) -> Unit + devices: List, + current: BluetoothDevice2?, + callback: (BluetoothDevice2?) -> Unit ): AlertDialog { return MaterialAlertDialogBuilder(context).apply { setTitle(R.string.settings_maindevice_address_label) 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 907a17e3..a842d0bd 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 @@ -30,7 +30,6 @@ class GeneralSettingsFragmentVM @Inject constructor( val events = SingleLiveEvent() init { - generalSettings.monitorMode.flow .withPrevious() .filter { (old, new) -> 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 91988edc..1b8be6da 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 @@ -1,7 +1,6 @@ package eu.darken.capod.monitor.core.worker import android.app.NotificationManager -import android.bluetooth.BluetoothDevice import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker @@ -9,6 +8,7 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.debug.Bugs @@ -30,8 +30,20 @@ import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.playpause.PlayPause import eu.darken.capod.reaction.core.popup.PopUpReaction import eu.darken.capod.reaction.ui.popup.PopUpWindow -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext @HiltWorker @@ -130,7 +142,7 @@ class MonitorWorker @AssistedInject constructor( .flatMapLatest { arguments -> val monitorMode = arguments[0] as MonitorMode val mainAddress = arguments[1] as String? - val devices = arguments[2] as Collection + val devices = arguments[2] as Collection log(TAG) { "Monitor mode: $monitorMode" } when (monitorMode) { @@ -145,7 +157,7 @@ class MonitorWorker @AssistedInject constructor( log(TAG, WARN) { "Main device address not set, staying alive while any is connected" } } devices.any { it.address == mainAddress } -> { - log(TAG) { "MainDevice is connected ($mainAddress), aborting any timeout." } + log(TAG) { "Main device is connected ($mainAddress), aborting any timeout." } } else -> { log(TAG) { "No known Pods are connected, canceling worker soon." } diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragment.kt b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragment.kt index 77ab7cbf..bf888131 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragment.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsFragment.kt @@ -1,6 +1,5 @@ package eu.darken.capod.reaction.ui -import android.bluetooth.BluetoothDevice import android.os.Bundle import android.view.View import androidx.annotation.Keep @@ -11,6 +10,7 @@ import androidx.preference.ListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import eu.darken.capod.R +import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.uix.PreferenceFragment3 import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.main.core.GeneralSettings @@ -36,7 +36,7 @@ class ReactionSettingsFragment : PreferenceFragment3() { override val preferenceFile: Int = R.xml.preferences_reactions private var isPro: Boolean = false - private var bondedDevices: List = emptyList() + private var bondedDevices: List = emptyList() private val autoConnectConditionPref by lazy { findPreference(settings.autoConnectCondition.key)!! } override fun onPreferencesCreated() { @@ -83,6 +83,11 @@ class ReactionSettingsFragment : PreferenceFragment3() { upgradeRepo.launchBillingFlow(requireActivity()) preference.isChecked = false return true + } else if (preference.key == reactionSettings.showPopUpOnConnection.key && !isPro) { + preference as CheckBoxPreference + upgradeRepo.launchBillingFlow(requireActivity()) + preference.isChecked = false + return true } return super.onPreferenceTreeClick(preference) diff --git a/app/src/main/res/drawable/ic_message_24.xml b/app/src/main/res/drawable/ic_message_24.xml new file mode 100644 index 00000000..1d35c302 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_message_outline_24.xml b/app/src/main/res/drawable/ic_message_outline_24.xml new file mode 100644 index 00000000..5bbebf32 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_outline_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2dc7b490..63fc1187 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,10 +46,12 @@ None Your device model The model of your main device. This helps the app recognize your device when it is not connected to your phone. - Show popup - Show a popup when the device case is opened (experimental). One pod mode Wearing both pods is not required, wearing a single pod is sufficient to trigger reactions. + Show case popup + Show a popup when the device case is opened (experimental). + Show connection popup + Show a popup when the device connects for the first time. Device status diff --git a/app/src/main/res/xml/preferences_reactions.xml b/app/src/main/res/xml/preferences_reactions.xml index 171e0774..1ea79db9 100644 --- a/app/src/main/res/xml/preferences_reactions.xml +++ b/app/src/main/res/xml/preferences_reactions.xml @@ -33,9 +33,17 @@ android:title="@string/settings_autoconnect_condition_label" /> + + + + \ No newline at end of file