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) delay(3000 * attempt)
true true
} }
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
}
companion object { companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider") val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
@@ -28,6 +28,7 @@ class BillingDataRepo @Inject constructor(
) { ) {
private val connectionProvider = billingClientConnectionProvider.connection private val connectionProvider = billingClientConnectionProvider.connection
.catch { log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } }
.replayingShare(scope) .replayingShare(scope)
val billingData: Flow<BillingData> = connectionProvider val billingData: Flow<BillingData> = connectionProvider
@@ -6,8 +6,6 @@ import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext 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.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
@@ -27,7 +25,7 @@ import javax.inject.Singleton
class BleScanner @Inject constructor( class BleScanner @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings, private val fakeBleData: FakeBleData,
) { ) {
fun scan( fun scan(
@@ -72,8 +70,8 @@ class BleScanner @Inject constructor(
) )
if (adapter.isOffloadedScanBatchingSupported) { if (adapter.isOffloadedScanBatchingSupported) {
when (scannerMode) { when (scannerMode) {
ScannerMode.LOW_POWER -> setReportDelay(5000) ScannerMode.LOW_POWER -> setReportDelay(2000)
ScannerMode.BALANCED -> setReportDelay(2500) ScannerMode.BALANCED -> setReportDelay(1000)
ScannerMode.LOW_LATENCY -> setReportDelay(500) ScannerMode.LOW_LATENCY -> setReportDelay(500)
} }
} else { } else {
@@ -87,7 +85,7 @@ class BleScanner @Inject constructor(
adapter.bluetoothLeScanner.flushPendingScanResults(callback) adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) { when (scannerMode) {
ScannerMode.LOW_POWER -> break ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2500) ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500) ScannerMode.LOW_LATENCY -> delay(500)
} }
} }
@@ -108,58 +106,7 @@ class BleScanner @Inject constructor(
log(TAG, INFO) { "BleScanner stopped" } log(TAG, INFO) { "BleScanner stopped" }
} }
} }
.map { origs -> .map { fakeBleData.maybeAddfakeData(it) }
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()
}
companion object { companion object {
private val TAG = logTag("Bluetooth", "BleScanner") 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() .filterNotNull()
fun <T : Any> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn( fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
scope = scope, scope = scope,
replay = 1, replay = 1,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO) 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 { vm.workerAutolaunch.observe2 {
// While UI is active, subscribe to the autolaunch routine // While UI is active, subscribe to the autolaunch routine
@@ -4,7 +4,6 @@ import android.app.Activity
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings import eu.darken.capod.common.debug.autoreport.DebugSettings
@@ -46,7 +45,6 @@ class OverviewFragmentVM @Inject constructor(
debugSettings: DebugSettings, debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo, private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val webpageTool: WebpageTool,
) : ViewModel3(dispatcherProvider = dispatcherProvider) { ) : ViewModel3(dispatcherProvider = dispatcherProvider) {
val upgradeState = upgradeRepo.upgradeInfo.asLiveData2() val upgradeState = upgradeRepo.upgradeInfo.asLiveData2()
@@ -55,7 +53,7 @@ class OverviewFragmentVM @Inject constructor(
private val updateTicker = channelFlow<Unit> { private val updateTicker = channelFlow<Unit> {
while (isActive) { while (isActive) {
trySend(Unit) trySend(Unit)
delay(1000) delay(3000)
} }
} }
@@ -106,11 +104,10 @@ class OverviewFragmentVM @Inject constructor(
debugSettings.isDebugModeEnabled.flow, debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow, generalSettings.showAll.flow,
bluetoothManager.isBluetoothEnabled, bluetoothManager.isBluetoothEnabled,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled -> podMonitor.mainDevice,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled, mainPod ->
val items = mutableListOf<OverviewAdapter.Item>() val items = mutableListOf<OverviewAdapter.Item>()
val mainPod = podMonitor.mainDevice.first()
permissions permissions
.map { perm -> .map { perm ->
PermissionCardVH.Item( 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.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant import java.time.Instant
@@ -34,10 +33,7 @@ class BasicSingleApplePodsCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
batteryLabel.text = device.getBatteryLevelHeadset(context) batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent)) batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface import android.graphics.Typeface
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.lists.binding 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
import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor
import eu.darken.capod.pods.core.apple.DualApplePods.LidState import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import java.time.Duration
import java.time.Instant import java.time.Instant
class DualApplePodsCardVH(parent: ViewGroup) : class DualApplePodsCardVH(parent: ViewGroup) :
@@ -36,12 +38,11 @@ class DualApplePodsCardVH(parent: ViewGroup) :
} }
deviceIcon.setImageResource(device.iconRes) 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) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
// Left Pod // Left Pod
device.apply { device.apply {
@@ -3,11 +3,13 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import eu.darken.capod.R
import eu.darken.capod.common.lists.BindableVH import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.DifferItem import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.modular.ModularAdapter import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.main.ui.overview.OverviewAdapter import eu.darken.capod.main.ui.overview.OverviewAdapter
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.getSignalQuality
import java.time.Instant import java.time.Instant
abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>( abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
@@ -15,6 +17,10 @@ abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
parent: ViewGroup parent: ViewGroup
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B> { ) : 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 { interface Item : OverviewAdapter.Item {
val now: Instant 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.apple.SingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant import java.time.Instant
@@ -35,10 +34,7 @@ class SingleApplePodsCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
batteryLabel.text = device.getBatteryLevelHeadset(context) batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent)) 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.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant import java.time.Instant
@@ -30,10 +29,7 @@ class UnknownPodDeviceCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
rawdata.text = device.rawDataHex rawdata.text = device.rawDataHex
} }
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core 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.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope 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.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.main.core.GeneralSettings 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.PodDevice
import eu.darken.capod.pods.core.PodFactory import eu.darken.capod.pods.core.PodFactory
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -32,38 +35,47 @@ class PodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>() private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex() 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 val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled
.flatMapLatest { isBluetoothEnabled -> .flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) { if (isBluetoothEnabled) {
log(TAG) { "Bluetooth is enabled" }
generalSettings.scannerMode.flow generalSettings.scannerMode.flow
.flatMapLatest { mode ->
bleScanner.scan(scannerMode = mode).map { it.preFilterAndMap(mode) }
}
} else { } else {
log(TAG, WARN) { "Bluetooth is currently disabled" } log(TAG, WARN) { "Bluetooth is currently disabled" }
emptyFlow() flowOf(null)
} }
} }
.flatMapLatest { bleScanner.scan(scannerMode = it) } .map { newPods ->
.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) }
val pods = mutableMapOf<PodDevice.Id, PodDevice>() val pods = mutableMapOf<PodDevice.Id, PodDevice>()
cacheLock.withLock { cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
return@map emptyList()
}
val now = Instant.now() val now = Instant.now()
deviceCache.toList().forEach { (key, value) -> 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" } log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
deviceCache.remove(key) deviceCache.remove(key)
} }
@@ -77,41 +89,56 @@ class PodMonitor @Inject constructor(
} }
} }
val now = Instant.now() val presorted = pods.values.sortPodsToInterest()
val minimumSignalQuality = generalSettings.minimumSignalQuality.value val main = presorted.determineMainDevice()
val aboveTresholdSorted = pods.values presorted.sortedByDescending { it == main }
.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 }
} }
.onStart { emit(emptyList()) } .onStart { emit(emptyList()) }
.catch { .retryWhen { cause, attempt ->
log(TAG, ERROR) { "PodMonitor failed:\n${it.asLog()}" } log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
} }
.replayingShare(appScope) .replayingShare(appScope)
val mainDevice: Flow<PodDevice?> val mainDevice: Flow<PodDevice?>
get() = devices.map { it.determineMainDevice() } get() = devices
.map { it.determineMainDevice() }
.replayingShare(appScope)
private fun Collection<PodDevice>.determineMainDevice(): PodDevice? = private fun Collection<PodDevice>.sortPodsToInterest(): List<PodDevice> = this.let { devices ->
maxByOrNull { it.rssi }?.let filter@{ device -> val now = Instant.now()
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
generalSettings.mainDeviceModel.value.let { return@let devices.sortedWith(
if (device.model != it && it != PodDevice.Model.UNKNOWN) return@filter null 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 { companion object {
@@ -21,6 +21,7 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.* import eu.darken.capod.monitor.core.*
import eu.darken.capod.monitor.ui.MonitorNotifications import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.reaction.ReactionHub import eu.darken.capod.reaction.ReactionHub
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -68,9 +69,13 @@ class MonitorWorker @AssistedInject constructor(
Result.success(inputData) Result.success(inputData)
} catch (e: Throwable) { } catch (e: Throwable) {
Bugs.report(tag = TAG, "Execution failed", exception = e) if (e !is CancellationException) {
finishedWithError = true Bugs.report(tag = TAG, "Execution failed", exception = e)
Result.failure(inputData) finishedWithError = true
Result.failure(inputData)
} else {
Result.success()
}
} finally { } finally {
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).") this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
} }
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
interface PodDevice { interface PodDevice {
@@ -16,21 +17,28 @@ interface PodDevice {
val model: Model 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 scanResult: BleScanResult
val rssiHistory: List<Int>
val rssi: Int val rssi: Int
get() = rssiHistory.sum() / rssiHistory.size get() = scanResult.rssi
val confidence: Float
/** /**
* This is not correct but it works ¯\_(ツ)_/¯ * This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs). * The range of the RSSI is device specific (ROMs).
*/ */
val signalQuality: Float val signalQuality: Float
get() = (100 - abs(rssi)) / 100f get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
val rawData: ByteArray val rawData: ByteArray
@@ -93,4 +101,8 @@ interface PodDevice {
"Unknown" "Unknown"
); );
} }
companion object {
const val BASE_CONFIDENCE = 0.5f
}
} }
@@ -45,10 +45,19 @@ fun getBatteryDrawable(percent: Float?): Int = when {
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance() private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
fun PodDevice.lastSeenFormatted(now: Instant): String { fun PodDevice.lastSeenFormatted(now: Instant): String {
val duration = Duration.between(lastSeenAt, now) val duration = Duration.between(seenLastAt, now)
return lastSeenFormatter.format( return lastSeenFormatter.format(
duration.seconds.toDouble(), duration.seconds.toDouble(),
RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.SECONDS 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( class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder, private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.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, private val unknownAppleFactory: UnknownAppleDevice.Factory,
) { ) {
@@ -12,17 +12,17 @@ import eu.darken.capod.pods.core.apple.beats.*
@Module @Module
abstract class AppleFactoryModule { abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePods.Factory @Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePods.Factory @Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePods.Factory @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 airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePods.Factory @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 beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePods.Factory @Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePods.Factory @Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePods.Factory @Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePods.Factory @Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePods.Factory @Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
} }
@@ -1,17 +1,11 @@
package eu.darken.capod.pods.core.apple 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.lowerNibble
import eu.darken.capod.common.toHex import eu.darken.capod.common.toHex
import eu.darken.capod.common.upperNibble import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
interface ApplePods : PodDevice { interface ApplePods : PodDevice {
@@ -55,103 +49,4 @@ interface ApplePods : PodDevice {
get() = proximityMessage.data[8] 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) 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( data class UnknownAppleDevice(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int> override val confidence: Float = 0f,
private val rssiAverage: Int? = null,
) : ApplePods { ) : ApplePods {
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label) 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 isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
override fun create( override fun create(
scanResult: BleScanResult, scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message, proximityMessage: ProximityPairing.Message,
): ApplePods { ): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage) var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return UnknownAppleDevice( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
rssiHistory = recognized.rssiHistory,
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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsGen1 constructor( data class AirPodsGen1 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val rssiHistory: List<Int> private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
@@ -23,27 +28,35 @@ data class AirPodsGen1 constructor(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory, 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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsGen2 constructor( data class AirPodsGen2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val rssiHistory: List<Int>, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
@@ -23,27 +28,35 @@ data class AirPodsGen2 constructor(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory, 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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsGen3 constructor( data class AirPodsGen3 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val rssiHistory: List<Int>, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory, 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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsMax( data class AirPodsMax(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods { ) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
rssiHistory = recognized.rssiHistory,
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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsPro( data class AirPodsPro(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val rssiHistory: List<Int>, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@@ -23,33 +29,40 @@ data class AirPodsPro(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory, 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()
companion object { private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
private val DEVICE_CODE = 0x0e20.toUShort() }
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
} }
} }
@@ -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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsFlex( data class BeatsFlex(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
rssiHistory = recognized.rssiHistory,
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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsSolo3( data class BeatsSolo3(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
rssiHistory = recognized.rssiHistory,
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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsStudio3( data class BeatsStudio3(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
rssiHistory = recognized.rssiHistory,
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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsX( data class BeatsX(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_X 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
rssiHistory = recognized.rssiHistory,
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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class PowerBeats3( data class PowerBeats3(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
rssiHistory = recognized.rssiHistory,
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.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class PowerBeatsPro( data class PowerBeatsPro(
override val identifier: PodDevice.Id = PodDevice.Id(), 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 scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
override val rssiHistory: List<Int>, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
@@ -23,27 +28,35 @@ data class PowerBeatsPro(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage 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 = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { 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( if (result != null) basic = basic.copy(identifier = result.id)
identifier = recognized.identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory, 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 android.bluetooth.le.ScanFilter
import dagger.Reusable import dagger.Reusable
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import javax.inject.Inject import javax.inject.Inject
@@ -15,24 +13,6 @@ object ProximityPairing {
val length: Int, val length: Int,
val data: UByteArray 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 { override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) } 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 const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
private val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte() internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
private const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25 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" style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" 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" android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/barrier_top" app:layout_constraintBottom_toTopOf="@id/barrier_top"
app:layout_constraintEnd_toEndOf="@id/name" app:layout_constraintEnd_toEndOf="@id/name"
app:layout_constraintStart_toStartOf="@id/name" app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintTop_toBottomOf="@id/name" app:layout_constraintTop_toBottomOf="@id/last_seen"
tools:text="3s ago" /> tools:text="First seen: 3s ago" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/reception" android:id="@+id/reception"
@@ -79,7 +91,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+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)" /> tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+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)" /> tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+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)" /> tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
@@ -66,7 +66,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+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)" /> tools:text="Yours (RSSI -61)" />
<com.google.android.material.textview.MaterialTextView <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