mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
More refactoring
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import eu.darken.capod.BuildConfig
|
||||
|
||||
|
||||
// Can't be const because that prevents them from being mocked in tests
|
||||
@Suppress("MayBeConstant")
|
||||
object BuildConfigWrap {
|
||||
val DEBUG: Boolean = BuildConfig.DEBUG
|
||||
|
||||
val BUILD_TYPE: BuildType = when (val typ = BuildConfig.BUILD_TYPE) {
|
||||
"debug" -> BuildType.DEV
|
||||
"beta" -> BuildType.BETA
|
||||
"release" -> BuildType.RELEASE
|
||||
else -> throw IllegalArgumentException("Unknown buildtype: $typ")
|
||||
}
|
||||
|
||||
enum class BuildType {
|
||||
DEV,
|
||||
BETA,
|
||||
RELEASE,
|
||||
;
|
||||
}
|
||||
|
||||
val FLAVOR: Flavor = when (val flav = BuildConfig.FLAVOR) {
|
||||
"gplay" -> Flavor.GPLAY
|
||||
"foss" -> Flavor.FOSS
|
||||
else -> throw IllegalStateException("Unknown flavor: $flav")
|
||||
}
|
||||
|
||||
enum class Flavor {
|
||||
GPLAY,
|
||||
FOSS,
|
||||
;
|
||||
}
|
||||
|
||||
val APPLICATION_ID = BuildConfig.APPLICATION_ID
|
||||
|
||||
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
|
||||
val VERSION_NAME: String = BuildConfig.VERSION_NAME
|
||||
val GIT_SHA: String = BuildConfig.GITSHA
|
||||
|
||||
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
|
||||
val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
|
||||
val VERSION_DESCRIPTION_TINY: String = "v$VERSION_NAME"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.os.Build
|
||||
|
||||
// Can't be const because that prevents them from being mocked in tests
|
||||
@Suppress("MayBeConstant")
|
||||
object BuildWrap {
|
||||
|
||||
val VERSION = VersionWrap
|
||||
|
||||
object VersionWrap {
|
||||
val SDK_INT = Build.VERSION.SDK_INT
|
||||
}
|
||||
}
|
||||
|
||||
fun hasApiLevel(level: Int): Boolean = BuildWrap.VERSION.SDK_INT >= level
|
||||
|
||||
fun withinApiLevel(start: Int, end: Int): Boolean = BuildWrap.VERSION.SDK_INT in start..end
|
||||
@@ -1,20 +0,0 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import java.util.*
|
||||
|
||||
fun Byte.toHex(): String = String.format("%02X", this)
|
||||
fun UByte.toHex(): String = this.toByte().toHex()
|
||||
|
||||
val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toShort()
|
||||
val Byte.lowerNibble get() = (this.toInt() and 0b1111).toShort()
|
||||
val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUShort()
|
||||
val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUShort()
|
||||
|
||||
fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos)
|
||||
fun UByte.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
|
||||
|
||||
fun Short.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
|
||||
fun UShort.isBitSet(pos: Int): Boolean = this.toShort().isBitSet(pos)
|
||||
|
||||
fun UShort.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(4, '0')
|
||||
fun UByte.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(8, '0')
|
||||
@@ -1,42 +0,0 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class InstallId @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val installIDFile = File(context.filesDir, INSTALL_ID_FILENAME)
|
||||
val id: String by lazy {
|
||||
val existing = if (installIDFile.exists()) {
|
||||
installIDFile.readText().also {
|
||||
if (!UUID_PATTERN.matcher(it).matches()) throw IllegalStateException("Invalid InstallID: $it")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return@lazy existing ?: UUID.randomUUID().toString().also {
|
||||
log(TAG) { "New install ID created: $it" }
|
||||
installIDFile.writeText(it)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG: String = logTag("InstallID")
|
||||
private val UUID_PATTERN = Pattern.compile(
|
||||
"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
)
|
||||
|
||||
private const val INSTALL_ID_FILENAME = "installid"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
object SystemClockWrap {
|
||||
|
||||
val elapsedRealtimeNanos: Long
|
||||
get() = SystemClock.elapsedRealtimeNanos()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
|
||||
object UIConverter {
|
||||
fun convertDpToPixels(context: Context, dp: Float): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
dp,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
|
||||
fun convertSpToPixels(context: Context, sp: Float): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
sp,
|
||||
context.resources.displayMetrics
|
||||
).toInt()
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.Parcelable
|
||||
import androidx.core.util.forEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class BleScanResult(
|
||||
val address: String,
|
||||
val rssi: Int,
|
||||
val generatedAtNanos: Long,
|
||||
val manufacturerSpecificData: Map<Int, ByteArray>
|
||||
) : Parcelable {
|
||||
|
||||
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
||||
|
||||
override fun toString(): String {
|
||||
val sb = StringBuilder()
|
||||
manufacturerSpecificData.forEach { (key, value) ->
|
||||
sb.append("$key: ${value.joinToString(separator = " ") { String.format("%02X", it) }}")
|
||||
}
|
||||
return "BleScanResult($rssi, $address, $generatedAtNanos, $sb"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromScanResult(scanResult: ScanResult) = BleScanResult(
|
||||
address = scanResult.device.address,
|
||||
rssi = scanResult.rssi,
|
||||
generatedAtNanos = scanResult.timestampNanos,
|
||||
manufacturerSpecificData = mutableMapOf<Int, ByteArray>().apply {
|
||||
scanResult.scanRecord?.manufacturerSpecificData?.forEach { key, value ->
|
||||
this[key] = value
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.main.core.ScannerMode
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BleScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val fakeBleData: FakeBleData,
|
||||
) {
|
||||
|
||||
@SuppressLint("MissingPermission") fun scan(
|
||||
filters: Set<ScanFilter>,
|
||||
scannerMode: ScannerMode,
|
||||
compatMode: Boolean,
|
||||
): Flow<List<BleScanResult>> = callbackFlow {
|
||||
log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" }
|
||||
if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" }
|
||||
|
||||
val adapter = bluetoothManager.adapter
|
||||
|
||||
val supportsOffloadFiltering = adapter.isOffloadedFilteringSupported.also {
|
||||
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
|
||||
} && !compatMode
|
||||
|
||||
val supportsOffloadBatching = adapter.isOffloadedScanBatchingSupported.also {
|
||||
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
|
||||
} && !compatMode
|
||||
|
||||
val scanner = bluetoothManager.scanner
|
||||
|
||||
val callback = object : ScanCallback() {
|
||||
var lastScanAt = System.currentTimeMillis()
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
log(TAG, VERBOSE) {
|
||||
val delay = System.currentTimeMillis() - lastScanAt
|
||||
lastScanAt = System.currentTimeMillis()
|
||||
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
|
||||
}
|
||||
val toSend = if (
|
||||
supportsOffloadFiltering
|
||||
|| filters.isEmpty()
|
||||
|| filters.any { it.matchesSafe(result) }
|
||||
) {
|
||||
listOf(BleScanResult.fromScanResult(result))
|
||||
} else {
|
||||
log(TAG, VERBOSE) { "Manual filtering: No match for $result" }
|
||||
emptyList()
|
||||
}
|
||||
trySend(toSend)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
log(TAG, VERBOSE) {
|
||||
val delay = System.currentTimeMillis() - lastScanAt
|
||||
lastScanAt = System.currentTimeMillis()
|
||||
"onBatchScanResults(delay=${delay}ms, results=$results)"
|
||||
}
|
||||
|
||||
val toSend = results
|
||||
.filter { result ->
|
||||
val passed = when {
|
||||
supportsOffloadFiltering -> true
|
||||
filters.isEmpty() -> true
|
||||
else -> filters.any { it.matches(result) }
|
||||
}
|
||||
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
|
||||
passed
|
||||
}
|
||||
.map { BleScanResult.fromScanResult(it) }
|
||||
trySend(toSend)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
log(TAG, WARN) { "onScanFailed(errorCode=$errorCode)" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val settings = ScanSettings.Builder().apply {
|
||||
setScanMode(
|
||||
when (scannerMode) {
|
||||
ScannerMode.LOW_POWER -> ScanSettings.SCAN_MODE_LOW_POWER
|
||||
ScannerMode.BALANCED -> ScanSettings.SCAN_MODE_BALANCED
|
||||
ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY
|
||||
}
|
||||
)
|
||||
if (supportsOffloadBatching) {
|
||||
setReportDelay(
|
||||
when (scannerMode) {
|
||||
ScannerMode.LOW_POWER -> 2000L
|
||||
ScannerMode.BALANCED -> 1000L
|
||||
ScannerMode.LOW_LATENCY -> 500L
|
||||
}
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
|
||||
log(TAG, VERBOSE) { "Settings created for offloaded filtering: $settings" }
|
||||
|
||||
val flushJob = launch {
|
||||
log(TAG) { "Flush job launched" }
|
||||
while (isActive) {
|
||||
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
||||
log(TAG, VERBOSE) { "Flushing scan results." }
|
||||
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
|
||||
when (scannerMode) {
|
||||
ScannerMode.LOW_POWER -> break
|
||||
ScannerMode.BALANCED -> delay(1000)
|
||||
ScannerMode.LOW_LATENCY -> delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanner.startScan(
|
||||
if (supportsOffloadFiltering) filters.toList() else listOf(ScanFilter.Builder().build()),
|
||||
settings,
|
||||
callback
|
||||
)
|
||||
log(TAG) { "BleScanner started (filters=$filters, settings=$settings)" }
|
||||
|
||||
awaitClose {
|
||||
flushJob.cancel()
|
||||
scanner.stopScan(callback)
|
||||
log(TAG) { "BleScanner stopped" }
|
||||
}
|
||||
}
|
||||
.map { fakeBleData.maybeAddfakeData(it) }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "BleScanner")
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.ParcelUuid
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
|
||||
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
|
||||
return uuids?.contains(uuid) ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.Object android.util.SparseArray.get(int)' on a null object reference
|
||||
* at android.bluetooth.le.ScanRecord.getManufacturerSpecificData(ScanRecord.java:118)
|
||||
* at android.bluetooth.le.ScanFilter.matches(ScanFilter.java:369)
|
||||
* ZenFone Max Pro M1 (ZB602KL) (WW) / Max Pro M1 (ZB601KL) (IN) (ZB602KL), Android 9, PKQ1.WW_Phone-16.2017.2009.087-20200826
|
||||
* Intel Gemini Lake Chromebook (octopus), Android 9, R99-14469.59.0 release-keys
|
||||
*/
|
||||
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
|
||||
matches(scanResult)
|
||||
} catch (e: NullPointerException) {
|
||||
log { "AOSP error: ${e.asLog()}" }
|
||||
false
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.ParcelUuid
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BluetoothManager2 @Inject constructor(
|
||||
private val manager: BluetoothManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
) {
|
||||
|
||||
val adapter: BluetoothAdapter
|
||||
get() = manager.adapter
|
||||
|
||||
val scanner: BluetoothLeScanner
|
||||
get() = adapter.bluetoothLeScanner
|
||||
?: throw IllegalStateException("Bluetooth is disabled or permissiong missing")
|
||||
|
||||
val isBluetoothEnabled: Flow<Boolean> = callbackFlow {
|
||||
send(manager.adapter?.isEnabled ?: false)
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (BluetoothAdapter.ACTION_STATE_CHANGED != intent.action) {
|
||||
log(TAG) { "Unknown BluetoothAdapter action: $intent" }
|
||||
return
|
||||
}
|
||||
|
||||
val value = when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
|
||||
BluetoothAdapter.STATE_OFF -> false
|
||||
BluetoothAdapter.STATE_ON -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
trySend(value)
|
||||
}
|
||||
}
|
||||
context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
|
||||
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
|
||||
|
||||
var profileProxy: BluetoothProfile2? = null
|
||||
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
|
||||
|
||||
profileProxy = BluetoothProfile2(
|
||||
profileType = profile,
|
||||
profileProxy = proxy,
|
||||
).also { trySend(it) }
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {
|
||||
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
|
||||
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
|
||||
}
|
||||
|
||||
}, profile)
|
||||
|
||||
awaitClose {
|
||||
log(TAG) { "Closing BluetoothProfile: $profileProxy" }
|
||||
profileProxy?.let {
|
||||
manager.adapter.closeProfileProxy(it.profileType, it.proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun monitorDevicesForProfile(
|
||||
profile: Int = BluetoothProfile.HEADSET
|
||||
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
|
||||
callbackFlow {
|
||||
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
|
||||
trySend(bluetoothProfile.connectedDevices)
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
|
||||
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
|
||||
}
|
||||
|
||||
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
|
||||
start()
|
||||
}
|
||||
val handler = Handler(handlerThread.looper)
|
||||
|
||||
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
|
||||
val action = intent.action
|
||||
if (action == null) {
|
||||
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
|
||||
return
|
||||
}
|
||||
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
|
||||
if (device == null) {
|
||||
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
|
||||
return
|
||||
}
|
||||
|
||||
this@callbackFlow.launch {
|
||||
val currentDevices = bluetoothProfile.connectedDevices
|
||||
|
||||
when (action) {
|
||||
BluetoothDevice.ACTION_ACL_CONNECTED -> {
|
||||
log(TAG) { "Adding $device to current devices $currentDevices" }
|
||||
trySend(currentDevices.plus(device))
|
||||
}
|
||||
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
|
||||
log(TAG) { "Removing $device from current devices $currentDevices" }
|
||||
trySend(currentDevices.minus(device))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context.registerReceiver(receiver, filter, null, handler)
|
||||
|
||||
awaitClose {
|
||||
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectedDevices(
|
||||
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
|
||||
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
|
||||
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
|
||||
.map { devices ->
|
||||
devices.filter { device ->
|
||||
featureFilter.any { feature ->
|
||||
device.hasFeature(feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
|
||||
|
||||
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
connectMethod.invoke(bluetoothProfile.proxy, device)
|
||||
|
||||
log(TAG) { "Nudged connection to $device" }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
false
|
||||
}
|
||||
}.first()
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "Manager2")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothProfile
|
||||
|
||||
data class BluetoothProfile2(
|
||||
internal val profileType: Int,
|
||||
private val profileProxy: BluetoothProfile,
|
||||
) {
|
||||
|
||||
val proxy: BluetoothProfile
|
||||
get() = profileProxy
|
||||
|
||||
val connectedDevices: Set<BluetoothDevice>
|
||||
get() = proxy.connectedDevices.toSet()
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Tws i99999
|
||||
BleScanResult(
|
||||
address = "5E:9E:D1:29:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
|
||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package eu.darken.capod.common.coroutine
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@Singleton
|
||||
class AppCoroutineScope @Inject constructor() : CoroutineScope {
|
||||
override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default
|
||||
}
|
||||
|
||||
@Qualifier
|
||||
@MustBeDocumented
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class AppScope
|
||||
@@ -1,19 +0,0 @@
|
||||
package eu.darken.capod.common.coroutine
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
abstract class CoroutineModule {
|
||||
|
||||
@Binds
|
||||
abstract fun dispatcherProvider(defaultProvider: DefaultDispatcherProvider): DispatcherProvider
|
||||
|
||||
@Binds
|
||||
@AppScope
|
||||
abstract fun appscope(appCoroutineScope: AppCoroutineScope): CoroutineScope
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package eu.darken.capod.common.coroutine
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider
|
||||
@@ -1,21 +0,0 @@
|
||||
package eu.darken.capod.common.coroutine
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
// Need this to improve testing
|
||||
// Can currently only replace the main-thread dispatcher.
|
||||
// https://github.com/Kotlin/kotlinx.coroutines/issues/1365
|
||||
@Suppress("PropertyName", "VariableNaming")
|
||||
interface DispatcherProvider {
|
||||
val Default: CoroutineContext
|
||||
get() = Dispatchers.Default
|
||||
val Main: CoroutineContext
|
||||
get() = Dispatchers.Main
|
||||
val MainImmediate: CoroutineContext
|
||||
get() = Dispatchers.Main.immediate
|
||||
val Unconfined: CoroutineContext
|
||||
get() = Dispatchers.Unconfined
|
||||
val IO: CoroutineContext
|
||||
get() = Dispatchers.IO
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package eu.darken.capod.common.debug
|
||||
|
||||
import com.bugsnag.android.Bugsnag
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
|
||||
object Bugs {
|
||||
var ready = false
|
||||
fun report(
|
||||
tag: String,
|
||||
message: String,
|
||||
exception: Throwable
|
||||
) {
|
||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
||||
|
||||
if (!ready) {
|
||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
||||
return
|
||||
}
|
||||
Bugsnag.notify(exception)
|
||||
}
|
||||
|
||||
private val TAG = logTag("Bugs")
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package eu.darken.capod.common.debug.autoreport
|
||||
|
||||
import android.content.Context
|
||||
import com.bugsnag.android.Bugsnag
|
||||
import com.bugsnag.android.Configuration
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.InstallId
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagErrorHandler
|
||||
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagLogger
|
||||
import eu.darken.capod.common.debug.autoreport.bugsnag.NOPBugsnagErrorHandler
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AutoReporting @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val installId: InstallId,
|
||||
private val bugsnagLogger: Provider<BugsnagLogger>,
|
||||
private val bugsnagErrorHandler: Provider<BugsnagErrorHandler>,
|
||||
private val nopBugsnagErrorHandler: Provider<NOPBugsnagErrorHandler>,
|
||||
) {
|
||||
|
||||
fun setup() {
|
||||
val isEnabled = debugSettings.isAutoReportingEnabled.value
|
||||
log(TAG) { "setup(): isEnabled=$isEnabled" }
|
||||
|
||||
try {
|
||||
val bugsnagConfig = Configuration.load(context).apply {
|
||||
if (debugSettings.isAutoReportingEnabled.value) {
|
||||
Logging.install(bugsnagLogger.get())
|
||||
setUser(installId.id, null, null)
|
||||
autoTrackSessions = true
|
||||
addOnError(bugsnagErrorHandler.get())
|
||||
addMetadata("App", "buildFlavor", BuildConfigWrap.FLAVOR)
|
||||
log(TAG) { "Bugsnag setup done!" }
|
||||
} else {
|
||||
autoTrackSessions = false
|
||||
addOnError(nopBugsnagErrorHandler.get())
|
||||
log(TAG) { "Installing Bugsnag NOP error handler due to user opt-out!" }
|
||||
}
|
||||
}
|
||||
|
||||
Bugsnag.start(context, bugsnagConfig)
|
||||
Bugs.ready = true
|
||||
} catch (e: IllegalStateException) {
|
||||
log(TAG) { "Bugsnag API Key not configured." }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Debug", "AutoReport")
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package eu.darken.capod.common.debug.autoreport
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.preferences.PreferenceStoreMapper
|
||||
import eu.darken.capod.common.preferences.Settings
|
||||
import eu.darken.capod.common.preferences.createFlowPreference
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DebugSettings @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : Settings() {
|
||||
|
||||
override val preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE)
|
||||
|
||||
val isAutoReportingEnabled = preferences.createFlowPreference(
|
||||
key = "debug.bugreport.automatic.enabled",
|
||||
// Reporting is opt-out for gplay, and opt-in for github builds
|
||||
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
||||
)
|
||||
val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false)
|
||||
|
||||
val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false)
|
||||
|
||||
val showUnfiltered = preferences.createFlowPreference("debug.blescanner.unfiltered.enabled", false)
|
||||
|
||||
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
||||
isDebugModeEnabled,
|
||||
showFakeData,
|
||||
showUnfiltered,
|
||||
)
|
||||
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
package eu.darken.capod.common.debug.autoreport.bugsnag
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.bugsnag.android.Event
|
||||
import com.bugsnag.android.OnErrorCallback
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.BuildConfig
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.debug.autoreport.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BugsnagErrorHandler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val bugsnagLogger: BugsnagLogger,
|
||||
private val debugSettings: DebugSettings,
|
||||
) : OnErrorCallback {
|
||||
|
||||
override fun onError(event: Event): Boolean {
|
||||
bugsnagLogger.injectLog(event)
|
||||
|
||||
TAB_APP.also { tab ->
|
||||
event.addMetadata(tab, "gitSha", BuildConfig.GITSHA)
|
||||
event.addMetadata(tab, "buildTime", BuildConfig.BUILDTIME)
|
||||
|
||||
context.tryFormattedSignature()?.let { event.addMetadata(tab, "signatures", it) }
|
||||
}
|
||||
|
||||
return debugSettings.isAutoReportingEnabled.value && !BuildConfigWrap.DEBUG
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAB_APP = "app"
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
fun Context.tryFormattedSignature(): String? = try {
|
||||
packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures?.let { sigs ->
|
||||
val sb = StringBuilder("[")
|
||||
for (i in sigs.indices) {
|
||||
sb.append(sigs[i].hashCode())
|
||||
if (i + 1 != sigs.size) sb.append(", ")
|
||||
}
|
||||
sb.append("]")
|
||||
sb.toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log(WARN) { e.asLog() }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package eu.darken.capod.common.debug.autoreport.bugsnag
|
||||
|
||||
import com.bugsnag.android.Event
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import java.lang.String.format
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BugsnagLogger @Inject constructor() : Logging.Logger {
|
||||
|
||||
// Adding one to the initial size accounts for the add before remove.
|
||||
private val buffer: Deque<String> = ArrayDeque(BUFFER_SIZE + 1)
|
||||
|
||||
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
|
||||
val line = "${System.currentTimeMillis()} ${priority.toLabel()}/$tag: $message"
|
||||
synchronized(buffer) {
|
||||
buffer.addLast(line)
|
||||
if (buffer.size > BUFFER_SIZE) {
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun injectLog(event: Event) {
|
||||
synchronized(buffer) {
|
||||
var i = 100
|
||||
buffer.forEach { event.addMetadata("Log", format(Locale.ROOT, "%03d", i++), it) }
|
||||
event.addMetadata("Log", format(Locale.ROOT, "%03d", i), event.originalError?.asLog())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BUFFER_SIZE = 200
|
||||
|
||||
private fun Logging.Priority.toLabel(): String = when (this) {
|
||||
Logging.Priority.VERBOSE -> "V"
|
||||
Logging.Priority.DEBUG -> "D"
|
||||
Logging.Priority.INFO -> "I"
|
||||
Logging.Priority.WARN -> "W"
|
||||
Logging.Priority.ERROR -> "E"
|
||||
Logging.Priority.ASSERT -> "WTF"
|
||||
}
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package eu.darken.capod.common.debug.autoreport.bugsnag
|
||||
|
||||
import com.bugsnag.android.Event
|
||||
import com.bugsnag.android.OnErrorCallback
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NOPBugsnagErrorHandler @Inject constructor() : OnErrorCallback {
|
||||
|
||||
override fun onError(event: Event): Boolean {
|
||||
log(WARN) { "Error, but skipping bugsnag due to user opt-out: ${event.originalError?.asLog()}" }
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package eu.darken.capod.common.debug.logging
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStreamWriter
|
||||
import java.time.Instant
|
||||
|
||||
|
||||
@SuppressLint("LogNotTimber")
|
||||
class FileLogger(private val logFile: File) : Logging.Logger {
|
||||
private var logWriter: OutputStreamWriter? = null
|
||||
|
||||
@SuppressLint("SetWorldReadable")
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (logWriter != null) return
|
||||
|
||||
logFile.parentFile!!.mkdirs()
|
||||
if (logFile.createNewFile()) {
|
||||
Log.i(TAG, "File logger writing to " + logFile.path)
|
||||
}
|
||||
if (logFile.setReadable(true, false)) {
|
||||
Log.i(TAG, "Debug run log read permission set")
|
||||
}
|
||||
|
||||
try {
|
||||
logWriter = OutputStreamWriter(FileOutputStream(logFile, true))
|
||||
logWriter!!.write("=== BEGIN ===\n")
|
||||
logWriter!!.write("Logfile: $logFile\n")
|
||||
logWriter!!.flush()
|
||||
Log.i(TAG, "File logger started.")
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
|
||||
logFile.delete()
|
||||
if (logWriter != null) logWriter!!.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
logWriter?.let {
|
||||
logWriter = null
|
||||
try {
|
||||
it.write("=== END ===\n")
|
||||
it.close()
|
||||
} catch (ignore: IOException) {
|
||||
}
|
||||
Log.i(TAG, "File logger stopped.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
|
||||
logWriter?.let {
|
||||
try {
|
||||
it.write("${Instant.ofEpochMilli(System.currentTimeMillis())} ${priority.shortLabel}/$tag: $message\n")
|
||||
it.flush()
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Failed to write log line.", e)
|
||||
try {
|
||||
it.close()
|
||||
} catch (ignore: Exception) {
|
||||
}
|
||||
logWriter = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = "FileLogger(file=$logFile)"
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Debug", "FileLogger")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package eu.darken.capod.common.debug.logging
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlin.math.min
|
||||
|
||||
class LogCatLogger : Logging.Logger {
|
||||
|
||||
override fun isLoggable(priority: Logging.Priority): Boolean = true
|
||||
|
||||
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
|
||||
|
||||
val trimmedTag = if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
|
||||
tag
|
||||
} else {
|
||||
tag.substring(0, MAX_TAG_LENGTH)
|
||||
}
|
||||
|
||||
if (message.length < MAX_LOG_LENGTH) {
|
||||
writeToLogcat(priority.intValue, trimmedTag, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Split by line, then ensure each line can fit into Log's maximum length.
|
||||
var i = 0
|
||||
val length = message.length
|
||||
while (i < length) {
|
||||
var newline = message.indexOf('\n', i)
|
||||
newline = if (newline != -1) newline else length
|
||||
do {
|
||||
val end = min(newline, i + MAX_LOG_LENGTH)
|
||||
val part = message.substring(i, end)
|
||||
writeToLogcat(priority.intValue, trimmedTag, part)
|
||||
i = end
|
||||
} while (i < newline)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeToLogcat(priority: Int, tag: String, part: String) = when (priority) {
|
||||
Log.ASSERT -> Log.wtf(tag, part)
|
||||
else -> Log.println(priority, tag, part)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_LOG_LENGTH = 4000
|
||||
private const val MAX_TAG_LENGTH = 23
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package eu.darken.capod.common.debug.logging
|
||||
|
||||
fun logTag(vararg tags: String): String {
|
||||
val sb = StringBuilder("CAP:")
|
||||
for (i in tags.indices) {
|
||||
sb.append(tags[i])
|
||||
if (i < tags.size - 1) sb.append(":")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package eu.darken.capod.common.debug.logging
|
||||
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
/**
|
||||
* Inspired by
|
||||
* https://github.com/PaulWoitaschek/Slimber
|
||||
* https://github.com/square/logcat
|
||||
* https://github.com/JakeWharton/timber
|
||||
*/
|
||||
|
||||
object Logging {
|
||||
enum class Priority(
|
||||
val intValue: Int,
|
||||
val shortLabel: String
|
||||
) {
|
||||
VERBOSE(2, "V"),
|
||||
DEBUG(3, "D"),
|
||||
INFO(4, "I"),
|
||||
WARN(5, "W"),
|
||||
ERROR(6, "E"),
|
||||
ASSERT(7, "WTF");
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
fun isLoggable(priority: Priority): Boolean = true
|
||||
|
||||
fun log(
|
||||
priority: Priority,
|
||||
tag: String,
|
||||
message: String,
|
||||
metaData: Map<String, Any>?
|
||||
)
|
||||
}
|
||||
|
||||
private val internalLoggers = mutableListOf<Logger>()
|
||||
|
||||
val loggers: List<Logger>
|
||||
get() = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||
|
||||
val hasReceivers: Boolean
|
||||
get() = synchronized(internalLoggers) {
|
||||
internalLoggers.isNotEmpty()
|
||||
}
|
||||
|
||||
fun install(logger: Logger) {
|
||||
synchronized(internalLoggers) { internalLoggers.add(logger) }
|
||||
log { "Was installed $logger" }
|
||||
}
|
||||
|
||||
fun remove(logger: Logger) {
|
||||
log { "Removing: $logger" }
|
||||
synchronized(internalLoggers) { internalLoggers.remove(logger) }
|
||||
}
|
||||
|
||||
fun logInternal(
|
||||
tag: String,
|
||||
priority: Priority,
|
||||
metaData: Map<String, Any>?,
|
||||
message: String
|
||||
) {
|
||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||
snapshot
|
||||
.filter { it.isLoggable(priority) }
|
||||
.forEach {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
log { "Clearing all loggers" }
|
||||
synchronized(internalLoggers) { internalLoggers.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
inline fun Any.log(
|
||||
priority: Logging.Priority = Logging.Priority.DEBUG,
|
||||
metaData: Map<String, Any>? = null,
|
||||
message: () -> String,
|
||||
) {
|
||||
if (Logging.hasReceivers) {
|
||||
Logging.logInternal(
|
||||
tag = "CAP:${logTagViaCallSite()}",
|
||||
priority = priority,
|
||||
metaData = metaData,
|
||||
message = message(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun log(
|
||||
tag: String,
|
||||
priority: Logging.Priority = Logging.Priority.DEBUG,
|
||||
metaData: Map<String, Any>? = null,
|
||||
message: () -> String,
|
||||
) {
|
||||
if (Logging.hasReceivers) {
|
||||
Logging.logInternal(
|
||||
tag = tag,
|
||||
priority = priority,
|
||||
metaData = metaData,
|
||||
message = message(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.asLog(): String {
|
||||
val stringWriter = StringWriter(256)
|
||||
val printWriter = PrintWriter(stringWriter, false)
|
||||
printStackTrace(printWriter)
|
||||
printWriter.flush()
|
||||
return stringWriter.toString()
|
||||
}
|
||||
|
||||
@PublishedApi
|
||||
internal fun Any.logTagViaCallSite(): String {
|
||||
val javaClass = this::class.java
|
||||
val fullClassName = javaClass.name
|
||||
val outerClassName = fullClassName.substringBefore('$')
|
||||
val simplerOuterClassName = outerClassName.substringAfterLast('.')
|
||||
return if (simplerOuterClassName.isEmpty()) {
|
||||
fullClassName
|
||||
} else {
|
||||
simplerOuterClassName.removeSuffix("Kt")
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FlowPreference<T> constructor(
|
||||
private val preferences: SharedPreferences,
|
||||
val key: String,
|
||||
val rawReader: (Any?) -> T,
|
||||
val rawWriter: (T) -> Any?
|
||||
) {
|
||||
|
||||
private val flowInternal = MutableStateFlow(value)
|
||||
val flow: Flow<T> = flowInternal
|
||||
|
||||
private val preferenceChangeListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { changedPrefs, changedKey ->
|
||||
if (changedKey != key) return@OnSharedPreferenceChangeListener
|
||||
|
||||
val newValue = rawReader(changedPrefs.all[key])
|
||||
|
||||
val currentValue = flowInternal.value
|
||||
if (currentValue != newValue && flowInternal.compareAndSet(currentValue, newValue)) {
|
||||
log(VERBOSE) { "$changedPrefs:$changedKey changed to $newValue" }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
}
|
||||
|
||||
var value: T
|
||||
get() = rawReader(valueRaw)
|
||||
set(newVal) {
|
||||
valueRaw = rawWriter(newVal)
|
||||
}
|
||||
|
||||
var valueRaw: Any?
|
||||
get() = preferences.all[key] ?: rawWriter(rawReader(null))
|
||||
set(value) {
|
||||
preferences.edit {
|
||||
when (value) {
|
||||
is Boolean -> putBoolean(key, value)
|
||||
is String -> putString(key, value)
|
||||
is Int -> putInt(key, value)
|
||||
is Long -> putLong(key, value)
|
||||
is Float -> putFloat(key, value)
|
||||
null -> remove(key)
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
|
||||
}
|
||||
flowInternal.value = rawReader(value)
|
||||
}
|
||||
|
||||
fun update(update: (T) -> T) {
|
||||
value = update(value)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
||||
|
||||
inline fun <reified T> basicReader(defaultValue: T): (rawValue: Any?) -> T =
|
||||
{ rawValue ->
|
||||
(rawValue ?: defaultValue) as T
|
||||
}
|
||||
|
||||
inline fun <reified T> basicWriter(): (T) -> Any? =
|
||||
{ value ->
|
||||
when (value) {
|
||||
is Boolean -> value
|
||||
is String -> value
|
||||
is Int -> value
|
||||
is Long -> value
|
||||
is Float -> value
|
||||
null -> null
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
|
||||
key: String,
|
||||
defaultValue: T = null as T
|
||||
) = FlowPreference(
|
||||
preferences = this,
|
||||
key = key,
|
||||
rawReader = basicReader(defaultValue),
|
||||
rawWriter = basicWriter()
|
||||
)
|
||||
|
||||
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
|
||||
key: String,
|
||||
noinline reader: (rawValue: Any?) -> T,
|
||||
noinline writer: (value: T) -> Any?
|
||||
) = FlowPreference(
|
||||
preferences = this,
|
||||
key = key,
|
||||
rawReader = reader,
|
||||
rawWriter = writer
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.squareup.moshi.Moshi
|
||||
|
||||
inline fun <reified T> moshiReader(
|
||||
moshi: Moshi,
|
||||
defaultValue: T,
|
||||
): (Any?) -> T {
|
||||
val adapter = moshi.adapter(T::class.java)
|
||||
return { rawValue ->
|
||||
rawValue as String?
|
||||
rawValue?.let { adapter.fromJson(it) } ?: defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> moshiWriter(
|
||||
moshi: Moshi,
|
||||
): (T) -> Any? {
|
||||
val adapter = moshi.adapter(T::class.java)
|
||||
return { newValue: T ->
|
||||
newValue?.let { adapter.toJson(it) }
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
|
||||
key: String,
|
||||
defaultValue: T = null as T,
|
||||
moshi: Moshi,
|
||||
) = FlowPreference(
|
||||
preferences = this,
|
||||
key = key,
|
||||
rawReader = moshiReader(moshi, defaultValue),
|
||||
rawWriter = moshiWriter(moshi)
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
|
||||
open class PreferenceStoreMapper(
|
||||
private vararg val flowPreferences: FlowPreference<*>
|
||||
) : PreferenceDataStore() {
|
||||
|
||||
override fun getBoolean(key: String, defValue: Boolean): Boolean {
|
||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw as Boolean
|
||||
} ?: throw NotImplementedError("getBoolean(key=$key, defValue=$defValue)")
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean) {
|
||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw = value
|
||||
} ?: throw NotImplementedError("putBoolean(key=$key, defValue=$value)")
|
||||
}
|
||||
|
||||
override fun getString(key: String, defValue: String?): String? {
|
||||
val pref = flowPreferences.singleOrNull { it.key == key }
|
||||
?: throw NotImplementedError("getString(key=$key, defValue=$defValue)")
|
||||
|
||||
return pref.let { flowPref ->
|
||||
flowPref.valueRaw as String?
|
||||
}
|
||||
}
|
||||
|
||||
override fun putString(key: String, value: String?) {
|
||||
val pref = flowPreferences.singleOrNull { it.key == key }
|
||||
?: throw NotImplementedError("putString(key=$key, defValue=$value)")
|
||||
pref.let { flowPref ->
|
||||
flowPref.valueRaw = value
|
||||
}
|
||||
}
|
||||
|
||||
override fun getInt(key: String?, defValue: Int): Int {
|
||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw as Int
|
||||
} ?: throw NotImplementedError("getInt(key=$key, defValue=$defValue)")
|
||||
}
|
||||
|
||||
override fun putInt(key: String?, value: Int) {
|
||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw = value
|
||||
} ?: throw NotImplementedError("putInt(key=$key, defValue=$value)")
|
||||
}
|
||||
|
||||
override fun getLong(key: String?, defValue: Long): Long {
|
||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw as Long
|
||||
} ?: throw NotImplementedError("getLong(key=$key, defValue=$defValue)")
|
||||
}
|
||||
|
||||
override fun putLong(key: String?, value: Long) {
|
||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw = value
|
||||
} ?: throw NotImplementedError("putLong(key=$key, defValue=$value)")
|
||||
}
|
||||
|
||||
override fun getFloat(key: String?, defValue: Float): Float {
|
||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw as Float
|
||||
} ?: throw NotImplementedError("getFloat(key=$key, defValue=$defValue)")
|
||||
}
|
||||
|
||||
override fun putFloat(key: String?, value: Float) {
|
||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
||||
flowPref.valueRaw = value
|
||||
} ?: throw NotImplementedError("putFloat(key=$key, defValue=$value)")
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String?, values: MutableSet<String>?) {
|
||||
throw NotImplementedError("putStringSet(key=$key, defValue=$values)")
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> {
|
||||
throw NotImplementedError("getStringSet(key=$key, defValue=$defValues)")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceDataStore
|
||||
|
||||
abstract class Settings {
|
||||
|
||||
abstract val preferenceDataStore: PreferenceDataStore
|
||||
|
||||
abstract val preferences: SharedPreferences
|
||||
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package eu.darken.capod.common.preferences
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
|
||||
fun SharedPreferences.clearAndNotify() {
|
||||
val currentKeys = this.all.keys.toSet()
|
||||
log(VERBOSE) { "$this clearAndNotify(): $currentKeys" }
|
||||
edit {
|
||||
currentKeys.forEach { remove(it) }
|
||||
}
|
||||
// Clear does not notify anyone using registerOnSharedPreferenceChangeListener
|
||||
edit(commit = true) { clear() }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.debug.autoreport.DebugSettings
|
||||
import eu.darken.capod.common.preferences.PreferenceStoreMapper
|
||||
import eu.darken.capod.common.preferences.Settings
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package eu.darken.capod.main.core
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class ScannerMode(
|
||||
val identifier: String,
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@Json(name = "scanner.mode.lowpower") LOW_POWER(
|
||||
"scanner.mode.lowpower",
|
||||
R.string.settings_scanner_mode_lowpower_label
|
||||
),
|
||||
@Json(name = "scanner.mode.balanced") BALANCED(
|
||||
"scanner.mode.balanced",
|
||||
R.string.settings_scanner_mode_balanced_label
|
||||
),
|
||||
@Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
|
||||
"scanner.mode.lowlatency",
|
||||
R.string.settings_scanner_mode_lowlatency_label
|
||||
),
|
||||
}
|
||||
+1
-1
@@ -8,12 +8,12 @@ import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.preferences.PercentSliderPreference
|
||||
import eu.darken.capod.common.uix.PreferenceFragment2
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.ScannerMode
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.bluetooth.le.ScanFilter
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.bluetooth.BleScanner
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.autoreport.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
@@ -13,7 +14,6 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.ScannerMode
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.PodFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface DualPodDevice : PodDevice {
|
||||
|
||||
enum class Pod {
|
||||
LEFT,
|
||||
RIGHT
|
||||
}
|
||||
|
||||
val batteryLeftPodPercent: Float?
|
||||
|
||||
val batteryRightPodPercent: Float?
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface HasCase {
|
||||
|
||||
val batteryCasePercent: Float?
|
||||
|
||||
val isCaseCharging: Boolean
|
||||
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface HasChargeDetection {
|
||||
|
||||
val isHeadsetBeingCharged: Boolean
|
||||
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface HasChargeDetectionDual : HasChargeDetection {
|
||||
|
||||
val isLeftPodCharging: Boolean
|
||||
|
||||
val isRightPodCharging: Boolean
|
||||
|
||||
val isEitherPodCharging: Boolean
|
||||
get() = isLeftPodCharging || isRightPodCharging
|
||||
|
||||
override val isHeadsetBeingCharged: Boolean
|
||||
get() = isEitherPodCharging
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface HasDualMicrophone {
|
||||
|
||||
val isLeftPodMicrophone: Boolean
|
||||
|
||||
val isRightPodMicrophone: Boolean
|
||||
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface HasEarDetection {
|
||||
|
||||
val isBeingWorn: Boolean
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface HasEarDetectionDual : HasEarDetection {
|
||||
|
||||
val isLeftPodInEar: Boolean
|
||||
|
||||
val isRightPodInEar: Boolean
|
||||
|
||||
val isEitherPodInEar: Boolean
|
||||
get() = isLeftPodInEar || isRightPodInEar
|
||||
|
||||
override val isBeingWorn: Boolean
|
||||
get() = isLeftPodInEar && isRightPodInEar
|
||||
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
|
||||
interface HasPodStyle {
|
||||
|
||||
val podStyle: PodStyle
|
||||
|
||||
interface PodStyle {
|
||||
fun getLabel(context: Context): String
|
||||
|
||||
@ColorRes
|
||||
fun getColor(context: Context): Int
|
||||
|
||||
val identifier: String
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface HasStateDetection {
|
||||
|
||||
val state: State
|
||||
|
||||
interface State {
|
||||
fun getLabel(context: Context): String
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
interface PodDevice {
|
||||
|
||||
val identifier: Id
|
||||
|
||||
val model: Model
|
||||
|
||||
val address: String
|
||||
get() = scanResult.address
|
||||
|
||||
val seenLastAt: Instant
|
||||
|
||||
val seenFirstAt: Instant
|
||||
|
||||
val seenCounter: Int
|
||||
|
||||
val scanResult: BleScanResult
|
||||
|
||||
val rssi: Int
|
||||
get() = scanResult.rssi
|
||||
|
||||
val confidence: Float
|
||||
|
||||
/**
|
||||
* This is not correct but it works ¯\_(ツ)_/¯
|
||||
* The range of the RSSI is device specific (ROMs).
|
||||
*/
|
||||
val signalQuality: Float
|
||||
get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
|
||||
|
||||
val rawData: Map<Int, ByteArray>
|
||||
get() = scanResult.manufacturerSpecificData
|
||||
|
||||
val rawDataHex: List<String>
|
||||
get() = rawData.entries.map { entry ->
|
||||
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
|
||||
}
|
||||
|
||||
fun getLabel(context: Context): String = model.label
|
||||
|
||||
@get:DrawableRes
|
||||
val iconRes: Int
|
||||
get() = model.iconRes
|
||||
|
||||
@JvmInline
|
||||
value class Id(private val id: UUID = UUID.randomUUID())
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
enum class Model(
|
||||
val label: String,
|
||||
@DrawableRes val iconRes: Int = R.drawable.ic_device_generic_earbuds,
|
||||
) {
|
||||
@Json(name = "airpods.gen1") AIRPODS_GEN1(
|
||||
label = "AirPods (Gen 1)",
|
||||
iconRes = R.drawable.ic_device_airpods_gen1,
|
||||
),
|
||||
@Json(name = "airpods.gen2") AIRPODS_GEN2(
|
||||
"AirPods (Gen 2)",
|
||||
R.drawable.ic_device_airpods_gen2,
|
||||
),
|
||||
@Json(name = "airpods.gen3") AIRPODS_GEN3(
|
||||
"AirPods (Gen 3)",
|
||||
R.drawable.ic_device_airpods_gen2,
|
||||
),
|
||||
@Json(name = "airpods.pro") AIRPODS_PRO(
|
||||
"AirPods Pro",
|
||||
R.drawable.ic_device_airpods_gen2
|
||||
),
|
||||
@Json(name = "airpods.max") AIRPODS_MAX(
|
||||
"AirPods Max",
|
||||
R.drawable.ic_device_generic_headphones
|
||||
),
|
||||
@Json(name = "beats.flex") BEATS_FLEX(
|
||||
"Beats Flex"
|
||||
),
|
||||
@Json(name = "beats.solo.3") BEATS_SOLO_3(
|
||||
"Beats Solo 3"
|
||||
),
|
||||
@Json(name = "beats.studio.3") BEATS_STUDIO_3(
|
||||
"Beats Studio 3"
|
||||
),
|
||||
@Json(name = "beats.x") BEATS_X(
|
||||
"Beats X"
|
||||
),
|
||||
@Json(name = "beats.powerbeats.3") POWERBEATS_3(
|
||||
"Power Beats 3"
|
||||
),
|
||||
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
|
||||
"Power Beats Pro"
|
||||
),
|
||||
@Json(name = "fakes.tws.i99999") TWS_I99999(
|
||||
"TWS i99999"
|
||||
),
|
||||
@Json(name = "fakes.varunr.airpodspro") VARUNR_AIRPODS_PRO(
|
||||
"Fake AirPods Pro"
|
||||
),
|
||||
@Json(name = "unknown") UNKNOWN(
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BASE_CONFIDENCE = 0.5f
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
import android.content.Context
|
||||
import android.icu.text.RelativeDateTimeFormatter
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun DualPodDevice.getBatteryLevelLeftPod(context: Context): String =
|
||||
batteryLeftPodPercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun DualPodDevice.getBatteryLevelRightPod(context: Context): String =
|
||||
batteryRightPodPercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun HasCase.getBatteryLevelCase(context: Context): String =
|
||||
batteryCasePercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun SinglePodDevice.getBatteryLevelHeadset(context: Context): String =
|
||||
batteryHeadsetPercent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
fun PodDevice.getSignalQuality(context: Context): String {
|
||||
val percentage = 100 * signalQuality
|
||||
return "~${percentage.roundToInt()}%"
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
||||
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
||||
percent > 0.50f -> R.drawable.ic_baseline_battery_4_bar_24
|
||||
percent > 0.35f -> R.drawable.ic_baseline_battery_3_bar_24
|
||||
percent > 0.20f -> R.drawable.ic_baseline_battery_2_bar_24
|
||||
percent > 0.05f -> R.drawable.ic_baseline_battery_1_bar_24
|
||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||
}
|
||||
|
||||
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
|
||||
|
||||
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||
val duration = Duration.between(seenLastAt, now)
|
||||
return lastSeenFormatter.format(
|
||||
duration.seconds.toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.SECONDS
|
||||
)
|
||||
}
|
||||
|
||||
fun PodDevice.firstSeenFormatted(now: Instant): String {
|
||||
val duration = Duration.between(seenFirstAt, now)
|
||||
return lastSeenFormatter.format(
|
||||
duration.toMinutes().toDouble(),
|
||||
RelativeDateTimeFormatter.Direction.LAST,
|
||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.apple.AppleFactory
|
||||
import eu.darken.capod.pods.core.unknown.UnknownDeviceFactory
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class PodFactory @Inject constructor(
|
||||
private val appleFactory: AppleFactory,
|
||||
private val unknownFactory: UnknownDeviceFactory,
|
||||
) {
|
||||
|
||||
suspend fun createPod(scanResult: BleScanResult): PodDevice? {
|
||||
log(TAG, VERBOSE) { "Trying to create Pod for $scanResult" }
|
||||
|
||||
log(TAG, INFO) { "Decoding $scanResult" }
|
||||
|
||||
var device = appleFactory.create(scanResult)
|
||||
|
||||
if (device == null) {
|
||||
log(TAG, VERBOSE) { "Using fallback factory" }
|
||||
device = unknownFactory.create(scanResult)
|
||||
}
|
||||
|
||||
log(TAG, INFO) { "Pod created: $device" }
|
||||
return device
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Pod", "Factory")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package eu.darken.capod.pods.core
|
||||
|
||||
interface SinglePodDevice : PodDevice {
|
||||
|
||||
val batteryHeadsetPercent: Float?
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppleFactory @Inject constructor(
|
||||
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
|
||||
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
||||
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
|
||||
private val unknownAppleFactory: UnknownAppleDevice.Factory,
|
||||
) {
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
private fun getMessage(scanResult: BleScanResult): ProximityPairing.Message? {
|
||||
val messages = try {
|
||||
continuityProtocolDecoder.decode(scanResult)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Data wasn't continuity protocol conform:\n${e.asLog()}" }
|
||||
return null
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
log(TAG, WARN) { "Data contained no continuity messages: $scanResult" }
|
||||
return null
|
||||
}
|
||||
|
||||
if (messages.size > 1) {
|
||||
log(TAG, WARN) { "Decoded multiple continuity messages, picking first: $messages" }
|
||||
}
|
||||
|
||||
val proximityMessage = proximityPairingDecoder.decode(messages.first())
|
||||
if (proximityMessage == null) {
|
||||
log(TAG) { "Not a proximity pairing message: $messages" }
|
||||
return null
|
||||
}
|
||||
|
||||
return proximityMessage
|
||||
}
|
||||
|
||||
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
|
||||
val pm = getMessage(scanResult) ?: return@withLock null
|
||||
|
||||
val factory = podFactories.firstOrNull { it.isResponsible(pm) }
|
||||
|
||||
return@withLock (factory ?: unknownAppleFactory).create(
|
||||
scanResult = scanResult,
|
||||
message = pm,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Pod", "Apple", "Factory")
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.pods.core.apple.airpods.*
|
||||
import eu.darken.capod.pods.core.apple.beats.*
|
||||
import eu.darken.capod.pods.core.apple.misc.Twsi99999
|
||||
import eu.darken.capod.pods.core.apple.misc.VarunrAirPodsPro
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
abstract class AppleFactoryModule {
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
|
||||
|
||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
|
||||
|
||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||
|
||||
@Binds @IntoSet abstract fun fakesTwsi999999(factory: Twsi99999.Factory): ApplePodsFactory<out ApplePods>
|
||||
@Binds @IntoSet
|
||||
abstract fun fakesVarunrAirPodsPro(factory: VarunrAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.toHex
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
|
||||
interface ApplePods : PodDevice {
|
||||
|
||||
val proximityMessage: ProximityPairing.Message
|
||||
|
||||
// We start counting at the airpods prefix byte
|
||||
val rawPrefix: UByte
|
||||
get() = proximityMessage.data[0]
|
||||
|
||||
val rawDeviceModel: UShort
|
||||
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
|
||||
|
||||
val rawStatus: UByte
|
||||
get() = proximityMessage.data[3]
|
||||
|
||||
val rawStatusHex: String
|
||||
get() = rawStatus.toHex()
|
||||
|
||||
val rawPodsBattery: UByte
|
||||
get() = proximityMessage.data[4]
|
||||
|
||||
val rawPodsBatteryHex: String
|
||||
get() = rawPodsBattery.toHex()
|
||||
|
||||
val rawFlags: UShort
|
||||
get() = proximityMessage.data[5].upperNibble
|
||||
|
||||
val rawCaseBattery: UShort
|
||||
get() = proximityMessage.data[5].lowerNibble
|
||||
|
||||
val rawCaseLidState: UByte
|
||||
get() = proximityMessage.data[6]
|
||||
|
||||
val rawDeviceColor: UByte
|
||||
get() = proximityMessage.data[7]
|
||||
|
||||
val rawSuffix: UByte
|
||||
get() = proximityMessage.data[8]
|
||||
|
||||
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
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.HasCase
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
|
||||
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,
|
||||
)
|
||||
|
||||
private fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
|
||||
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
|
||||
length = ProximityPairing.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>()
|
||||
|
||||
|
||||
fun KnownDevice.getLatestCaseBattery(): Float? = history
|
||||
.filterIsInstance<HasCase>()
|
||||
.mapNotNull { it.batteryCasePercent }
|
||||
.lastOrNull()
|
||||
|
||||
fun KnownDevice.getLatestCaseLidState(basic: DualAirPods): DualAirPods.LidState? {
|
||||
val definitive = setOf(DualAirPods.LidState.OPEN, DualAirPods.LidState.CLOSED)
|
||||
if (definitive.contains(basic.caseLidState)) return null
|
||||
|
||||
return history
|
||||
.filterIsInstance<AirPodsPro>()
|
||||
.lastOrNull { it.caseLidState != DualAirPods.LidState.NOT_IN_CASE }
|
||||
?.caseLidState
|
||||
}
|
||||
|
||||
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(message: ProximityPairing.Message): Boolean
|
||||
|
||||
abstract fun create(
|
||||
scanResult: BleScanResult,
|
||||
message: ProximityPairing.Message,
|
||||
): ApplePods
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.*
|
||||
import eu.darken.capod.pods.core.DualPodDevice.Pod
|
||||
|
||||
interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
|
||||
HasStateDetection, HasDualMicrophone, HasAppleColor {
|
||||
|
||||
val primaryPod: Pod
|
||||
get() = when (rawStatus.isBitSet(5)) {
|
||||
true -> Pod.LEFT
|
||||
false -> Pod.RIGHT
|
||||
}
|
||||
|
||||
/**
|
||||
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
val isOnePodInCase: Boolean
|
||||
get() = rawStatus.isBitSet(4)
|
||||
|
||||
val areBothPodsInCase: Boolean
|
||||
get() = rawStatus.isBitSet(2)
|
||||
|
||||
override val isLeftPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(3)
|
||||
false -> rawStatus.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodInEar: Boolean
|
||||
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||
true -> rawStatus.isBitSet(1)
|
||||
false -> rawStatus.isBitSet(3)
|
||||
}
|
||||
|
||||
/**
|
||||
* The data flip bit is set if the left pod is primary.
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isLeftPodMicrophone: Boolean
|
||||
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
/**
|
||||
* The data flip bit is UNset if the right pod is primary.
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isRightPodMicrophone: Boolean
|
||||
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
override val isLeftPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(0)
|
||||
true -> rawFlags.isBitSet(1)
|
||||
}
|
||||
|
||||
override val isRightPodCharging: Boolean
|
||||
get() = when (areValuesFlipped) {
|
||||
false -> rawFlags.isBitSet(1)
|
||||
true -> rawFlags.isBitSet(0)
|
||||
}
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
|
||||
val caseLidState: LidState
|
||||
get() {
|
||||
val rawstate = rawCaseLidState
|
||||
return LidState.values().firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO this is glitchy
|
||||
* The counters generally increase if quickly and repeatedly:
|
||||
* - open/close
|
||||
* - add/remove the last airpod to the case
|
||||
* They reset after some time to their start values.
|
||||
* The upper limits are not the maximums but are only reached if playing with the case.
|
||||
*/
|
||||
enum class LidState(val rawRange: IntRange) {
|
||||
OPEN(0x30..0x37),
|
||||
CLOSED(0x38..0x3F),
|
||||
NOT_IN_CASE(0x00..0x03),
|
||||
UNKNOWN(0xFF..0xFF);
|
||||
}
|
||||
|
||||
override val state: ConnectionState
|
||||
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
|
||||
|
||||
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
|
||||
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
|
||||
IDLE(0x04, R.string.pods_connection_state_idle_label),
|
||||
MUSIC(0x05, R.string.pods_connection_state_music_label),
|
||||
CALL(0x06, R.string.pods_connection_state_call_label),
|
||||
RINGING(0x07, R.string.pods_connection_state_ringing_label),
|
||||
HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label),
|
||||
UNKNOWN(null, R.string.pods_connection_state_unknown_label);
|
||||
|
||||
override fun getLabel(context: Context): String = context.getString(labelRes)
|
||||
|
||||
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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
|
||||
|
||||
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualAirPods>(tag) {
|
||||
|
||||
fun DualAirPods.getCaseMatchMarkings() = SplitPodsMarkings(
|
||||
leftPodBattery = batteryLeftPodPercent,
|
||||
rightPodBattery = batteryRightPodPercent,
|
||||
microPhoneLeft = isLeftPodMicrophone,
|
||||
microPhoneRight = isRightPodMicrophone,
|
||||
chargingLeft = isLeftPodCharging,
|
||||
chargingRight = isRightPodCharging,
|
||||
color = rawDeviceColor,
|
||||
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: UByte,
|
||||
val model: PodDevice.Model,
|
||||
)
|
||||
|
||||
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualAirPods): Collection<KnownDevice> {
|
||||
val target = device.getCaseMatchMarkings()
|
||||
|
||||
return filter { known ->
|
||||
known.history
|
||||
.filterIsInstance<DualAirPods>()
|
||||
.any { it.getCaseMatchMarkings() == target }
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchHistory(current: DualAirPods): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import android.content.Context
|
||||
import eu.darken.capod.pods.core.HasPodStyle
|
||||
|
||||
interface HasAppleColor : ApplePods, HasPodStyle {
|
||||
|
||||
override val podStyle: HasPodStyle.PodStyle
|
||||
get() = DeviceColor.values()
|
||||
.firstOrNull { it.raw == rawDeviceColor }
|
||||
?: DeviceColor.UNKNOWN
|
||||
|
||||
enum class DeviceColor(val raw: UByte?) : HasPodStyle.PodStyle {
|
||||
WHITE(0x00),
|
||||
BLACK(0x01),
|
||||
RED(0x02),
|
||||
BLUE(0x03),
|
||||
PINK(0x04),
|
||||
GRAY(0x05),
|
||||
SILVER(0x06),
|
||||
GOLD(0x07),
|
||||
ROSE_GOLD(0x08),
|
||||
SPACE_GRAY(0x09),
|
||||
DARK_BLUE(0x0a),
|
||||
LIGHT_BLUE(0x0b),
|
||||
YELLOW(0x0c),
|
||||
UNKNOWN(null);
|
||||
|
||||
override fun getLabel(context: Context): String = this.name
|
||||
|
||||
override fun getColor(context: Context): Int = android.R.color.white
|
||||
|
||||
override val identifier: String
|
||||
get() = name
|
||||
|
||||
constructor(raw: Int) : this(raw.toUByte())
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
|
||||
/**
|
||||
* Devices that only present a single charge level, e.g. most Beats devices
|
||||
*/
|
||||
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
|
||||
|
||||
override val batteryHeadsetPercent: Float?
|
||||
get() = when (val value = rawPodsBattery.lowerNibble.toInt()) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Headset above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
|
||||
@@ -1,69 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualAirPods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen1 constructor(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualAirPods.LidState? = null
|
||||
) : DualAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: DualAirPods.LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0220.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen1")
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualAirPods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen2 constructor(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualAirPods.LidState? = null
|
||||
) : DualAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: DualAirPods.LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0F20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen2")
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualAirPods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsGen3 constructor(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualAirPods.LidState? = null
|
||||
) : DualAirPods {
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: DualAirPods.LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x1320.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen3")
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsMax(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods, HasEarDetection {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
val isHeadphonesBeingWorn: Boolean
|
||||
get() = rawStatus.isBitSet(1)
|
||||
|
||||
val isHeadsetBeingCharged: Boolean
|
||||
get() = rawFlags.isBitSet(0)
|
||||
|
||||
override val isBeingWorn: Boolean
|
||||
get() = isHeadphonesBeingWorn
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsMax(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE_DIRTY = 10.toUByte()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Max")
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualAirPods
|
||||
import eu.darken.capod.pods.core.apple.DualAirPods.LidState
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AirPodsPro(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: LidState? = null
|
||||
) : DualAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
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(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0e20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BeatsFlex(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsFlex(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x1020.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Beats", "Flex")
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BeatsSolo3(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0620.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Beats", "Solo", "3")
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BeatsStudio3(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE_DIRTY = 9.toUByte()
|
||||
private val TAG = logTag("PodDevice", "Beats", "Studio", "3")
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BeatsX(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_X
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = BeatsX(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0520.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Beats", "X")
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PowerBeats3(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : SingleApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = PowerBeats3(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0320.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "3")
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualAirPods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PowerBeatsPro(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
private val cachedCaseState: DualAirPods.LidState? = null
|
||||
) : DualAirPods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||
|
||||
override val caseLidState: DualAirPods.LidState
|
||||
get() = cachedCaseState ?: super.caseLidState
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE_DIRTY = 11.toUByte()
|
||||
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "Pro")
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Basically an AirPods GEN1 clone
|
||||
* Similar data structure but a lot of placeholder values or hardcoded values
|
||||
*/
|
||||
data class Twsi99999 constructor(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.TWS_I99999
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<ApplePods>.rssi
|
||||
|
||||
/**
|
||||
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
/**
|
||||
* The data flip bit is set if the left pod is primary.
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isLeftPodMicrophone: Boolean
|
||||
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
/**
|
||||
* The data flip bit is UNset if the right pod is primary.
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isRightPodMicrophone: Boolean
|
||||
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> cachedBatteryPercentage
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<Twsi99999>(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
|
||||
getModelInfo().full == DEVICE_CODE && length == 19
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = Twsi99999(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0220.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "TWS", "i99999")
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import android.content.Context
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
data class UnknownAppleDevice(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = 0f,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : ApplePods {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
|
||||
|
||||
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = true
|
||||
|
||||
override fun create(
|
||||
scanResult: BleScanResult,
|
||||
message: ProximityPairing.Message,
|
||||
): ApplePods {
|
||||
var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("PodDevice", "Apple", "Unknown")
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.common.lowerNibble
|
||||
import eu.darken.capod.common.upperNibble
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasDualMicrophone
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ApplePodsFactory
|
||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* AirPods Pro clone similar to Twsi999999.
|
||||
* Shorter data structure.
|
||||
*/
|
||||
data class VarunrAirPodsPro constructor(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val proximityMessage: ProximityPairing.Message,
|
||||
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||
private val rssiAverage: Int? = null,
|
||||
private val cachedBatteryPercentage: Float? = null,
|
||||
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.VARUNR_AIRPODS_PRO
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super<ApplePods>.rssi
|
||||
|
||||
/**
|
||||
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||
* If the right pod is the primary, the values are flipped.
|
||||
*/
|
||||
val areValuesFlipped: Boolean
|
||||
get() = !rawStatus.isBitSet(5)
|
||||
|
||||
override val batteryLeftPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.upperNibble.toInt()
|
||||
false -> rawPodsBattery.lowerNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Left pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
(value / 10f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val batteryRightPodPercent: Float?
|
||||
get() {
|
||||
val value = when (areValuesFlipped) {
|
||||
true -> rawPodsBattery.lowerNibble.toInt()
|
||||
false -> rawPodsBattery.upperNibble.toInt()
|
||||
}
|
||||
return when (value) {
|
||||
15 -> null
|
||||
else -> if (value > 10) {
|
||||
log { "Right pod: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isThisPodInThecase: Boolean
|
||||
get() = rawStatus.isBitSet(6)
|
||||
|
||||
/**
|
||||
* The data flip bit is set if the left pod is primary.
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isLeftPodMicrophone: Boolean
|
||||
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
/**
|
||||
* The data flip bit is UNset if the right pod is primary.
|
||||
* For the pod that is in the case, this is flipped again though.
|
||||
*/
|
||||
override val isRightPodMicrophone: Boolean
|
||||
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||
|
||||
override val batteryCasePercent: Float?
|
||||
get() = when (val value = rawCaseBattery.toInt()) {
|
||||
15 -> cachedBatteryPercentage
|
||||
else -> if (value > 10) {
|
||||
log { "Case: Above 100% battery: $value" }
|
||||
1.0f
|
||||
} else {
|
||||
value / 10f
|
||||
}
|
||||
}
|
||||
|
||||
override val isCaseCharging: Boolean
|
||||
get() = rawFlags.isBitSet(2)
|
||||
|
||||
class Factory @Inject constructor() : ApplePodsFactory<VarunrAirPodsPro>(TAG) {
|
||||
|
||||
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
|
||||
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
|
||||
getModelInfo().full == DEVICE_CODE && length == 19
|
||||
}
|
||||
|
||||
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
|
||||
var basic = VarunrAirPodsPro(scanResult = scanResult, proximityMessage = message)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEVICE_CODE = 0x0E20.toUShort()
|
||||
private val TAG = logTag("PodDevice", "Apple", "Varunr", "AirPodsPro")
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol
|
||||
|
||||
import android.os.ParcelUuid
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
|
||||
object ContinuityProtocol {
|
||||
|
||||
data class Message(
|
||||
val type: UByte,
|
||||
val length: Int,
|
||||
val data: UByteArray
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Message) return false
|
||||
|
||||
if (!data.contentEquals(other.data)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = data.contentHashCode()
|
||||
}
|
||||
|
||||
@Reusable
|
||||
class Decoder @Inject constructor() {
|
||||
fun decode(scanResult: BleScanResult): List<Message> = scanResult
|
||||
.getManufacturerSpecificData(APPLE_COMPANY_IDENTIFIER)
|
||||
?.let { data ->
|
||||
val messages = mutableListOf<Message>()
|
||||
|
||||
var remainingData = data.asUByteArray()
|
||||
while (remainingData.size >= 2) {
|
||||
val dataLength = remainingData[1].toInt()
|
||||
val dataStart = 2
|
||||
val dataEnd = dataStart + dataLength
|
||||
Message(
|
||||
type = remainingData[0],
|
||||
length = dataLength,
|
||||
data = remainingData.copyOfRange(dataStart, dataEnd)
|
||||
).run { messages.add(this) }
|
||||
|
||||
remainingData = remainingData.copyOfRange(dataEnd, remainingData.size)
|
||||
}
|
||||
if (remainingData.isNotEmpty()) {
|
||||
log(TAG, WARN) { "Data contained malformed protocol message $remainingData" }
|
||||
}
|
||||
messages.toList()
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
const val APPLE_COMPANY_IDENTIFIER = 0x004C
|
||||
|
||||
// Continuity protocol data is in these vendor specific data sets
|
||||
val BLE_FEATURE_UUIDS = setOf(
|
||||
ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"),
|
||||
ParcelUuid.fromString("2a72e02b-7b99-778f-014d-ad0b7221ec74")
|
||||
)
|
||||
|
||||
val TAG = logTag("ContinuityProtocol", "Decoder")
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol
|
||||
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
object ProximityPairing {
|
||||
|
||||
data class Message(
|
||||
val type: UByte,
|
||||
val length: Int,
|
||||
val data: UByteArray
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
|
||||
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
|
||||
}
|
||||
}
|
||||
|
||||
@Reusable
|
||||
class Decoder @Inject constructor() {
|
||||
fun decode(message: ContinuityProtocol.Message): Message? {
|
||||
if (message.type != CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING) {
|
||||
log { "Not a proximity pairing message: $this" }
|
||||
return null
|
||||
}
|
||||
|
||||
return Message(
|
||||
type = message.type,
|
||||
length = message.length,
|
||||
data = message.data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBleScanFilter(): Set<ScanFilter> {
|
||||
val manufacturerData = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply {
|
||||
this[0] = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING.toByte()
|
||||
this[1] = PAIRING_MESSAGE_LENGTH.toByte()
|
||||
}
|
||||
|
||||
val manufacturerDataMask = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply {
|
||||
this[0] = 1
|
||||
this[1] = 1
|
||||
}
|
||||
val builder = ScanFilter.Builder().apply {
|
||||
setManufacturerData(
|
||||
ContinuityProtocol.APPLE_COMPANY_IDENTIFIER,
|
||||
manufacturerData,
|
||||
manufacturerDataMask
|
||||
)
|
||||
}
|
||||
return setOf(builder.build())
|
||||
}
|
||||
|
||||
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
|
||||
internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
|
||||
|
||||
// This is the default message length among official Apple devices, clones may have different length
|
||||
internal const val PAIRING_MESSAGE_LENGTH = 25
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package eu.darken.capod.pods.core.unknown
|
||||
|
||||
import android.content.Context
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import java.time.Instant
|
||||
|
||||
data class UnknownDevice(
|
||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||
override val seenLastAt: Instant = Instant.now(),
|
||||
override val seenFirstAt: Instant = Instant.now(),
|
||||
override val seenCounter: Int = 1,
|
||||
override val scanResult: BleScanResult,
|
||||
override val confidence: Float = 0f,
|
||||
private val rssiAverage: Int? = null,
|
||||
) : PodDevice {
|
||||
|
||||
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
|
||||
|
||||
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
|
||||
|
||||
override val rssi: Int
|
||||
get() = rssiAverage ?: super.rssi
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("PodDevice", "Unknown")
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package eu.darken.capod.pods.core.unknown
|
||||
|
||||
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.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UnknownDeviceFactory @Inject constructor() {
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
|
||||
var basic = UnknownDevice(
|
||||
identifier = PodDevice.Id(),
|
||||
scanResult = scanResult,
|
||||
)
|
||||
val result = searchHistory(basic)
|
||||
|
||||
if (result != null) basic = basic.copy(identifier = result.id)
|
||||
updateHistory(basic)
|
||||
|
||||
if (result == null) return basic
|
||||
|
||||
return basic.copy(
|
||||
identifier = result.id,
|
||||
seenFirstAt = result.seenFirstAt,
|
||||
seenCounter = result.seenCounter,
|
||||
confidence = result.confidence,
|
||||
rssiAverage = result.averageRssi(basic.rssi),
|
||||
)
|
||||
}
|
||||
|
||||
data class KnownDevice(
|
||||
val id: PodDevice.Id,
|
||||
val seenFirstAt: Instant,
|
||||
val seenCounter: Int,
|
||||
val history: List<PodDevice>
|
||||
) {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
|
||||
|
||||
private fun searchHistory(current: PodDevice): KnownDevice? {
|
||||
val scanResult = current.scanResult
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
val recognizedDevice: KnownDevice? = knownDevices.values
|
||||
.firstOrNull { it.lastAddress == scanResult.address }
|
||||
?.also { log(TAG, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
|
||||
|
||||
if (recognizedDevice == null) {
|
||||
log(TAG) { "searchHistory1: Didn't recognize: $current" }
|
||||
}
|
||||
|
||||
return recognizedDevice
|
||||
}
|
||||
|
||||
private fun updateHistory(device: PodDevice) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Pod", "Unknown", "Factory")
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,6 @@
|
||||
|
||||
<string name="notification_channel_device_status_label">Device status</string>
|
||||
|
||||
<string name="general_error_label">Error</string>
|
||||
<string name="general_share_action">Share</string>
|
||||
<string name="general_done_action">Done</string>
|
||||
<string name="general_copy_action">Copy</string>
|
||||
<string name="general_thank_you_label">Thank you</string>
|
||||
<string name="general_value_not_available_label">N/A</string>
|
||||
<string name="general_grant_permission_action">Grant permission</string>
|
||||
<string name="general_upgrade_action">Upgrade</string>
|
||||
<string name="general_check_action">Check</string>
|
||||
<string name="general_close_action">Close</string>
|
||||
|
||||
<string name="debug_debuglog_size_label">Size</string>
|
||||
<string name="debug_debuglog_size_compressed_label">Compressed size</string>
|
||||
<string name="debug_notification_channel_label">Debug notifications</string>
|
||||
@@ -93,9 +82,7 @@
|
||||
<string name="settings_monitor_mode_manual_label">When app is open</string>
|
||||
<string name="settings_monitor_mode_automatic_label">When device is connected</string>
|
||||
<string name="settings_monitor_mode_always_label">Always</string>
|
||||
<string name="settings_scanner_mode_lowpower_label">Low power</string>
|
||||
<string name="settings_scanner_mode_balanced_label">Balanced</string>
|
||||
<string name="settings_scanner_mode_lowlatency_label">Low latency</string>
|
||||
|
||||
<string name="settings_autopause_label">Auto pause</string>
|
||||
<string name="settings_autopause_description">Pause audio when removing the device from your ear.</string>
|
||||
<string name="settings_showall_label">Show all devices</string>
|
||||
|
||||
Reference in New Issue
Block a user