Compare commits

..
17 Commits
Author SHA1 Message Date
darken b4d9316665 Add translation scalfolding. 2022-01-29 20:26:15 +01:00
darken a234ac0dd2 Add crowdin scripts 2022-01-29 20:23:06 +01:00
darken 3fc18bc40b Improve sorting and heuristics. 2022-01-29 20:00:04 +01:00
darken 0f924ecb82 Track first time a device is seen and display it. 2022-01-29 18:00:13 +01:00
darken 6bd350ce42 Refactor fake data into extra class 2022-01-29 16:41:50 +01:00
darken e1b4ab14e4 Version bump 2022-01-27 23:01:01 +01:00
darken adef0b39e9 Version bump (1.3.0) 2022-01-27 22:49:51 +01:00
darken af12a0e5cb Improved signal quality heuristic and value caching. 2022-01-27 22:45:47 +01:00
darken 90fc2fac24 Improve sorting 2022-01-27 00:46:06 +01:00
darken 3be7d8b43f Improve logging 2022-01-26 18:29:02 +01:00
darken a35d8c57b4 Improve device sorting and main device determination 2022-01-26 18:17:52 +01:00
darken f792431710 Fluff 2022-01-26 18:17:35 +01:00
darken 7ff20acfc0 When bluetooth is disabled, stop emissions. 2022-01-26 14:50:48 +01:00
darken 8e20e0e108 Don't report cancellation exceptions. 2022-01-26 14:33:56 +01:00
darken a6fda04640 Fix handling of Bluetooth enable/disable and restarting the monitor. 2022-01-26 13:11:01 +01:00
darken 0bbcfb5e88 Code fluff 2022-01-26 13:09:15 +01:00
darken e79ba6bfe5 Version bump (1.2.1) 2022-01-26 12:53:13 +01:00
347 changed files with 2605 additions and 441 deletions
@@ -109,9 +109,6 @@ class BillingClientConnectionProvider @Inject constructor(
delay(3000 * attempt)
true
}
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
}
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
@@ -28,6 +28,7 @@ class BillingDataRepo @Inject constructor(
) {
private val connectionProvider = billingClientConnectionProvider.connection
.catch { log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } }
.replayingShare(scope)
val billingData: Flow<BillingData> = connectionProvider
@@ -6,8 +6,6 @@ import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -27,7 +25,7 @@ import javax.inject.Singleton
class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
private val fakeBleData: FakeBleData,
) {
fun scan(
@@ -72,8 +70,8 @@ class BleScanner @Inject constructor(
)
if (adapter.isOffloadedScanBatchingSupported) {
when (scannerMode) {
ScannerMode.LOW_POWER -> setReportDelay(5000)
ScannerMode.BALANCED -> setReportDelay(2500)
ScannerMode.LOW_POWER -> setReportDelay(2000)
ScannerMode.BALANCED -> setReportDelay(1000)
ScannerMode.LOW_LATENCY -> setReportDelay(500)
}
} else {
@@ -87,7 +85,7 @@ class BleScanner @Inject constructor(
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2500)
ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
@@ -108,58 +106,7 @@ class BleScanner @Inject constructor(
log(TAG, INFO) { "BleScanner stopped" }
}
}
.map { origs ->
val fakeDevices = mutableListOf<BleScanResult>()
if (debugSettings.showFakeData.value) {
// AirPods Pro
BleScanResult(
address = "78:73:AF:B4:85:5E",
rssi = -48,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0E 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run { fakeDevices.add(this) }
// AirPods Gen1
BleScanResult(
address = "4E:9E:D1:49:D2:6D",
rssi = -55,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
).run { fakeDevices.add(this) }
// AirPods Max
BleScanResult(
address = "7E:E5:C7:65:D2:B5",
rssi = -30,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
).run { fakeDevices.add(this) }
// BeatsFlex
BleScanResult(
address = "5E:9E:D1:49:D2:6D",
rssi = -59,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run { fakeDevices.add(this) }
// Unknown Device
BleScanResult(
address = "6E:9E:D1:49:D2:6D",
rssi = -60,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run { fakeDevices.add(this) }
}
origs + fakeDevices
}
fun String.hexToByteArray(): ByteArray {
val trimmed = this
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
.map { fakeBleData.maybeAddfakeData(it) }
companion object {
private val TAG = logTag("Bluetooth", "BleScanner")
@@ -0,0 +1,103 @@
package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import javax.inject.Inject
import kotlin.random.Random
@Reusable
class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
) {
fun maybeAddfakeData(originals: List<BleScanResult>): List<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals
return originals + getFakeData()
}
fun getFakeData(): Collection<BleScanResult> {
val fakeDevices = mutableListOf<BleScanResult>()
// AirPods Gen1
BleScanResult(
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Gen2
BleScanResult(
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Gen3
BleScanResult(
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Max
BleScanResult(
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// BeatsFlex
BleScanResult(
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// Unknown Device
BleScanResult(
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
return fakeDevices
}
private fun String.hexToByteArray(): ByteArray {
val trimmed = this
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}
@@ -34,7 +34,7 @@ fun <T : Any> Flow<T>.shareLatest(
)
.filterNotNull()
fun <T : Any> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
scope = scope,
replay = 1,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
@@ -70,7 +70,9 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
}
}
vm.listItems.observe2(ui) { adapter.update(it) }
vm.listItems.observe2(ui) {
adapter.update(it)
}
vm.workerAutolaunch.observe2 {
// While UI is active, subscribe to the autolaunch routine
@@ -4,7 +4,6 @@ import android.app.Activity
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings
@@ -46,7 +45,6 @@ class OverviewFragmentVM @Inject constructor(
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val webpageTool: WebpageTool,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
val upgradeState = upgradeRepo.upgradeInfo.asLiveData2()
@@ -55,7 +53,7 @@ class OverviewFragmentVM @Inject constructor(
private val updateTicker = channelFlow<Unit> {
while (isActive) {
trySend(Unit)
delay(1000)
delay(3000)
}
}
@@ -106,11 +104,10 @@ class OverviewFragmentVM @Inject constructor(
debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow,
bluetoothManager.isBluetoothEnabled,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled ->
podMonitor.mainDevice,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled, mainPod ->
val items = mutableListOf<OverviewAdapter.Item>()
val mainPod = podMonitor.mainDevice.first()
permissions
.map { perm ->
PermissionCardVH.Item(
@@ -9,7 +9,6 @@ import eu.darken.capod.databinding.OverviewPodsAppleSingleBasicItemBinding
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
@@ -34,10 +33,7 @@ class BasicSingleApplePodsCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context)
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
reception.text = item.getReceptionText()
batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
@@ -10,6 +11,7 @@ import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import java.time.Duration
import java.time.Instant
class DualApplePodsCardVH(parent: ViewGroup) :
@@ -36,12 +38,11 @@ class DualApplePodsCardVH(parent: ViewGroup) :
}
deviceIcon.setImageResource(device.iconRes)
lastSeen.text = device.lastSeenFormatted(item.now)
lastSeen.text = context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
reception.text = device.getSignalQuality(context)
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
reception.text = item.getReceptionText()
// Left Pod
device.apply {
@@ -3,11 +3,13 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding
import eu.darken.capod.R
import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.main.ui.overview.OverviewAdapter
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.getSignalQuality
import java.time.Instant
abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
@@ -15,6 +17,10 @@ abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
parent: ViewGroup
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B> {
fun Item.getReceptionText(): String = device.getSignalQuality(context)
.let { if (showDebug) "$it ${device.seenCounter}" else it }
.let { if (isMainPod) "$it\n(${getString(R.string.pods_yours)})" else it }
interface Item : OverviewAdapter.Item {
val now: Instant
@@ -10,7 +10,6 @@ import eu.darken.capod.databinding.OverviewPodsAppleSingleItemBinding
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
@@ -35,10 +34,7 @@ class SingleApplePodsCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context)
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
reception.text = item.getReceptionText()
batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
@@ -6,7 +6,6 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
@@ -30,10 +29,7 @@ class UnknownPodDeviceCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context)
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
reception.text = item.getReceptionText()
rawdata.text = device.rawDataHex
}
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope
@@ -9,9 +10,11 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.ScannerMode
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -32,38 +35,47 @@ class PodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex()
private suspend fun List<BleScanResult>.preFilterAndMap(
scannerMode: ScannerMode
): List<PodDevice> = this
.groupBy { it.address }
.values
.map { sameAdrDevs ->
// For each address we only want the newest result, upstream may batch data
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest
}
.mapNotNull { podFactory.createPod(it) }
val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled
.flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) {
log(TAG) { "Bluetooth is enabled" }
generalSettings.scannerMode.flow
.flatMapLatest { mode ->
bleScanner.scan(scannerMode = mode).map { it.preFilterAndMap(mode) }
}
} else {
log(TAG, WARN) { "Bluetooth is currently disabled" }
emptyFlow()
flowOf(null)
}
}
.flatMapLatest { bleScanner.scan(scannerMode = it) }
.map { result ->
// For each address we only want the newest result, upstream may batch data
result.groupBy { it.address }
.values
.map { sameAdrDevs ->
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest
}
}
.map { scanResults ->
val newPods = scanResults
.mapNotNull { podFactory.createPod(it) }
.map { newPods ->
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
return@map emptyList()
}
val now = Instant.now()
deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.lastSeenAt, now) > Duration.ofSeconds(20)) {
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
deviceCache.remove(key)
}
@@ -77,41 +89,56 @@ class PodMonitor @Inject constructor(
}
}
val now = Instant.now()
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
val presorted = pods.values.sortPodsToInterest()
val main = presorted.determineMainDevice()
val aboveTresholdSorted = pods.values
.filter { it.signalQuality > minimumSignalQuality }
.sortedWith(
compareBy<PodDevice> { Duration.between(it.lastSeenAt, now) }.thenByDescending { it.rssi }
)
val belowThresholdSorted = pods.values
.filter { it.signalQuality <= minimumSignalQuality }
.sortedWith(
compareBy<PodDevice> { Duration.between(it.lastSeenAt, now) }.thenByDescending { it.rssi }
)
val main = pods.values.determineMainDevice()
(aboveTresholdSorted + belowThresholdSorted).sortedByDescending { it == main }
presorted.sortedByDescending { it == main }
}
.onStart { emit(emptyList()) }
.catch {
log(TAG, ERROR) { "PodMonitor failed:\n${it.asLog()}" }
.retryWhen { cause, attempt ->
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
}
.replayingShare(appScope)
val mainDevice: Flow<PodDevice?>
get() = devices.map { it.determineMainDevice() }
get() = devices
.map { it.determineMainDevice() }
.replayingShare(appScope)
private fun Collection<PodDevice>.determineMainDevice(): PodDevice? =
maxByOrNull { it.rssi }?.let filter@{ device ->
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
private fun Collection<PodDevice>.sortPodsToInterest(): List<PodDevice> = this.let { devices ->
val now = Instant.now()
generalSettings.mainDeviceModel.value.let {
if (device.model != it && it != PodDevice.Model.UNKNOWN) return@filter null
return@let devices.sortedWith(
compareByDescending<PodDevice> { true }
.thenBy {
val age = Duration.between(it.seenLastAt, now).seconds
if (age < 5) 0L else (age / 3L).toLong()
}
.thenByDescending { it.signalQuality }
.thenByDescending { (it.seenCounter / 10) }
)
}
private fun List<PodDevice>.determineMainDevice(): PodDevice? = this
.sortPodsToInterest()
.let { devices ->
val mainDeviceModel = generalSettings.mainDeviceModel.value
val presorted = devices.sortedByDescending {
it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN
}
if (device.signalQuality > minimumSignalQuality) device else null
return@let presorted.firstOrNull()?.let { candidate ->
when {
candidate.model == PodDevice.Model.UNKNOWN -> null
mainDeviceModel != PodDevice.Model.UNKNOWN && candidate.model != mainDeviceModel -> null
candidate.signalQuality <= generalSettings.minimumSignalQuality.value -> null
else -> candidate
}
}
}
companion object {
@@ -21,6 +21,7 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.*
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.reaction.ReactionHub
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
@@ -68,9 +69,13 @@ class MonitorWorker @AssistedInject constructor(
Result.success(inputData)
} catch (e: Throwable) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
if (e !is CancellationException) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
} else {
Result.success()
}
} finally {
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
}
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import java.time.Instant
import java.util.*
import kotlin.math.abs
import kotlin.math.max
interface PodDevice {
@@ -16,21 +17,28 @@ interface PodDevice {
val model: Model
val lastSeenAt: Instant
val address: String
get() = scanResult.address
val seenLastAt: Instant
val seenFirstAt: Instant
val seenCounter: Int
val scanResult: BleScanResult
val rssiHistory: List<Int>
val rssi: Int
get() = rssiHistory.sum() / rssiHistory.size
get() = scanResult.rssi
val confidence: Float
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val signalQuality: Float
get() = (100 - abs(rssi)) / 100f
get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
val rawData: ByteArray
@@ -93,4 +101,8 @@ interface PodDevice {
"Unknown"
);
}
companion object {
const val BASE_CONFIDENCE = 0.5f
}
}
@@ -45,10 +45,19 @@ fun getBatteryDrawable(percent: Float?): Int = when {
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
fun PodDevice.lastSeenFormatted(now: Instant): String {
val duration = Duration.between(lastSeenAt, now)
val duration = Duration.between(seenLastAt, now)
return lastSeenFormatter.format(
duration.seconds.toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.SECONDS
)
}
fun PodDevice.firstSeenFormatted(now: Instant): String {
val duration = Duration.between(seenFirstAt, now)
return lastSeenFormatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
}
@@ -21,7 +21,7 @@ import javax.inject.Singleton
class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val podFactories: @JvmSuppressWildcards Set<ApplePods.Factory>,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
) {
@@ -12,17 +12,17 @@ import eu.darken.capod.pods.core.apple.beats.*
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
}
@@ -1,17 +1,11 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.toHex
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
interface ApplePods : PodDevice {
@@ -55,103 +49,4 @@ interface ApplePods : PodDevice {
get() = proximityMessage.data[8]
abstract class Factory(private val tag: String) {
internal data class ValueCache(
val caseBatteryPercentage: Float?
)
internal val cachedValues = mutableMapOf<PodDevice.Id, ValueCache>()
internal data class KnownDevice(
val identifier: PodDevice.Id,
val scanResult: BleScanResult,
val message: ProximityPairing.Message,
val rssiHistory: List<Int> = emptyList()
) {
val address: String
get() = scanResult.address
val rssi: Int
get() = scanResult.rssi
val timestampNanos: Duration
get() = Duration.ofNanos(scanResult.generatedAtNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
}
private val knownDevs = mutableMapOf<PodDevice.Id, KnownDevice>()
internal fun recognizeDevice(scanResult: BleScanResult, message: ProximityPairing.Message): KnownDevice {
val address = scanResult.address
var recognizedDevice: KnownDevice? = knownDevs.values
.firstOrNull { it.address == address }
?.also { log(tag, VERBOSE) { "recognizeDevice: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
val currentMarkers = message.getRecogMarkers()
recognizedDevice = knownDevs.values
.firstOrNull { it.message.getRecogMarkers() == currentMarkers }
?.also {
log(tag, DEBUG) { "recognizeDevice: Close match based similarity markers." }
log(tag, DEBUG) { "recognizeDevice: Old marker: ${it.message.getRecogMarkers()}" }
log(tag, DEBUG) { "recognizeDevice: New marker: $currentMarkers" }
}
}
recognizedDevice = if (recognizedDevice == null) {
log(tag, VERBOSE) { "recognizeDevice: Mapping as new device" }
KnownDevice(
identifier = PodDevice.Id(),
scanResult = scanResult,
message = message,
rssiHistory = listOf(scanResult.rssi)
)
} else {
recognizedDevice.copy(
scanResult = scanResult,
message = message,
rssiHistory = recognizedDevice.rssiHistory
.toMutableList()
.let {
if (it.size > 2) it.removeAt(0)
it.plus(scanResult.rssi)
}
)
}
knownDevs[recognizedDevice.identifier] = recognizedDevice
knownDevs.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
log(tag, VERBOSE) { "recognizeDevice: Removing stale known device: $knownDevice" }
knownDevs.remove(knownDevice.identifier)
}
}
return recognizedDevice
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods
}
}
@@ -0,0 +1,147 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
import java.time.Instant
abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
data class Markings(
val vendor: UByte,
val length: UByte,
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = ProximityPairing.PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
caseBatteryData = data[5].lowerNibble,
deviceColor = data[7]
)
data class KnownDevice(
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>
) {
val lastMessage: ProximityPairing.Message
get() = history.last().proximityMessage
val lastAddress: String
get() = history.last().address
val confidence: Float
get() = 0.75f + (history.size / (MAX_HISTORY * 4f))
fun averageRssi(latest: Int): Int =
history.map { it.rssi }.plus(latest).takeLast(10).median()
fun isOlderThan(age: Duration): Boolean {
val now = Instant.now()
return Duration.between(history.last().seenLastAt, now) > age
}
private fun List<Int>.median(): Int = this.sorted().let {
if (it.size % 2 == 0)
(it[it.size / 2] + it[(it.size - 1) / 2]) / 2
else
it[it.size / 2]
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 10
}
}
internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val message = current.proximityMessage
knownDevices.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
log(tag, VERBOSE) { "searchHistory1: Removing stale known device: $knownDevice" }
knownDevices.remove(knownDevice.id)
}
}
knownDevices.values
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach {
knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY))
}
var recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(tag, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
val currentMarkers = message.getApplePodsMarkings()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastMessage.getApplePodsMarkings() == currentMarkers }
?.also { log(tag) { "searchHistory1: Close match based on similarity: $currentMarkers" } }
}
if (recognizedDevice == null) {
log(tag) { "searchHistory1: Didn't recognize: $message" }
}
return recognizedDevice
}
fun updateHistory(device: PodType) {
val existing = knownDevices[device.identifier]
knownDevices[device.identifier] = when {
existing != null -> {
existing.copy(
seenCounter = existing.seenCounter + 1,
history = existing.history.plus(device)
)
}
else -> {
log(tag) { "searchHistory1: Creating new history for $device" }
KnownDevice(
id = device.identifier,
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = listOf(device)
)
}
}
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods
}
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple
abstract class BasicSingleApplePodsFactory(private val tag: String) : ApplePodsFactory<BasicSingleApplePods>(tag)
@@ -182,4 +182,5 @@ interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase {
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
}
@@ -0,0 +1,84 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = deviceColor,
model = model
)
/**
* Split pods, one in case, one not.
*/
data class SplitPodsMarkings(
val leftPodBattery: Float?,
val rightPodBattery: Float?,
val microPhoneLeft: Boolean,
val microPhoneRight: Boolean,
val chargingLeft: Boolean,
val chargingRight: Boolean,
val color: DualApplePods.DeviceColor,
val model: PodDevice.Model,
)
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings()
return filter { known ->
known.history
.filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target }
}
}
fun KnownDevice.getLatestCaseBattery(): Float? = history
.filterIsInstance<DualApplePods>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>()
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState
}
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
log(tag, DEBUG) { "searchHistory2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
return when (caseIgnored.size) {
0 -> basicResult
1 -> caseIgnored.single()
else -> {
log(tag) { "searchHistory2: More than one result when ignoring case markers." }
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
caseIgnored.minus(oldest).forEach {
log(tag) { "searchHistory2: Removing outlier: $it" }
knownDevices.remove(it.id)
}
oldest
}
}
}
}
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
@@ -11,30 +11,43 @@ import javax.inject.Inject
data class UnknownAppleDevice(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>
override val confidence: Float = 0f,
private val rssiAverage: Int? = null,
) : ApplePods {
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
override fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return UnknownAppleDevice(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
@@ -5,17 +5,22 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen1 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
@@ -23,27 +28,35 @@ data class AirPodsGen1 constructor(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen1(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,17 +5,22 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
@@ -23,27 +28,35 @@ data class AirPodsGen2 constructor(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen2(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,44 +5,57 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen3 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,33 +5,47 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsMax(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = AirPodsMax(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return AirPodsMax(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,17 +5,23 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@@ -23,33 +29,40 @@ data class AirPodsPro(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsPro(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
}
}
}
@@ -5,33 +5,47 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsFlex(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = BeatsFlex(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsFlex(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,33 +5,46 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsSolo3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsSolo3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,33 +5,44 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsStudio3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsStudio3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,33 +5,47 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsX(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_X
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = BeatsX(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsX(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,32 +5,46 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class PowerBeats3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = PowerBeats3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return PowerBeats3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
@@ -5,17 +5,22 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class PowerBeatsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
@@ -23,27 +28,35 @@ data class PowerBeatsPro(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = PowerBeatsPro(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -3,8 +3,6 @@ package eu.darken.capod.pods.core.apple.protocol
import android.bluetooth.le.ScanFilter
import dagger.Reusable
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import javax.inject.Inject
@@ -15,24 +13,6 @@ object ProximityPairing {
val length: Int,
val data: UByteArray
) {
data class Markers(
val vendor: UByte,
val length: UByte,
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
fun getRecogMarkers(): Markers = Markers(
vendor = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
caseBatteryData = data[5].lowerNibble,
deviceColor = data[7]
)
override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
@@ -81,6 +61,6 @@ object ProximityPairing {
}
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
private val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
private const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
internal const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
}
@@ -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="M12,5c-3.87,0 -7,3.13 -7,7h2c0,-2.76 2.24,-5 5,-5s5,2.24 5,5h2c0,-3.87 -3.13,-7 -7,-7zM13,14.29c0.88,-0.39 1.5,-1.26 1.5,-2.29 0,-1.38 -1.12,-2.5 -2.5,-2.5S9.5,10.62 9.5,12c0,1.02 0.62,1.9 1.5,2.29v3.3L7.59,21 9,22.41l3,-3 3,3L16.41,21 13,17.59v-3.3zM12,1C5.93,1 1,5.93 1,12h2c0,-4.97 4.03,-9 9,-9s9,4.03 9,9h2c0,-6.07 -4.93,-11 -11,-11z" />
</vector>
@@ -45,12 +45,24 @@
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/first_seen"
app:layout_constraintEnd_toEndOf="@id/name"
app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintTop_toBottomOf="@id/name"
app:layout_goneMarginBottom="8dp"
tools:text="Last seen: 3s ago" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/first_seen"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/barrier_top"
app:layout_constraintEnd_toEndOf="@id/name"
app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="3s ago" />
app:layout_constraintTop_toBottomOf="@id/last_seen"
tools:text="First seen: 3s ago" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/reception"
@@ -79,7 +91,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -66,7 +66,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<com.google.android.material.textview.MaterialTextView
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="notification_channel_device_status_label">Geräte Status</string>
<string name="general_error_label">Fehler</string>
<string name="general_share_action">Teilen</string>
<string name="general_done_action">Fertig</string>
<string name="general_copy_action">Kopieren</string>
<string name="general_value_unknown_label">Unbekannt</string>
<string name="general_thank_you_label">Danke</string>
<string name="general_value_not_available_label">k.A.</string>
<string name="general_grant_permission_action">Berechtigung erteilen</string>
</resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

Some files were not shown because too many files have changed in this diff Show More