Compare commits

..
58 Commits
Author SHA1 Message Date
darken b4d9316665 Add translation scalfolding. 2022-01-29 20:26:15 +01:00
darken a234ac0dd2 Add crowdin scripts 2022-01-29 20:23:06 +01:00
darken 3fc18bc40b Improve sorting and heuristics. 2022-01-29 20:00:04 +01:00
darken 0f924ecb82 Track first time a device is seen and display it. 2022-01-29 18:00:13 +01:00
darken 6bd350ce42 Refactor fake data into extra class 2022-01-29 16:41:50 +01:00
darken e1b4ab14e4 Version bump 2022-01-27 23:01:01 +01:00
darken adef0b39e9 Version bump (1.3.0) 2022-01-27 22:49:51 +01:00
darken af12a0e5cb Improved signal quality heuristic and value caching. 2022-01-27 22:45:47 +01:00
darken 90fc2fac24 Improve sorting 2022-01-27 00:46:06 +01:00
darken 3be7d8b43f Improve logging 2022-01-26 18:29:02 +01:00
darken a35d8c57b4 Improve device sorting and main device determination 2022-01-26 18:17:52 +01:00
darken f792431710 Fluff 2022-01-26 18:17:35 +01:00
darken 7ff20acfc0 When bluetooth is disabled, stop emissions. 2022-01-26 14:50:48 +01:00
darken 8e20e0e108 Don't report cancellation exceptions. 2022-01-26 14:33:56 +01:00
darken a6fda04640 Fix handling of Bluetooth enable/disable and restarting the monitor. 2022-01-26 13:11:01 +01:00
darken 0bbcfb5e88 Code fluff 2022-01-26 13:09:15 +01:00
darken e79ba6bfe5 Version bump (1.2.1) 2022-01-26 12:53:13 +01:00
darken 369b0207c4 Version bump (1.2.0) 2022-01-26 12:39:30 +01:00
darken ce737b45b9 Add cooldown mechanism to lid reactions. 2022-01-26 12:28:23 +01:00
darken cde3afdcd9 Average RSSI 2022-01-26 01:02:21 +01:00
darken 07b3f4320e Remove test logging 2022-01-26 00:09:30 +01:00
darken e1c71cf6b6 Improve sorting 2022-01-26 00:08:51 +01:00
darken c233cd2445 Sort main pod up to the top. 2022-01-25 22:56:24 +01:00
darken 8f609ca989 Handle ear detection and microphone detection for dual pods better. 2022-01-25 22:54:53 +01:00
darken 2de2369600 Fix popup window overdraw 2022-01-25 12:06:09 +01:00
darken d5d2ae784c Add popup via system alert window. 2022-01-25 09:59:57 +01:00
darken 56bedb64c4 Only request "access background" when monitor mode is always. 2022-01-24 20:17:01 +01:00
darken 3b48ca90d3 Make low latency the default scanmode for better userexperience. 2022-01-24 15:43:19 +01:00
darken 272ecc006f Better logging of unknown devices. 2022-01-24 15:42:31 +01:00
darken ddacfa6841 Don't report fake device data. 2022-01-24 15:36:10 +01:00
darken c5b1c417a4 Add one-pod mode for reactions 2022-01-24 15:29:05 +01:00
darken 38bc3be5b7 Improve layouts 2022-01-24 15:10:30 +01:00
darken 57cd823b63 Improve notification layouts 2022-01-23 20:52:39 +01:00
darken 6cd799130e Clean up strings 2022-01-23 20:16:29 +01:00
darken a1c58e0a32 Improve single and basic layout. 2022-01-23 20:08:34 +01:00
darken 9cd4cea63d Improve dual pods layout 2022-01-23 19:04:23 +01:00
darken 1d5e13cc24 Improve permission texts, privacy policy link and layouts. 2022-01-21 12:18:59 +01:00
darken 6d2c4ba8ca Improve permission texts. 2022-01-21 00:05:00 +01:00
darken 357baa12e6 Improve permission texts. 2022-01-20 23:21:54 +01:00
darken 909a9ba194 Version bump 2022-01-20 23:07:23 +01:00
darken 79f68c3a0d Add more privacy policy... 2022-01-20 23:07:07 +01:00
darken 5eb97cc966 Version bump (1.1.0) 2022-01-20 17:11:58 +01:00
darken e4f405ec65 Fix unit test workflow 2022-01-20 17:10:34 +01:00
darken f392df05f9 Filter emissions to only react to distinct items. 2022-01-20 17:01:05 +01:00
darken 0f8e441a92 Fix crash on profile service disconnect 2022-01-20 16:07:11 +01:00
darken edbc944724 Reduce logspam 2022-01-20 16:06:58 +01:00
darken 65eb92dcb1 Reduce logspam 2022-01-20 16:06:47 +01:00
darken 7df24f9c76 Fix device cards being shown even if Bluetooth is disabled. 2022-01-20 15:52:12 +01:00
darken 5d72d0116b Fix Bluetooth Profile triggering illegal state exception due to already resumed coroutine when the profile service disconnects. 2022-01-20 15:49:49 +01:00
darken 91a16f55d9 Add translation link to Crowdin and fix capod private repo links. 2022-01-20 15:20:54 +01:00
darken 9ae3516a83 Add fastlane files. 2022-01-20 15:12:19 +01:00
darken f3c20caa13 Add GitHub workflows 2022-01-20 15:12:05 +01:00
darken 680bcbad2a Update internal readme. 2022-01-20 15:11:52 +01:00
darken 5f60f2b5eb Fix lid detection tests 2022-01-20 15:11:37 +01:00
darken 7efe6619d2 Merge remote-tracking branch 'origin/main' into main 2022-01-19 19:40:43 +01:00
Matthias Urhahn 16a880a92d Update README.md 2022-01-19 17:23:46 +01:00
Matthias Urhahn 79db1e2412 Update README.md 2022-01-19 17:20:11 +01:00
Matthias Urhahn b6e8d69eff Update README.md 2022-01-19 16:56:58 +01:00
434 changed files with 4633 additions and 1104 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Android CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew assembleDebug
- name: Run tests
run: ./gradlew testGplayDebugUnitTest testFossDebugUnitTest
@@ -0,0 +1,17 @@
name: "Validate Gradle Wrapper"
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
validation:
name: "Validation"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: gradle/wrapper-validation-action@v1
+19 -12
View File
@@ -1,11 +1,21 @@
<img src="https://github.com/d4rken/capod/raw/main/.assets/banner.png" width="400">
# Companion App for AirPods (CAPod) # Companion App for AirPods (CAPod)
![Build](https://github.com/d4rken/capod/actions/workflows/android.yml/badge.svg) ![Build](https://github.com/d4rken/capod/actions/workflows/android.yml/badge.svg)
[![Github All Releases](https://img.shields.io/github/downloads/d4rken/capod/total.svg)](https://github.com/d4rken/capod/releases/latest) [![Github All Releases](https://img.shields.io/github/downloads/d4rken/capod/total.svg)](https://github.com/d4rken/capod/releases/latest)
A companion app that adds support for AirPod specific features to Android. A companion app that adds support for AirPod specific features to Android:
<img src="https://github.com/d4rken/capod/raw/master/.assets/banner.png" width="400"> * Battery level for pods & cases.
* Charging status for pods & case.
* Additional infos about connection, microphone & case.
* Can receive and show all nearby devices.
* Ear detection with automatic play/pause.
* Automatically connect phone & airpods.
* Show popup when case is opened.
CAPod is ad-free. Some additional features require an in-app purchase.
Currently supported models: Currently supported models:
@@ -20,17 +30,17 @@ Currently supported models:
* Beats X * Beats X
* Beats Flex * Beats Flex
## Support the project
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* Help translate CAPod [on Crowdin](https://crowdin.com/project/airpod-companion)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
## Download ## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) * [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [GitHub](https://github.com/d4rken/capod/releases/latest) * [GitHub](https://github.com/d4rken/capod/releases/latest)
## Support the project
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* Help translate CAPod [on Crowdin](https://crowdin.com/project/capod)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
## Get help ## Get help
* [Github Issues](https://github.com/d4rken/capod/issues) * [Github Issues](https://github.com/d4rken/capod/issues)
@@ -38,10 +48,7 @@ Currently supported models:
## Screenshots ## Screenshots
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/1.png" width="200"> <img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/2.png" width="200">
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/3.png" width="200">
<img src="https://github.com/d4rken/capod/raw/master/.assets/screenshots/4.png" width="200">
## Thanks to ## Thanks to
@@ -109,9 +109,6 @@ class BillingClientConnectionProvider @Inject constructor(
delay(3000 * attempt) delay(3000 * attempt)
true true
} }
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
}
companion object { companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider") val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
@@ -28,6 +28,7 @@ class BillingDataRepo @Inject constructor(
) { ) {
private val connectionProvider = billingClientConnectionProvider.connection private val connectionProvider = billingClientConnectionProvider.connection
.catch { log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } }
.replayingShare(scope) .replayingShare(scope)
val billingData: Flow<BillingData> = connectionProvider val billingData: Flow<BillingData> = connectionProvider
+2
View File
@@ -28,6 +28,8 @@
android:usesPermissionFlags="neverForLocation" /> android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-feature <uses-feature
android:name="android.hardware.bluetooth_le" android:name="android.hardware.bluetooth_le"
android:required="true" /> android:required="true" />
@@ -16,5 +16,6 @@ object BuildConfigWrap {
val VERSION_NAME: String = BuildConfig.VERSION_NAME val VERSION_NAME: String = BuildConfig.VERSION_NAME
val GIT_SHA: String = BuildConfig.GITSHA val GIT_SHA: String = BuildConfig.GITSHA
val VERSION_DESCRIPTION: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE" 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"
} }
@@ -5,10 +5,16 @@ import java.util.*
fun Byte.toHex(): String = String.format("%02X", this) fun Byte.toHex(): String = String.format("%02X", this)
fun UByte.toHex(): String = this.toByte().toHex() fun UByte.toHex(): String = this.toByte().toHex()
val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toByte() val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toShort()
val Byte.lowerNibble get() = (this.toInt() and 0b1111).toByte() val Byte.lowerNibble get() = (this.toInt() and 0b1111).toShort()
val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUByte() val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUShort()
val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUByte() val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUShort()
fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos) fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos)
fun UByte.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(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')
@@ -0,0 +1,5 @@
package eu.darken.capod.common
object PrivacyPolicy {
const val URL = "https://raw.githubusercontent.com/d4rken/capod-public/main/PRIVACY_POLICY.md"
}
@@ -6,8 +6,6 @@ import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.* import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
@@ -27,7 +25,7 @@ import javax.inject.Singleton
class BleScanner @Inject constructor( class BleScanner @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings, private val fakeBleData: FakeBleData,
) { ) {
fun scan( fun scan(
@@ -72,8 +70,8 @@ class BleScanner @Inject constructor(
) )
if (adapter.isOffloadedScanBatchingSupported) { if (adapter.isOffloadedScanBatchingSupported) {
when (scannerMode) { when (scannerMode) {
ScannerMode.LOW_POWER -> setReportDelay(5000) ScannerMode.LOW_POWER -> setReportDelay(2000)
ScannerMode.BALANCED -> setReportDelay(2500) ScannerMode.BALANCED -> setReportDelay(1000)
ScannerMode.LOW_LATENCY -> setReportDelay(500) ScannerMode.LOW_LATENCY -> setReportDelay(500)
} }
} else { } else {
@@ -87,7 +85,7 @@ class BleScanner @Inject constructor(
adapter.bluetoothLeScanner.flushPendingScanResults(callback) adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) { when (scannerMode) {
ScannerMode.LOW_POWER -> break ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2500) ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500) ScannerMode.LOW_LATENCY -> delay(500)
} }
} }
@@ -108,58 +106,7 @@ class BleScanner @Inject constructor(
log(TAG, INFO) { "BleScanner stopped" } log(TAG, INFO) { "BleScanner stopped" }
} }
} }
.map { origs -> .map { fakeBleData.maybeAddfakeData(it) }
val fakeDevices = mutableListOf<BleScanResult>()
if (debugSettings.showFakeData.value) {
// AirPods Pro
BleScanResult(
address = "78:73:AF:B4:85:5E",
rssi = -48,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0E 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run { fakeDevices.add(this) }
// AirPods Gen1
BleScanResult(
address = "4E:9E:D1:49:D2:6D",
rssi = -55,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
).run { fakeDevices.add(this) }
// AirPods Max
BleScanResult(
address = "7E:E5:C7:65:D2:B5",
rssi = -30,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
).run { fakeDevices.add(this) }
// BeatsFlex
BleScanResult(
address = "5E:9E:D1:49:D2:6D",
rssi = -59,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run { fakeDevices.add(this) }
// Unknown Device
BleScanResult(
address = "6E:9E:D1:49:D2:6D",
rssi = -60,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run { fakeDevices.add(this) }
}
origs + fakeDevices
}
fun String.hexToByteArray(): ByteArray {
val trimmed = this
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
companion object { companion object {
private val TAG = logTag("Bluetooth", "BleScanner") private val TAG = logTag("Bluetooth", "BleScanner")
@@ -17,19 +17,12 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
@Singleton @Singleton
class BluetoothManager2 @Inject constructor( class BluetoothManager2 @Inject constructor(
@@ -68,90 +61,96 @@ class BluetoothManager2 @Inject constructor(
awaitClose { context.unregisterReceiver(receiver) } awaitClose { context.unregisterReceiver(receiver) }
} }
suspend fun getBluetoothProfile( fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
profile: Int = BluetoothProfile.HEADSET
): BluetoothProfile2 = withContext(dispatcherProvider.IO) {
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" } log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
suspendCancellableCoroutine { var profileProxy: BluetoothProfile2? = null
val connectionState = AtomicBoolean(false) manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
connectionState.set(true)
BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
isConnectedAtomic = connectionState
).run { it.resume(this) }
}
override fun onServiceDisconnected(profile: Int) { profileProxy = BluetoothProfile2(
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" } profileType = profile,
connectionState.set(false) profileProxy = proxy,
it.cancel(IOException("BluetoothProfile service disconnected (profile=$profile)")) ).also { trySend(it) }
} }
}, profile) 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 connectedDevicesForProfile(profile: Int): Flow<Set<BluetoothDevice>> = callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(getBluetoothProfile(profile).connectedDevices)
val filter = IntentFilter().apply { private fun monitorDevicesForProfile(
addAction(BluetoothDevice.ACTION_ACL_CONNECTED) profile: Int = BluetoothProfile.HEADSET
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) ): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
} callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(bluetoothProfile.connectedDevices)
val handlerThread = HandlerThread("BluetoothEventReceiver").apply { val filter = IntentFilter().apply {
start() addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
} addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
val handler = Handler(handlerThread.looper) }
val receiver: BroadcastReceiver = object : BroadcastReceiver() { val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
override fun onReceive(context: Context, intent: Intent) { start()
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" } }
val action = intent.action val handler = Handler(handlerThread.looper)
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 receiver: BroadcastReceiver = object : BroadcastReceiver() {
val currentDevices = getBluetoothProfile(profile).connectedDevices 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
}
when (action) { this@callbackFlow.launch {
BluetoothDevice.ACTION_ACL_CONNECTED -> { val currentDevices = bluetoothProfile.connectedDevices
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device)) when (action) {
} BluetoothDevice.ACTION_ACL_CONNECTED -> {
BluetoothDevice.ACTION_ACL_DISCONNECTED -> { log(TAG) { "Adding $device to current devices $currentDevices" }
log(TAG) { "Removing $device from current devices $currentDevices" } trySend(currentDevices.plus(device))
trySend(currentDevices.minus(device)) }
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
}
} }
} }
} }
} }
} context.registerReceiver(receiver, filter, null, handler)
context.registerReceiver(receiver, filter, null, handler)
awaitClose { awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." } log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver) context.unregisterReceiver(receiver)
}
} }
} }
fun connectedDevices( fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice>> = isBluetoothEnabled ): Flow<List<BluetoothDevice>> = isBluetoothEnabled
.flatMapLatest { connectedDevicesForProfile(BluetoothProfile.HEADSET) } .flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices -> .map { devices ->
devices.filter { device -> devices.filter { device ->
featureFilter.any { feature -> featureFilter.any { feature ->
@@ -162,21 +161,23 @@ class BluetoothManager2 @Inject constructor(
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = try { suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
log(TAG) { "Nudging Android connection to $device" } try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod( val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java "connect", BluetoothDevice::class.java
).apply { isAccessible = true } ).apply { isAccessible = true }
connectMethod.invoke(getBluetoothProfile().profile, device) connectMethod.invoke(bluetoothProfile.proxy, device)
log(TAG) { "Nudged connection to $device" } log(TAG) { "Nudged connection to $device" }
true true
} catch (e: Exception) { } catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e) Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false false
} }
}.first()
companion object { companion object {
private val TAG = logTag("Bluetooth", "Manager2") private val TAG = logTag("Bluetooth", "Manager2")
@@ -2,23 +2,15 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import java.util.concurrent.atomic.AtomicBoolean
data class BluetoothProfile2( data class BluetoothProfile2(
private val profileType: Int, internal val profileType: Int,
private val profileProxy: BluetoothProfile, private val profileProxy: BluetoothProfile,
private val isConnectedAtomic: AtomicBoolean,
) { ) {
val profile: BluetoothProfile val proxy: BluetoothProfile
get() { get() = profileProxy
if (!isConnected) throw IllegalStateException("Proxy is not connected")
return profileProxy
}
val connectedDevices: Set<BluetoothDevice> val connectedDevices: Set<BluetoothDevice>
get() = profile.connectedDevices.toSet() get() = proxy.connectedDevices.toSet()
val isConnected: Boolean
get() = isConnectedAtomic.get()
} }
@@ -0,0 +1,103 @@
package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import javax.inject.Inject
import kotlin.random.Random
@Reusable
class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
) {
fun maybeAddfakeData(originals: List<BleScanResult>): List<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals
return originals + getFakeData()
}
fun getFakeData(): Collection<BleScanResult> {
val fakeDevices = mutableListOf<BleScanResult>()
// AirPods Gen1
BleScanResult(
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Gen2
BleScanResult(
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Gen3
BleScanResult(
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Max
BleScanResult(
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// BeatsFlex
BleScanResult(
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// Unknown Device
BleScanResult(
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
return fakeDevices
}
private fun String.hexToByteArray(): ByteArray {
val trimmed = this
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}
@@ -58,7 +58,7 @@ class RecorderModule @Inject constructor(
context.startServiceCompat(Intent(context, RecorderService::class.java)) context.startServiceCompat(Intent(context, RecorderService::class.java))
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" } log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" } log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION_LONG}" }
copy( copy(
recorder = newRecorder recorder = newRecorder
@@ -89,7 +89,7 @@ class RecorderActivityVM @Inject constructor(
type = "application/zip" type = "application/zip"
addCategory(Intent.CATEGORY_DEFAULT) addCategory(Intent.CATEGORY_DEFAULT)
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION})") putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION_LONG})")
putExtra(Intent.EXTRA_TEXT, "Your text here.") putExtra(Intent.EXTRA_TEXT, "Your text here.")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
@@ -34,7 +34,7 @@ fun <T : Any> Flow<T>.shareLatest(
) )
.filterNotNull() .filterNotNull()
fun <T : Any> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn( fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
scope = scope, scope = scope,
replay = 1, replay = 1,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO) started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
@@ -62,6 +62,15 @@ enum class Permission(
val pwm = it.getSystemService(Context.POWER_SERVICE) as PowerManager val pwm = it.getSystemService(Context.POWER_SERVICE) as PowerManager
pwm.isIgnoringBatteryOptimizations(BuildConfigWrap.APPLICATION_ID) pwm.isIgnoringBatteryOptimizations(BuildConfigWrap.APPLICATION_ID)
}, },
),
SYSTEM_ALERT_WINDOW(
minApiLevel = Build.VERSION_CODES.BASE,
labelRes = R.string.permission_system_alert_window_label,
descriptionRes = R.string.permission_system_alert_window_description,
permissionId = "android.permission.SYSTEM_ALERT_WINDOW",
isGranted = {
android.provider.Settings.canDrawOverlays(it)
},
) )
} }
@@ -30,7 +30,7 @@ class GeneralSettings @Inject constructor(
val scannerMode = preferences.createFlowPreference( val scannerMode = preferences.createFlowPreference(
"core.scanner.mode", "core.scanner.mode",
ScannerMode.BALANCED, ScannerMode.LOW_LATENCY,
moshi moshi
) )
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isRequired import eu.darken.capod.common.permissions.isRequired
import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -18,6 +19,7 @@ import javax.inject.Singleton
class PermissionTool @Inject constructor( class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
private val reactionSettings: ReactionSettings,
) { ) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID()) private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
@@ -29,9 +31,12 @@ class PermissionTool @Inject constructor(
val missingPermissions: Flow<Set<Permission>> = combine( val missingPermissions: Flow<Set<Permission>> = combine(
permissionCheckTrigger, permissionCheckTrigger,
generalSettings.monitorMode.flow, generalSettings.monitorMode.flow,
) { _, monitorMode -> reactionSettings.showPopUpOnCaseOpen.flow
) { _, monitorMode, showPopUp ->
Permission.values() Permission.values()
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS } .filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
.filter { it.isRequired(context) } .filter { it.isRequired(context) }
.toSet() .toSet()
} }
@@ -33,11 +33,11 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
lateinit var adapter: OverviewAdapter lateinit var adapter: OverviewAdapter
lateinit var permissionLauncher: ActivityResultLauncher<String> lateinit var permissionLauncher: ActivityResultLauncher<String>
var awaitingIgnoreBatteryOptimization = false var awaitingPermission = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
awaitingIgnoreBatteryOptimization = savedInstanceState?.getBoolean("awaitingIgnoreBatteryOptimization") ?: false awaitingPermission = savedInstanceState?.getBoolean("awaitingPermission") ?: false
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
log { "Request for $id was granted=$granted" } log { "Request for $id was granted=$granted" }
@@ -70,23 +70,37 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
} }
} }
vm.listItems.observe2(ui) { adapter.update(it) } vm.listItems.observe2(ui) {
adapter.update(it)
}
vm.workerAutolaunch.observe2 { vm.workerAutolaunch.observe2 {
// While UI is active, subscribe to the autolaunch routine // While UI is active, subscribe to the autolaunch routine
} }
vm.requestPermissionEvent.observe2(ui) { vm.requestPermissionEvent.observe2(ui) {
if (it == Permission.IGNORE_BATTERY_OPTIMIZATION) { when (it) {
awaitingIgnoreBatteryOptimization = true Permission.IGNORE_BATTERY_OPTIMIZATION -> {
startActivity( awaitingPermission = true
Intent( startActivity(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Intent(
Uri.parse("package:${requireContext().packageName}") Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${requireContext().packageName}")
)
) )
) }
} else { Permission.SYSTEM_ALERT_WINDOW -> {
permissionLauncher.launch(it.permissionId) awaitingPermission = true
startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${requireContext().packageName}")
)
)
}
else -> {
permissionLauncher.launch(it.permissionId)
}
} }
} }
@@ -135,15 +149,15 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("awaitingIgnoreBatteryOptimization", awaitingIgnoreBatteryOptimization) outState.putBoolean("awaitingPermission", awaitingPermission)
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (awaitingIgnoreBatteryOptimization) { if (awaitingPermission) {
awaitingIgnoreBatteryOptimization = false awaitingPermission = false
log { "awaitingIgnoreBatteryOptimization=true" } log { "awaitingPermission=true" }
vm.onPermissionResult(true) vm.onPermissionResult(true)
} }
} }
@@ -53,7 +53,7 @@ class OverviewFragmentVM @Inject constructor(
private val updateTicker = channelFlow<Unit> { private val updateTicker = channelFlow<Unit> {
while (isActive) { while (isActive) {
trySend(Unit) trySend(Unit)
delay(1000) delay(3000)
} }
} }
@@ -104,13 +104,30 @@ class OverviewFragmentVM @Inject constructor(
debugSettings.isDebugModeEnabled.flow, debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow, generalSettings.showAll.flow,
bluetoothManager.isBluetoothEnabled, bluetoothManager.isBluetoothEnabled,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled -> podMonitor.mainDevice,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled, mainPod ->
val items = mutableListOf<OverviewAdapter.Item>() val items = mutableListOf<OverviewAdapter.Item>()
val mainPod = podMonitor.mainDevice.first() permissions
val now = Instant.now() .map { perm ->
pods PermissionCardVH.Item(
.map { permission = perm,
onRequest = { requestPermissionEvent.postValue(it) },
)
}
.run { items.addAll(this) }
if (permissions.isEmpty()) {
if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) {
items.add(0, MissingMainDeviceVH.Item)
}
}
if (permissions.isEmpty() && isBluetoothEnabled) {
pods.map {
val now = Instant.now()
when (it) { when (it) {
is DualApplePods -> DualApplePodsCardVH.Item( is DualApplePods -> DualApplePodsCardVH.Item(
now = now, now = now,
@@ -137,24 +154,7 @@ class OverviewFragmentVM @Inject constructor(
isMainPod = it == mainPod, isMainPod = it == mainPod,
) )
} }
} }.run { items.addAll(this) }
.run { items.addAll(this) }
permissions
.map { perm ->
PermissionCardVH.Item(
permission = perm,
onRequest = { requestPermissionEvent.postValue(it) }
)
}
.run { items.addAll(this) }
if (permissions.isEmpty()) {
if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) {
items.add(0, MissingMainDeviceVH.Item)
}
} }
items items
@@ -1,7 +1,10 @@
package eu.darken.capod.main.ui.overview.cards package eu.darken.capod.main.ui.overview.cards
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.ViewGroup import android.view.ViewGroup
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.lists.binding import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
@@ -25,6 +28,12 @@ class PermissionCardVH(parent: ViewGroup) :
permissionLabel.setText(item.permission.labelRes) permissionLabel.setText(item.permission.labelRes)
permissionDescription.setText(item.permission.descriptionRes) permissionDescription.setText(item.permission.descriptionRes)
grantAction.setOnClickListener { item.onRequest(item.permission) } grantAction.setOnClickListener { item.onRequest(item.permission) }
privacyPolicy.apply {
movementMethod = LinkMovementMethod.getInstance()
val ppText = getString(R.string.settings_privacy_policy_label)
val ppLink = PrivacyPolicy.URL
text = Html.fromHtml("<html><a href=\"$ppLink\">$ppText</a></html>", 0)
}
} }
data class Item( data class Item(
@@ -7,8 +7,8 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleSingleBasicItemBinding import eu.darken.capod.databinding.OverviewPodsAppleSingleBasicItemBinding
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant import java.time.Instant
@@ -33,16 +33,10 @@ class BasicSingleApplePodsCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
headphones.apply { batteryLabel.text = device.getBatteryLevelHeadset(context)
val sb = StringBuilder(context.getString(R.string.pods_single_headphones_label)) batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
sb.append("\n").append(device.getBatteryLevelHeadset(context))
text = sb
}
status.apply { status.apply {
val sb = StringBuilder() val sb = StringBuilder()
@@ -2,14 +2,16 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface import android.graphics.Typeface
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.lists.binding import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.HasDualPods.Pod
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor
import eu.darken.capod.pods.core.apple.DualApplePods.LidState import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import java.time.Duration
import java.time.Instant import java.time.Instant
class DualApplePodsCardVH(parent: ViewGroup) : class DualApplePodsCardVH(parent: ViewGroup) :
@@ -24,61 +26,71 @@ class DualApplePodsCardVH(parent: ViewGroup) :
val device = item.device val device = item.device
name.apply { name.apply {
val sb = StringBuilder(device.getLabel(context)) val sb = StringBuilder(device.getLabel(context))
if (device.deviceColor != DeviceColor.UNKNOWN) { if (!listOf(DeviceColor.WHITE, DeviceColor.UNKNOWN).contains(device.deviceColor)) {
sb.append(" (${device.getDeviceColorLabel(context)})") sb.append(" (${device.deviceColor.name})")
} }
text = sb text = sb
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD) if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL) else setTypeface(typeface, Typeface.NORMAL)
if (item.showDebug) {
append(" [${device.primaryPod.name}]")
}
} }
deviceIcon.setImageResource(device.iconRes) deviceIcon.setImageResource(device.iconRes)
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
reception.text = device.getSignalQuality(context) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})") // Left Pod
device.apply {
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
podLeftChargingIcon.isInvisible = !isLeftPodCharging
podLeftChargingLabel.isInvisible = !isLeftPodCharging
podLeftMicrophoneIcon.isInvisible = !isLeftPodMicrophone
podLeftMicrophoneLabel.isInvisible = !isLeftPodMicrophone
podLeftWearIcon.isInvisible = !isLeftPodInEar
podLeftWearLabel.isInvisible = !isLeftPodInEar
} }
podLeft.apply { // Right Pod
val sb = StringBuilder(context.getString(R.string.pods_dual_left_label)) device.apply {
sb.append("\n").append(device.getBatteryLevelLeftPod(context)) podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
when { podRightBatteryLabel.text = getBatteryLevelRightPod(context)
device.isLeftPodCharging -> sb.append("\n").append(getString(R.string.pods_charging_label))
device.isLeftPodInEar -> sb.append("\n").append(getString(R.string.pods_inear_label)) podRightChargingIcon.isInvisible = !isRightPodCharging
else -> {} podRightChargingLabel.isInvisible = !isRightPodCharging
podRightMicrophoneIcon.isInvisible = !isRightPodMicrophone
podRightMicrophoneLabel.isInvisible = !isRightPodMicrophone
podRightWearIcon.isInvisible = !isRightPodInEar
podRightWearLabel.isInvisible = !isRightPodInEar
}
// Case
device.apply {
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
podCaseChargingIcon.isInvisible = !isCaseCharging
podCaseChargingLabel.isInvisible = !isCaseCharging
podCaseLidLabel.text = when (caseLidState) {
LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
else -> context.getString(R.string.general_value_unknown_label)
} }
text = sb
}
podRight.apply { val hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
val sb = StringBuilder(getString(R.string.pods_dual_right_label)) podCaseLidIcon.isInvisible = hideInfo
sb.append("\n").append(device.getBatteryLevelRightPod(context)) podCaseLidLabel.isInvisible = hideInfo
when {
device.isRightPodCharging -> sb.append("\n").append(getString(R.string.pods_charging_label))
device.isRightPodInEar -> sb.append("\n").append(getString(R.string.pods_inear_label))
else -> {}
}
text = sb
}
when (device.microPhonePod) {
Pod.LEFT -> podLeft.append("\n(${context.getString(R.string.pods_microphone_label)})")
Pod.RIGHT -> podRight.append("\n(${context.getString(R.string.pods_microphone_label)})")
}
podCase.apply {
val sb = StringBuilder(getString(R.string.pods_case_label))
sb.append("\n").append(device.getBatteryLevelCase(context))
if (device.isCaseCharging) sb.append("\n").append(getString(R.string.pods_charging_label))
when (device.caseLidState) {
LidState.OPEN -> sb.append("\n").append(context.getString(R.string.pods_case_status_open_label))
LidState.CLOSED -> sb.append("\n").append(context.getString(R.string.pods_case_status_closed_label))
LidState.NOT_IN_CASE,
LidState.UNKNOWN -> {
}
}
text = sb
} }
status.apply { status.apply {
@@ -3,11 +3,13 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import eu.darken.capod.R
import eu.darken.capod.common.lists.BindableVH import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.differ.DifferItem import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.modular.ModularAdapter import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.main.ui.overview.OverviewAdapter import eu.darken.capod.main.ui.overview.OverviewAdapter
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.getSignalQuality
import java.time.Instant import java.time.Instant
abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>( abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
@@ -15,6 +17,10 @@ abstract class PodDeviceVH<D : PodDeviceVH.Item, B : ViewBinding>(
parent: ViewGroup parent: ViewGroup
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B> { ) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B> {
fun Item.getReceptionText(): String = device.getSignalQuality(context)
.let { if (showDebug) "$it ${device.seenCounter}" else it }
.let { if (isMainPod) "$it\n(${getString(R.string.pods_yours)})" else it }
interface Item : OverviewAdapter.Item { interface Item : OverviewAdapter.Item {
val now: Instant val now: Instant
@@ -3,12 +3,13 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface import android.graphics.Typeface
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.lists.binding import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleSingleItemBinding import eu.darken.capod.databinding.OverviewPodsAppleSingleItemBinding
import eu.darken.capod.pods.core.apple.SingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant import java.time.Instant
@@ -33,21 +34,16 @@ class SingleApplePodsCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
headphones.apply { batteryLabel.text = device.getBatteryLevelHeadset(context)
val sb = StringBuilder(context.getString(R.string.pods_single_headphones_label)) batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
sb.append("\n").append(device.getBatteryLevelHeadset(context))
when { chargingIcon.isInvisible = device.isHeadsetBeingCharged
device.isHeadsetBeingCharged -> sb.append("\n").append("Charging") chargingLabel.isInvisible = device.isHeadsetBeingCharged
device.isHeadphonesBeingWorn -> sb.append("\n").append("In ear")
else -> {} wearIcon.isInvisible = device.isHeadphonesBeingWorn
} wearLabel.isInvisible = device.isHeadphonesBeingWorn
text = sb
}
status.apply { status.apply {
val sb = StringBuilder() val sb = StringBuilder()
@@ -6,7 +6,6 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant import java.time.Instant
@@ -30,10 +29,7 @@ class UnknownPodDeviceCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context) reception.text = item.getReceptionText()
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
}
rawdata.text = device.rawDataHex rawdata.text = device.rawDataHex
} }
@@ -68,7 +68,7 @@ class SettingsFragment : Fragment2(R.layout.settings_fragment),
} }
ui.toolbar.apply { ui.toolbar.apply {
subtitle = BuildConfigWrap.VERSION_DESCRIPTION subtitle = BuildConfigWrap.VERSION_DESCRIPTION_SHORT
setNavigationOnClickListener { requireActivity().onBackPressed() } setNavigationOnClickListener { requireActivity().onBackPressed() }
} }
@@ -7,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.androidstarter.common.preferences.Settings import eu.darken.androidstarter.common.preferences.Settings
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.uix.PreferenceFragment2 import eu.darken.capod.common.uix.PreferenceFragment2
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
@@ -34,7 +35,11 @@ class SettingsIndexFragment : PreferenceFragment2() {
} }
override fun onPreferencesCreated() { override fun onPreferencesCreated() {
findPreference<Preference>("core.changelog")!!.summary = BuildConfigWrap.VERSION_DESCRIPTION findPreference<Preference>("core.changelog")!!.summary = BuildConfigWrap.VERSION_DESCRIPTION_LONG
findPreference<Preference>("core.privacy")!!.setOnPreferenceClickListener {
webpageTool.open(PrivacyPolicy.URL)
true
}
super.onPreferencesCreated() super.onPreferencesCreated()
} }
@@ -29,7 +29,7 @@ class SupportFragmentVM @Inject constructor(
bodyInfo.append("--- Infos for the developer ---\n") bodyInfo.append("--- Infos for the developer ---\n")
bodyInfo.append("App version: ").append(BuildConfigWrap.VERSION_DESCRIPTION).append("\n") bodyInfo.append("App version: ").append(BuildConfigWrap.VERSION_DESCRIPTION_LONG).append("\n")
bodyInfo.append("Device: ").append(Build.FINGERPRINT).append("\n") bodyInfo.append("Device: ").append(Build.FINGERPRINT).append("\n")
bodyInfo.append("Install ID: ").append(installId.id).append("\n") bodyInfo.append("Install ID: ").append(installId.id).append("\n")
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
@@ -9,9 +10,11 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.ScannerMode
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory import eu.darken.capod.pods.core.PodFactory
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -32,36 +35,47 @@ class PodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>() private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex() private val cacheLock = Mutex()
private suspend fun List<BleScanResult>.preFilterAndMap(
scannerMode: ScannerMode
): List<PodDevice> = this
.groupBy { it.address }
.values
.map { sameAdrDevs ->
// For each address we only want the newest result, upstream may batch data
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest
}
.mapNotNull { podFactory.createPod(it) }
val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled
.flatMapLatest { isBluetoothEnabled -> .flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) { if (isBluetoothEnabled) {
log(TAG) { "Bluetooth is enabled" }
generalSettings.scannerMode.flow generalSettings.scannerMode.flow
.flatMapLatest { mode ->
bleScanner.scan(scannerMode = mode).map { it.preFilterAndMap(mode) }
}
} else { } else {
log(TAG, WARN) { "Bluetooth is currently disabled" } log(TAG, WARN) { "Bluetooth is currently disabled" }
emptyFlow() flowOf(null)
} }
} }
.flatMapLatest { bleScanner.scan(scannerMode = it) } .map { newPods ->
.map { result ->
// For each address we only want the newest result, upstream may batch data
result.groupBy { it.address }
.values
.map { sameAdrDevs ->
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
log(TAG, VERBOSE) { "Discarding stale results: ${sameAdrDevs.minus(newest)}" }
newest
}
}
.map { scanResults ->
val newPods = scanResults
.mapNotNull { podFactory.createPod(it) }
val pods = mutableMapOf<PodDevice.Id, PodDevice>() val pods = mutableMapOf<PodDevice.Id, PodDevice>()
cacheLock.withLock { cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
return@map emptyList()
}
val now = Instant.now() val now = Instant.now()
deviceCache.toList().forEach { (key, value) -> deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.lastSeenAt, now) > Duration.ofSeconds(20)) { if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
log(TAG, VERBOSE) { "Removing stale device from cache: $value" } log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
deviceCache.remove(key) deviceCache.remove(key)
} }
@@ -75,45 +89,55 @@ class PodMonitor @Inject constructor(
} }
} }
val presorted = pods.values.sortPodsToInterest()
val main = presorted.determineMainDevice()
val aboveThreshold = mutableListOf<PodDevice>() presorted.sortedByDescending { it == main }
val belowThreshold = mutableListOf<PodDevice>()
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
pods.values.forEach { device ->
if (device.signalQuality > minimumSignalQuality) aboveThreshold.add(device)
else belowThreshold.add(device)
}
val now = Instant.now()
aboveThreshold.sortedWith(compareBy<PodDevice> {
Duration.between(
it.lastSeenAt,
now
)
}.thenByDescending { it.rssi })
.plus(belowThreshold.sortedWith(compareBy<PodDevice> {
Duration.between(
it.lastSeenAt,
now
)
}.thenByDescending { it.rssi }))
// pods.values.sortedByDescending { it.rssi }
} }
.onStart { emit(emptyList()) } .onStart { emit(emptyList()) }
.catch { .retryWhen { cause, attempt ->
log(TAG, ERROR) { "PodMonitor failed:\n${it.asLog()}" } log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
} }
.replayingShare(appScope) .replayingShare(appScope)
val mainDevice: Flow<PodDevice?> val mainDevice: Flow<PodDevice?>
get() = devices.map { devices -> get() = devices
devices.maxByOrNull { it.rssi }?.let filter@{ device -> .map { it.determineMainDevice() }
val minimumSignalQuality = generalSettings.minimumSignalQuality.value .replayingShare(appScope)
generalSettings.mainDeviceModel.value.let { private fun Collection<PodDevice>.sortPodsToInterest(): List<PodDevice> = this.let { devices ->
if (device.model != it && it != PodDevice.Model.UNKNOWN) return@filter null val now = Instant.now()
return@let devices.sortedWith(
compareByDescending<PodDevice> { true }
.thenBy {
val age = Duration.between(it.seenLastAt, now).seconds
if (age < 5) 0L else (age / 3L).toLong()
} }
.thenByDescending { it.signalQuality }
.thenByDescending { (it.seenCounter / 10) }
if (device.signalQuality > minimumSignalQuality) device else null )
}
private fun List<PodDevice>.determineMainDevice(): PodDevice? = this
.sortPodsToInterest()
.let { devices ->
val mainDeviceModel = generalSettings.mainDeviceModel.value
val presorted = devices.sortedByDescending {
it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN
}
return@let presorted.firstOrNull()?.let { candidate ->
when {
candidate.model == PodDevice.Model.UNKNOWN -> null
mainDeviceModel != PodDevice.Model.UNKNOWN && candidate.model != mainDeviceModel -> null
candidate.signalQuality <= generalSettings.minimumSignalQuality.value -> null
else -> candidate
}
} }
} }
@@ -0,0 +1,9 @@
package eu.darken.capod.monitor.core
import dagger.Reusable
import javax.inject.Inject
@Reusable
class PodSorter @Inject constructor(
)
@@ -21,6 +21,7 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.* import eu.darken.capod.monitor.core.*
import eu.darken.capod.monitor.ui.MonitorNotifications import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.reaction.ReactionHub import eu.darken.capod.reaction.ReactionHub
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -68,9 +69,13 @@ class MonitorWorker @AssistedInject constructor(
Result.success(inputData) Result.success(inputData)
} catch (e: Throwable) { } catch (e: Throwable) {
Bugs.report(tag = TAG, "Execution failed", exception = e) if (e !is CancellationException) {
finishedWithError = true Bugs.report(tag = TAG, "Execution failed", exception = e)
Result.failure(inputData) finishedWithError = true
Result.failure(inputData)
} else {
Result.success()
}
} finally { } finally {
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).") this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
} }
@@ -1,6 +1,7 @@
package eu.darken.capod.monitor.ui package eu.darken.capod.monitor.ui
import android.content.Context import android.content.Context
import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R import eu.darken.capod.R
@@ -26,38 +27,45 @@ class MonitorNotificationViewFactory @Inject constructor(
context.packageName, context.packageName,
R.layout.monitor_notification_dual_pods_small R.layout.monitor_notification_dual_pods_small
).apply { ).apply {
device.getBatteryLevelLeftPod(context) device.apply {
.let { if (device.isLeftPodCharging) "$it" else it } // Left
.run { setTextViewText(R.id.pod_left, this) } setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context))
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
device.getBatteryLevelCase(context) // Case
.let { if (device.isCaseCharging) "$it" else it } setTextViewText(R.id.pod_case_label, getBatteryLevelCase(context))
.run { setTextViewText(R.id.pod_case, this) } setViewVisibility(R.id.pod_case_charging, if (isCaseCharging) View.VISIBLE else View.GONE)
device.getBatteryLevelRightPod(context) // Right
.let { if (device.isRightPodCharging) "$it" else it } setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
.run { setTextViewText(R.id.pod_right, this) } setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
} }
private fun createSingleApplePods(device: SingleApplePods): RemoteViews = RemoteViews( private fun createSingleApplePods(device: SingleApplePods): RemoteViews = RemoteViews(
context.packageName, context.packageName,
R.layout.monitor_notification_single_pods_small R.layout.monitor_notification_single_pods_small
).apply { ).apply {
setTextViewText(R.id.headphones_label, device.getLabel(context)) device.apply {
setTextViewText(R.id.headphones_label, getLabel(context))
device.getBatteryLevelHeadset(context) setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
.let { if (!device.isHeadsetBeingCharged) "$it" else it } setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
.run { setTextViewText(R.id.headphones, this) } setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
setViewVisibility(R.id.headphones_worn, if (isHeadphonesBeingWorn) View.VISIBLE else View.GONE)
}
} }
private fun createSingleBasicApplePods(device: BasicSingleApplePods): RemoteViews = RemoteViews( private fun createSingleBasicApplePods(device: BasicSingleApplePods): RemoteViews = RemoteViews(
context.packageName, context.packageName,
R.layout.monitor_notification_single_pods_basic_small R.layout.monitor_notification_single_pods_basic_small
).apply { ).apply {
setTextViewText(R.id.headphones_label, device.getLabel(context)) device.apply {
setTextViewText(R.id.headphones_label, getLabel(context))
device.getBatteryLevelHeadset(context) setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
.run { setTextViewText(R.id.headphones, this) } setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
}
} }
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews( private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
@@ -0,0 +1,15 @@
package eu.darken.capod.pods.core
interface HasDualEarDetection : HasEarDetection {
val isLeftPodInEar: Boolean
val isRightPodInEar: Boolean
val isEitherPodInEar: Boolean
get() = isLeftPodInEar || isRightPodInEar
override val isBeingWorn: Boolean
get() = isLeftPodInEar && isRightPodInEar
}
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
interface PodDevice { interface PodDevice {
@@ -16,19 +17,28 @@ interface PodDevice {
val model: Model val model: Model
val lastSeenAt: Instant val address: String
get() = scanResult.address
val seenLastAt: Instant
val seenFirstAt: Instant
val seenCounter: Int
val scanResult: BleScanResult val scanResult: BleScanResult
val rssi: Int val rssi: Int
get() = scanResult.rssi get() = scanResult.rssi
val confidence: Float
/** /**
* This is not correct but it works ¯\_(ツ)_/¯ * This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs). * The range of the RSSI is device specific (ROMs).
*/ */
val signalQuality: Float val signalQuality: Float
get() = (100 - abs(rssi)) / 100f get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
val rawData: ByteArray val rawData: ByteArray
@@ -91,4 +101,8 @@ interface PodDevice {
"Unknown" "Unknown"
); );
} }
companion object {
const val BASE_CONFIDENCE = 0.5f
}
} }
@@ -2,6 +2,7 @@ package eu.darken.capod.pods.core
import android.content.Context import android.content.Context
import android.icu.text.RelativeDateTimeFormatter import android.icu.text.RelativeDateTimeFormatter
import androidx.annotation.DrawableRes
import eu.darken.capod.R import eu.darken.capod.R
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -28,13 +29,35 @@ fun PodDevice.getSignalQuality(context: Context): String {
return "~${percentage.roundToInt()}%" 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() private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
fun PodDevice.lastSeenFormatted(now: Instant): String { fun PodDevice.lastSeenFormatted(now: Instant): String {
val duration = Duration.between(lastSeenAt, now) val duration = Duration.between(seenLastAt, now)
return lastSeenFormatter.format( return lastSeenFormatter.format(
duration.seconds.toDouble(), duration.seconds.toDouble(),
RelativeDateTimeFormatter.Direction.LAST, RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.SECONDS RelativeDateTimeFormatter.RelativeUnit.SECONDS
) )
}
fun PodDevice.firstSeenFormatted(now: Instant): String {
val duration = Duration.between(seenFirstAt, now)
return lastSeenFormatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
} }
@@ -21,7 +21,7 @@ import javax.inject.Singleton
class AppleFactory @Inject constructor( class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder, private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder, private val proximityPairingDecoder: ProximityPairing.Decoder,
private val podFactories: @JvmSuppressWildcards Set<ApplePods.Factory>, private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val unknownAppleFactory: UnknownAppleDevice.Factory, private val unknownAppleFactory: UnknownAppleDevice.Factory,
) { ) {
@@ -57,24 +57,27 @@ class AppleFactory @Inject constructor(
log(TAG, INFO) { "Decoding $scanResult" } log(TAG, INFO) { "Decoding $scanResult" }
var factory = podFactories.firstOrNull { it.isResponsible(pm) } val factory = podFactories.firstOrNull { it.isResponsible(pm) }
if (factory == null) { val device = (factory ?: unknownAppleFactory).create(
scanResult = scanResult,
proximityMessage = pm,
)
if (factory == null && !SILENCED_PMS.contains(device.rawDeviceModel) && scanResult.address != "6E:9E:D1:49:D2:6D") {
SILENCED_PMS.add(device.rawDeviceModel)
Bugs.report( Bugs.report(
tag = TAG, tag = TAG,
message = "Unknown proximity message type", message = "Unknown proximity message type",
exception = IllegalArgumentException("Unknown ProximityMessage: $pm") exception = IllegalArgumentException("Unknown ProximityMessage: $pm")
) )
factory = unknownAppleFactory
} }
return factory.create( return@withLock device
scanResult = scanResult,
proximityMessage = pm,
)
} }
companion object { companion object {
private val SILENCED_PMS = mutableSetOf<UShort>()
private val TAG = logTag("Pod", "Apple", "Factory") private val TAG = logTag("Pod", "Apple", "Factory")
} }
} }
@@ -12,17 +12,17 @@ import eu.darken.capod.pods.core.apple.beats.*
@Module @Module
abstract class AppleFactoryModule { abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePods.Factory @Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePods.Factory @Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePods.Factory @Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePods.Factory @Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePods.Factory @Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePods.Factory @Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePods.Factory @Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePods.Factory @Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePods.Factory @Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePods.Factory @Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePods.Factory @Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
} }
@@ -1,14 +1,11 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.toHex
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG import eu.darken.capod.common.upperNibble
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
interface ApplePods : PodDevice { interface ApplePods : PodDevice {
@@ -27,11 +24,20 @@ interface ApplePods : PodDevice {
val rawStatus: UByte val rawStatus: UByte
get() = proximityMessage.data[3] get() = proximityMessage.data[3]
val rawStatusHex: String
get() = rawStatus.toHex()
val rawPodsBattery: UByte val rawPodsBattery: UByte
get() = proximityMessage.data[4] get() = proximityMessage.data[4]
val rawCaseBattery: UByte val rawPodsBatteryHex: String
get() = proximityMessage.data[5] get() = rawPodsBattery.toHex()
val rawFlags: UShort
get() = proximityMessage.data[5].upperNibble
val rawCaseBattery: UShort
get() = proximityMessage.data[5].lowerNibble
val rawCaseLidState: UByte val rawCaseLidState: UByte
get() = proximityMessage.data[6] get() = proximityMessage.data[6]
@@ -43,102 +49,4 @@ interface ApplePods : PodDevice {
get() = proximityMessage.data[8] get() = proximityMessage.data[8]
abstract class Factory(private val tag: String) {
internal data class ValueCache(
val caseBatteryPercentage: Float?
)
internal val cachedValues = mutableMapOf<PodDevice.Id, ValueCache>()
internal data class KnownDevice(
val identifier: PodDevice.Id,
val scanResult: BleScanResult,
val message: ProximityPairing.Message,
) {
val address: String
get() = scanResult.address
val rssi: Int
get() = scanResult.rssi
val timestampNanos: Duration
get() = Duration.ofNanos(scanResult.generatedAtNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
}
private val knownDevs = mutableMapOf<PodDevice.Id, KnownDevice>()
internal fun recognizeDevice(scanResult: BleScanResult, message: ProximityPairing.Message): PodDevice.Id {
val address = scanResult.address
var identifier: PodDevice.Id? = null
knownDevs.values.firstOrNull { it.address == address }?.let {
log(tag, VERBOSE) { "recognizeDevice: Recovered previous ID via address: $it" }
knownDevs[it.identifier] = it.copy(
scanResult = scanResult,
message = message,
)
identifier = it.identifier
}
if (identifier == null) {
val currentMarkers = message.getRecogMarkers()
knownDevs.values
.firstOrNull { it.message.getRecogMarkers() == currentMarkers }
?.let {
log(tag, DEBUG) { "recognizeDevice: Close match based similarity markers." }
log(tag, DEBUG) { "recognizeDevice: Old marker: ${it.message.getRecogMarkers()}" }
log(tag, DEBUG) { "recognizeDevice: New marker: $currentMarkers" }
knownDevs[it.identifier] = it.copy(
scanResult = scanResult,
message = message,
)
identifier = it.identifier
}
}
if (identifier == null) {
log(tag, VERBOSE) { "recognizeDevice: Mapping as new device" }
identifier = PodDevice.Id()
}
knownDevs[identifier!!] = KnownDevice(
identifier = identifier!!,
scanResult = scanResult,
message = message
)
knownDevs.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
log(tag, VERBOSE) { "recognizeDevice: Removing stale known device: $knownDevice" }
knownDevs.remove(knownDevice.identifier)
}
}
return identifier!!
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods
}
} }
@@ -0,0 +1,147 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
import java.time.Instant
abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
data class Markings(
val vendor: UByte,
val length: UByte,
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = ProximityPairing.PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
caseBatteryData = data[5].lowerNibble,
deviceColor = data[7]
)
data class KnownDevice(
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>
) {
val lastMessage: ProximityPairing.Message
get() = history.last().proximityMessage
val lastAddress: String
get() = history.last().address
val confidence: Float
get() = 0.75f + (history.size / (MAX_HISTORY * 4f))
fun averageRssi(latest: Int): Int =
history.map { it.rssi }.plus(latest).takeLast(10).median()
fun isOlderThan(age: Duration): Boolean {
val now = Instant.now()
return Duration.between(history.last().seenLastAt, now) > age
}
private fun List<Int>.median(): Int = this.sorted().let {
if (it.size % 2 == 0)
(it[it.size / 2] + it[(it.size - 1) / 2]) / 2
else
it[it.size / 2]
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 10
}
}
internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val message = current.proximityMessage
knownDevices.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
log(tag, VERBOSE) { "searchHistory1: Removing stale known device: $knownDevice" }
knownDevices.remove(knownDevice.id)
}
}
knownDevices.values
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach {
knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY))
}
var recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(tag, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
val currentMarkers = message.getApplePodsMarkings()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastMessage.getApplePodsMarkings() == currentMarkers }
?.also { log(tag) { "searchHistory1: Close match based on similarity: $currentMarkers" } }
}
if (recognizedDevice == null) {
log(tag) { "searchHistory1: Didn't recognize: $message" }
}
return recognizedDevice
}
fun updateHistory(device: PodType) {
val existing = knownDevices[device.identifier]
knownDevices[device.identifier] = when {
existing != null -> {
existing.copy(
seenCounter = existing.seenCounter + 1,
history = existing.history.plus(device)
)
}
else -> {
log(tag) { "searchHistory1: Creating new history for $device" }
KnownDevice(
id = device.identifier,
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = listOf(device)
)
}
}
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods
}
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple
abstract class BasicSingleApplePodsFactory(private val tag: String) : ApplePodsFactory<BasicSingleApplePods>(tag)
@@ -1,7 +1,6 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import android.content.Context import android.content.Context
import android.util.Range
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
@@ -9,23 +8,30 @@ import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.HasCase import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasDualEarDetection
import eu.darken.capod.pods.core.HasDualPods import eu.darken.capod.pods.core.HasDualPods
import eu.darken.capod.pods.core.HasDualPods.Pod import eu.darken.capod.pods.core.HasDualPods.Pod
import eu.darken.capod.pods.core.HasEarDetection
interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase { interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase {
val microPhonePod: Pod val primaryPod: Pod
get() = when (rawStatus.isBitSet(5)) { get() = when (rawStatus.isBitSet(5)) {
true -> Pod.LEFT true -> Pod.LEFT
false -> Pod.RIGHT 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? override val batteryLeftPodPercent: Float?
get() { get() {
val value = when (microPhonePod) { val value = when (areValuesFlipped) {
Pod.LEFT -> rawPodsBattery.lowerNibble.toInt() true -> rawPodsBattery.upperNibble.toInt()
Pod.RIGHT -> rawPodsBattery.upperNibble.toInt() false -> rawPodsBattery.lowerNibble.toInt()
} }
return when (value) { return when (value) {
15 -> null 15 -> null
@@ -40,9 +46,9 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
override val batteryRightPodPercent: Float? override val batteryRightPodPercent: Float?
get() { get() {
val value = when (microPhonePod) { val value = when (areValuesFlipped) {
Pod.LEFT -> rawPodsBattery.upperNibble.toInt() true -> rawPodsBattery.lowerNibble.toInt()
Pod.RIGHT -> rawPodsBattery.lowerNibble.toInt() false -> rawPodsBattery.upperNibble.toInt()
} }
return when (value) { return when (value) {
15 -> null 15 -> null
@@ -55,35 +61,55 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
} }
} }
val isLeftPodInEar: Boolean val isThisPodInThecase: Boolean
get() = when (microPhonePod) { get() = rawStatus.isBitSet(6)
Pod.LEFT -> rawStatus.isBitSet(1)
Pod.RIGHT -> rawStatus.isBitSet(3) 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)
} }
val isRightPodInEar: Boolean override val isRightPodInEar: Boolean
get() = when (microPhonePod) { get() = when (areValuesFlipped xor isThisPodInThecase) {
Pod.LEFT -> rawStatus.isBitSet(3) true -> rawStatus.isBitSet(1)
Pod.RIGHT -> rawStatus.isBitSet(1) false -> rawStatus.isBitSet(3)
} }
override val isBeingWorn: Boolean /**
get() = isLeftPodInEar && isRightPodInEar * 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.
*/
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.
*/
val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
val isLeftPodCharging: Boolean val isLeftPodCharging: Boolean
get() = when (microPhonePod) { get() = when (areValuesFlipped) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0) false -> rawFlags.isBitSet(0)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1) true -> rawFlags.isBitSet(1)
} }
val isRightPodCharging: Boolean val isRightPodCharging: Boolean
get() = when (microPhonePod) { get() = when (areValuesFlipped) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1) false -> rawFlags.isBitSet(1)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0) true -> rawFlags.isBitSet(0)
} }
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) { get() = when (val value = rawCaseBattery.toInt()) {
15 -> null 15 -> null
else -> if (value > 10) { else -> if (value > 10) {
log { "Case: Above 100% battery: $value" } log { "Case: Above 100% battery: $value" }
@@ -94,11 +120,12 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
} }
override val isCaseCharging: Boolean override val isCaseCharging: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(2) get() = rawFlags.isBitSet(2)
val caseLidState: LidState val caseLidState: LidState
get() { get() {
return LidState.values().firstOrNull { it.rawRange.contains(rawCaseLidState) } ?: LidState.UNKNOWN val rawstate = rawCaseLidState
return LidState.values().firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
} }
/** /**
@@ -109,39 +136,34 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
* They reset after some time to their start values. * 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. * The upper limits are not the maximums but are only reached if playing with the case.
*/ */
enum class LidState(val rawRange: Range<UByte>) { enum class LidState(val rawRange: IntRange) {
OPEN(0x30, 0x37), OPEN(0x30..0x37),
CLOSED(0x38, 0x3F), CLOSED(0x38..0x3F),
NOT_IN_CASE(0x00, 0x03), NOT_IN_CASE(0x00..0x03),
UNKNOWN(0xFF, 0xFF); UNKNOWN(0xFF..0xFF);
constructor(vararg raw: Int) : this(Range(raw[0].toUByte(), (raw[1].toUByte())))
} }
val deviceColor: DeviceColor val deviceColor: DeviceColor
get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN
enum class DeviceColor(val raw: UByte?) {
fun getDeviceColorLabel(context: Context): String = context.getString(deviceColor.labelRes) 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);
enum class DeviceColor(val raw: UByte?, @StringRes val labelRes: Int) { constructor(raw: Int) : this(raw.toUByte())
WHITE(0x00, R.string.pods_device_color_white_label),
BLACK(0x01, R.string.pods_device_color_black_label),
RED(0x02, R.string.pods_device_color_red_label),
BLUE(0x03, R.string.pods_device_color_blue_label),
PINK(0x04, R.string.pods_device_color_pink_label),
GRAY(0x05, R.string.pods_device_color_gray_label),
SILVER(0x06, R.string.pods_device_color_silver_label),
GOLD(0x07, R.string.pods_device_color_gold_label),
ROSE_GOLD(0x08, R.string.pods_device_color_rose_gold_label),
SPACE_GRAY(0x09, R.string.pods_device_color_space_gray_label),
DARK_BLUE(0x0a, R.string.pods_device_color_dark_blue_label),
LIGHT_BLUE(0x0b, R.string.pods_device_color_light_blue_label),
YELLOW(0x0c, R.string.pods_device_color_yellow_label),
UNKNOWN(null, R.string.general_value_unknown_label);
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
} }
val connectionState: ConnectionState val connectionState: ConnectionState
@@ -160,4 +182,5 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes) constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
} }
} }
@@ -0,0 +1,84 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = deviceColor,
model = model
)
/**
* Split pods, one in case, one not.
*/
data class SplitPodsMarkings(
val leftPodBattery: Float?,
val rightPodBattery: Float?,
val microPhoneLeft: Boolean,
val microPhoneRight: Boolean,
val chargingLeft: Boolean,
val chargingRight: Boolean,
val color: DualApplePods.DeviceColor,
val model: PodDevice.Model,
)
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings()
return filter { known ->
known.history
.filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target }
}
}
fun KnownDevice.getLatestCaseBattery(): Float? = history
.filterIsInstance<DualApplePods>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>()
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState
}
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
log(tag, DEBUG) { "searchHistory2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
return when (caseIgnored.size) {
0 -> basicResult
1 -> caseIgnored.single()
else -> {
log(tag) { "searchHistory2: More than one result when ignoring case markers." }
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
caseIgnored.minus(oldest).forEach {
log(tag) { "searchHistory2: Removing outlier: $it" }
knownDevices.remove(it.id)
}
oldest
}
}
}
}
@@ -1,7 +1,6 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.isBitSet import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.HasEarDetection import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasSinglePod import eu.darken.capod.pods.core.HasSinglePod
@@ -11,7 +10,7 @@ interface SingleApplePods : BasicSingleApplePods, HasEarDetection, HasSinglePod
get() = rawStatus.isBitSet(1) get() = rawStatus.isBitSet(1)
val isHeadsetBeingCharged: Boolean val isHeadsetBeingCharged: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(0) get() = rawFlags.isBitSet(0)
override val isBeingWorn: Boolean override val isBeingWorn: Boolean
get() = isHeadphonesBeingWorn get() = isHeadphonesBeingWorn
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
@@ -11,28 +11,43 @@ import javax.inject.Inject
data class UnknownAppleDevice( data class UnknownAppleDevice(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = 0f,
private val rssiAverage: Int? = null,
) : ApplePods { ) : ApplePods {
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label) override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
override fun create( override fun create(
scanResult: BleScanResult, scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message, proximityMessage: ProximityPairing.Message,
): ApplePods { ): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return UnknownAppleDevice( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
) )
} }
} }
@@ -5,16 +5,22 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsGen1 constructor( data class AirPodsGen1 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float? override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
@@ -22,26 +28,35 @@ data class AirPodsGen1 constructor(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen1( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
) )
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
} }
} }
@@ -5,16 +5,22 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsGen2 constructor( data class AirPodsGen2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
@@ -22,26 +28,35 @@ data class AirPodsGen2 constructor(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen2( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
) )
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
} }
} }
@@ -5,42 +5,57 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsGen3 constructor( data class AirPodsGen3 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen3( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
) )
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
} }
} }
@@ -5,31 +5,47 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsMax( data class AirPodsMax(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods { ) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = AirPodsMax(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return AirPodsMax( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
) )
} }
@@ -5,16 +5,23 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class AirPodsPro( data class AirPodsPro(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@@ -22,32 +29,40 @@ data class AirPodsPro(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = AirPodsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsPro( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
) )
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
} }
} companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
companion object { private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
private val DEVICE_CODE = 0x0e20.toUShort() }
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
} }
} }
@@ -5,31 +5,47 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsFlex( data class BeatsFlex(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = BeatsFlex(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsFlex( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
) )
} }
@@ -5,31 +5,46 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsSolo3( data class BeatsSolo3(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3 override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePods.Factory(TAG) { class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsSolo3( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
) )
} }
@@ -5,31 +5,44 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsStudio3( data class BeatsStudio3(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3 override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) { class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsStudio3( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
) )
} }
@@ -5,31 +5,47 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class BeatsX( data class BeatsX(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_X override val model: PodDevice.Model = PodDevice.Model.BEATS_X
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = BeatsX(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsX( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
) )
} }
@@ -5,30 +5,46 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class PowerBeats3( data class PowerBeats3(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods { ) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3 override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = PowerBeats3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return PowerBeats3( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
) )
} }
} }
@@ -5,16 +5,22 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
data class PowerBeatsPro( data class PowerBeatsPro(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult, override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
@@ -22,26 +28,35 @@ data class PowerBeatsPro(
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) { override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage) var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = PowerBeatsPro( if (result != null) basic = basic.copy(identifier = result.id)
identifier = identifier, updateHistory(basic)
scanResult = scanResult,
proximityMessage = proximityMessage, if (result == null) return basic
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
) )
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
} }
} }
@@ -3,8 +3,6 @@ package eu.darken.capod.pods.core.apple.protocol
import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanFilter
import dagger.Reusable import dagger.Reusable
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import javax.inject.Inject import javax.inject.Inject
@@ -15,22 +13,11 @@ object ProximityPairing {
val length: Int, val length: Int,
val data: UByteArray val data: UByteArray
) { ) {
data class Markers(
val vendor: UByte,
val length: UByte,
val device: UShort,
val batteryData: Set<UByte>,
val deviceColor: UByte,
)
fun getRecogMarkers(): Markers = Markers( override fun toString(): String {
vendor = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING, val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
length = PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(), return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(), }
// Make comparison order independent
batteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
deviceColor = data[7]
)
} }
@Reusable @Reusable
@@ -74,6 +61,6 @@ object ProximityPairing {
} }
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27 private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
private val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte() internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
private const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25 internal const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
} }
@@ -1,11 +1,14 @@
package eu.darken.capod.reaction.autoconnect package eu.darken.capod.reaction.autoconnect
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.HasDualEarDetection
import eu.darken.capod.pods.core.HasEarDetection import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
@@ -22,29 +25,36 @@ class AutoConnect @Inject constructor(
) { ) {
fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() } .flatMapLatest { isAutoConnectEnabled ->
.filterNotNull() if (isAutoConnectEnabled) {
.distinctUntilChanged() combine(
.map { podDevice -> bluetoothManager.connectedDevices().distinctUntilChanged(),
log(TAG) { "mainPodDevice is $podDevice" } podMonitor.mainDevice.filterNotNull().distinctUntilChangedBy { it.rawDataHex },
) { connectedDevices, mainDevice ->
connectedDevices to mainDevice
}
} else {
emptyFlow()
}
}
.map { (connectedDevices, mainDevice) ->
log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" }
val mainDeviceAddr = generalSettings.mainDeviceAddress.value val mainDeviceAddr = generalSettings.mainDeviceAddress.value
if (mainDeviceAddr.isNullOrEmpty()) { if (mainDeviceAddr.isNullOrEmpty()) {
log(TAG) { "mainDeviceAddress is null" } log(TAG, WARN) { "mainDeviceAddress is null" }
return@map return@map
} else {
log(TAG) { "mainDeviceAddress is $mainDeviceAddr" }
} }
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr } val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
if (bondedDevice == null) { if (bondedDevice == null) {
log(TAG) { "No bonded device matches $mainDeviceAddr" } log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
return@map return@map
} else { } else {
log(TAG) { "Found target device: $bondedDevice" } log(TAG, VERBOSE) { "Found target device: $bondedDevice" }
} }
val isAlreadyConnected = bluetoothManager.connectedDevices().first().any { val isAlreadyConnected = connectedDevices.any {
it.address == bondedDevice.address it.address == bondedDevice.address
} }
@@ -57,12 +67,18 @@ class AutoConnect @Inject constructor(
log(TAG) { "Checking condition $condition" } log(TAG) { "Checking condition $condition" }
val conditionFulfilled = when (condition) { val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (podDevice) { AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualApplePods -> podDevice.caseLidState == DualApplePods.LidState.OPEN is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true else -> true
} }
AutoConnectCondition.IN_EAR -> when (podDevice) { AutoConnectCondition.IN_EAR -> when (mainDevice) {
is HasEarDetection -> podDevice.isBeingWorn is HasEarDetection -> {
if (mainDevice is HasDualEarDetection && reactionSettings.onePodMode.value) {
mainDevice.isEitherPodInEar
} else {
mainDevice.isBeingWorn
}
}
else -> true else -> true
} }
} }
@@ -9,6 +9,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.HasDualEarDetection
import eu.darken.capod.pods.core.HasEarDetection import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -25,7 +26,8 @@ class PlayPause @Inject constructor(
fun monitor() = combine( fun monitor() = combine(
reactionSettings.autoPlay.flow, reactionSettings.autoPlay.flow,
reactionSettings.autoPause.flow, reactionSettings.autoPause.flow,
) { play, pause -> play || pause } reactionSettings.onePodMode.flow,
) { play, pause, _ -> play || pause }
.flatMapLatest { if (it) bluetoothManager.connectedDevices() else emptyFlow() } .flatMapLatest { if (it) bluetoothManager.connectedDevices() else emptyFlow() }
.flatMapLatest { .flatMapLatest {
if (it.isEmpty()) { if (it.isEmpty()) {
@@ -42,7 +44,28 @@ class PlayPause @Inject constructor(
if (previous is HasEarDetection && current is HasEarDetection) { if (previous is HasEarDetection && current is HasEarDetection) {
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" } log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" } log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
if (previous.identifier == current.identifier && previous.isBeingWorn != current.isBeingWorn) { if (previous.identifier != current.identifier) {
return@onEach
}
if (previous is HasDualEarDetection && current is HasDualEarDetection && reactionSettings.onePodMode.value) {
log(TAG) { "Ear status changed for dual pod device in single pod mode." }
val previousWorn = previous.isEitherPodInEar
val currentWorn = current.isEitherPodInEar
if (!previousWorn && currentWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
mediaControl.sendPlay()
} else {
log(TAG) { "autoPlay is disabled" }
}
} else if (!currentWorn && previousWorn && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
}
} else if (previous.isBeingWorn != current.isBeingWorn) {
log(TAG) { "Ear status changed for monitored device." } log(TAG) { "Ear status changed for monitored device." }
if (current.isBeingWorn && !mediaControl.isPlaying) { if (current.isBeingWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) { if (reactionSettings.autoPlay.value) {
@@ -1,7 +1,7 @@
package eu.darken.capod.reaction.popup package eu.darken.capod.reaction.popup
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -9,12 +9,12 @@ import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.DualApplePods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.popup.ui.PopUpNotification import eu.darken.capod.reaction.popup.ui.PopUpWindow
import eu.darken.capod.reaction.settings.ReactionSettings import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.flatMapLatest import java.time.Duration
import kotlinx.coroutines.flow.map import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -22,16 +22,25 @@ import javax.inject.Singleton
class PopUpReaction @Inject constructor( class PopUpReaction @Inject constructor(
private val podMonitor: PodMonitor, private val podMonitor: PodMonitor,
private val reactionSettings: ReactionSettings, private val reactionSettings: ReactionSettings,
private val popUpNotification: PopUpNotification, private val popupWindow: PopUpWindow,
private val dispatcherProvider: DispatcherProvider,
) { ) {
private val coolDowns = mutableMapOf<PodDevice.Id, Instant>()
fun monitor(): Flow<Unit> = reactionSettings.showPopUpOnCaseOpen.flow fun monitor(): Flow<Unit> = reactionSettings.showPopUpOnCaseOpen.flow
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() } .flatMapLatest { isEnabled ->
if (isEnabled) {
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }
} else {
emptyFlow()
}
}
.withPrevious() .withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" } .setupCommonEventHandlers(TAG) { "monitor" }
.map { (previous, current) -> .map { (previous, current) ->
if (previous is DualApplePods? && current is DualApplePods) { if (previous is DualApplePods? && current is DualApplePods) {
log(TAG) { log(TAG, VERBOSE) {
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) } val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) } val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})" "previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
@@ -46,17 +55,48 @@ class PopUpReaction @Inject constructor(
if (isSameDeviceWithCaseNowOpen || isNewDeviceWithJustOpenedCase) { if (isSameDeviceWithCaseNowOpen || isNewDeviceWithJustOpenedCase) {
log(TAG) { "Case lid status changed for monitored device." } log(TAG) { "Case lid status changed for monitored device." }
if (current.caseLidState == DualApplePods.LidState.OPEN && current.model != PodDevice.Model.UNKNOWN) { tryPopWindow(current)
log(TAG, INFO) { "Show popup" }
popUpNotification.showNotification(current)
} else if (current.caseLidState != DualApplePods.LidState.OPEN) {
log(TAG, INFO) { "Hide popup" }
popUpNotification.hideNotification()
}
} }
} }
} }
private suspend fun tryPopWindow(current: DualApplePods) {
if (current.caseLidState == DualApplePods.LidState.OPEN) {
log(TAG, INFO) { "Show popup" }
val now = Instant.now()
val lastShown = coolDowns[current.identifier] ?: Instant.MIN
val sinceLastPop = Duration.between(lastShown, now)
log(TAG) { "Time since last popup: $sinceLastPop" }
if (sinceLastPop < Duration.ofSeconds(10)) {
log(TAG, INFO) { "Popup is still on cooldown: $sinceLastPop" }
return
} else {
coolDowns[current.identifier] = Instant.now()
}
withContext(dispatcherProvider.Main) {
popupWindow.show(current)
}
} else if (current.caseLidState != DualApplePods.LidState.OPEN) {
when (current.caseLidState) {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
coolDowns.remove(current.identifier)
}
else -> {
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
coolDowns[current.identifier] = Instant.now()
}
}
log(TAG, INFO) { "Hide popup" }
withContext(dispatcherProvider.Main) {
popupWindow.close()
}
}
}
companion object { companion object {
private val TAG = logTag("Reaction", "PopUp") private val TAG = logTag("Reaction", "PopUp")
} }
@@ -1,82 +0,0 @@
package eu.darken.capod.reaction.popup.ui
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.PodDevice
import javax.inject.Inject
class PopUpNotification @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManager,
private val notificationViewFactory: PopUpNotificationViewFactory
) {
private val builder: NotificationCompat.Builder
init {
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
context.getString(R.string.notification_channel_reaction_popup_label),
NotificationManager.IMPORTANCE_HIGH
).run {
vibrationPattern = LongArray(1) { 0 }
enableVibration(true)
enableLights(false)
setSound(null, null)
notificationManager.createNotificationChannel(this)
}
val openIntent = Intent(context, MainActivity::class.java)
val openPi = PendingIntent.getActivity(
context,
0,
openIntent,
PendingIntentCompat.FLAG_IMMUTABLE
)
builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID).apply {
setChannelId(NOTIFICATION_CHANNEL_ID)
setContentIntent(openPi)
priority = NotificationCompat.PRIORITY_LOW
setSmallIcon(R.drawable.ic_device_generic_earbuds)
setContentTitle(context.getString(R.string.app_name))
}
}
private fun getBuilder(device: PodDevice): NotificationCompat.Builder = builder.apply {
setStyle(NotificationCompat.DecoratedCustomViewStyle())
setCustomContentView(notificationViewFactory.createContentView(device))
setSmallIcon(device.iconRes)
setSubText(null)
}
fun showNotification(device: PodDevice) {
val notification = getBuilder(device).build()
log(TAG) { "showNotification(): $device" }
notificationManager.notify(NOTIFICATION_ID, notification)
}
fun hideNotification() {
log(TAG) { "hideNotification()" }
notificationManager.cancel(NOTIFICATION_ID)
}
companion object {
val TAG = logTag("Reaction", "PopUp", "Notifications")
private val NOTIFICATION_CHANNEL_ID =
"${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.popup"
internal const val NOTIFICATION_ID = 9000
}
}
@@ -1,65 +0,0 @@
package eu.darken.capod.reaction.popup.ui
import android.content.Context
import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject
class PopUpNotificationViewFactory @Inject constructor(
@ApplicationContext private val context: Context
) {
fun createContentView(device: PodDevice): RemoteViews = when (device) {
is DualApplePods -> createDualApplePods(device)
is SingleApplePods -> createSingleApplePods(device)
is BasicSingleApplePods -> createSingleBasicApplePods(device)
else -> throw IllegalArgumentException("Unexpected device: $device")
}
private fun createDualApplePods(device: DualApplePods): RemoteViews = RemoteViews(
context.packageName,
R.layout.popup_notification_dual_pods
).apply {
setTextViewText(R.id.pod_label, device.getLabel(context))
device.getBatteryLevelLeftPod(context)
.let { if (device.isLeftPodCharging) "$it" else it }
.run { setTextViewText(R.id.pod_left, this) }
device.getBatteryLevelCase(context)
.let { if (device.isCaseCharging) "$it" else it }
.run { setTextViewText(R.id.pod_case, this) }
device.getBatteryLevelRightPod(context)
.let { if (device.isRightPodCharging) "$it" else it }
.run { setTextViewText(R.id.pod_right, this) }
}
private fun createSingleApplePods(device: SingleApplePods): RemoteViews = RemoteViews(
context.packageName,
R.layout.popup_notification_single_pods
).apply {
setTextViewText(R.id.headphones_label, device.getLabel(context))
device.getBatteryLevelHeadset(context)
.let { if (!device.isHeadsetBeingCharged) "$it" else it }
.run { setTextViewText(R.id.headphones, this) }
}
private fun createSingleBasicApplePods(device: BasicSingleApplePods): RemoteViews = RemoteViews(
context.packageName,
R.layout.popup_notification_single_pods_basic
).apply {
setTextViewText(R.id.headphones_label, device.getLabel(context))
device.getBatteryLevelHeadset(context)
.run { setTextViewText(R.id.headphones, this) }
}
}
@@ -0,0 +1,88 @@
package eu.darken.capod.reaction.popup.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.view.isInvisible
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBasicBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject
class PopUpPodViewFactory @Inject constructor(
@ApplicationContext private val appContext: Context,
private val debugSettings: DebugSettings,
) {
private val context = ContextThemeWrapper(appContext, R.style.AppTheme)
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) {
is DualApplePods -> createDualApplePods(parent, device)
is SingleApplePods -> createSingleApplePods(parent, device) // Unused, has no case to trigger reaction?
is BasicSingleApplePods -> createSingleBasicApplePods(
parent,
device
) // Unused, has no case to trigger reaction?
else -> throw IllegalArgumentException("Unexpected device: $device")
}
private fun createDualApplePods(parent: ViewGroup, device: DualApplePods): View =
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
podIcon.setImageResource(iconRes)
podLabel.text = getLabel(context)
signal.text = getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
// Left
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
// Case
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
// Right
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
}
}.root
private fun createSingleApplePods(parent: ViewGroup, device: SingleApplePods): View =
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
headphonesIcon.setImageResource(iconRes)
headphonesLabel.text = getLabel(context)
signal.text = getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
headphonesBatteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
headphonesBatteryLabel.text = getBatteryLevelHeadset(context)
}
}.root
private fun createSingleBasicApplePods(parent: ViewGroup, device: BasicSingleApplePods): View =
PopupNotificationSinglePodsBasicBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
headphonesIcon.setImageResource(iconRes)
headphonesLabel.text = getLabel(context)
signal.text = getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
headphonesBatteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
headphonesBatteryLabel.text = getBatteryLevelHeadset(context)
}
}.root
}
@@ -0,0 +1,75 @@
package eu.darken.capod.reaction.popup.ui
import android.content.Context
import android.content.Context.WINDOW_SERVICE
import android.graphics.PixelFormat
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.appcompat.view.ContextThemeWrapper
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
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 javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PopUpWindow @Inject constructor(
@ApplicationContext private val appContext: Context,
private val podViewFactory: PopUpPodViewFactory
) {
private val context = ContextThemeWrapper(appContext, R.style.AppTheme)
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
private val windowManager: WindowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
private val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, // Display it on top of other application windows
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, // Don't let it grab the input focus
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, // Make the underlying application window visible
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM
}
private val popUpView: View = layoutInflater.inflate(R.layout.popup_window_container_layout, null).apply {
findViewById<View>(R.id.close_action).setOnClickListener { close() }
}
private val deviceContainer = popUpView.findViewById<FrameLayout>(R.id.popup_content)
fun show(device: PodDevice) = try {
log(TAG) { "open()" }
if (popUpView.windowToken != null || popUpView.parent != null) {
log(TAG) { "View already added." }
close()
}
val podView = podViewFactory.createContentView(deviceContainer, device)
deviceContainer.removeAllViews()
deviceContainer.addView(podView)
windowManager.addView(popUpView, layoutParams)
} catch (e: Exception) {
log(TAG, ERROR) { "open() failed: ${e.asLog()}" }
}
fun close() = try {
log(TAG) { "close()" }
if (popUpView.parent != null) {
windowManager.removeView(popUpView)
deviceContainer.removeAllViews()
} else {
log(TAG) { "View was not added" }
}
} catch (e: Exception) {
log(TAG, ERROR) { "close() failed: ${e.asLog()}" }
}
companion object {
private val TAG = logTag("Reaction", "PopUp", "Window")
}
}
@@ -47,11 +47,17 @@ class ReactionSettings @Inject constructor(
false false
) )
val onePodMode = preferences.createFlowPreference(
"reaction.onepod.enabled",
false
)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
autoPause, autoPause,
autoPlay, autoPlay,
autoConnect, autoConnect,
autoConnectCondition, autoConnectCondition,
showPopUpOnCaseOpen, showPopUpOnCaseOpen,
onePodMode,
) )
} }
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v14h6V6z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v12h6V6z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v10h6V6z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v8h6V6z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v6h6V6z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v4h6V6z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v2h6V6z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M15.67,4H14V2h-4v2H8.33C7.6,4 7,4.6 7,5.33v15.33C7,21.4 7.6,22 8.33,22h7.33c0.74,0 1.34,-0.6 1.34,-1.33V5.33C17,4.6 16.4,4 15.67,4z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M15.67,4L14,4L14,2h-4v2L8.33,4C7.6,4 7,4.6 7,5.33v15.33C7,21.4 7.6,22 8.33,22h7.33c0.74,0 1.34,-0.6 1.34,-1.33L17,5.33C17,4.6 16.4,4 15.67,4zM12.95,17.95h-1.9v-1.9h1.9v1.9zM14.3,12.69s-0.38,0.42 -0.67,0.71c-0.48,0.48 -0.83,1.15 -0.83,1.6h-1.6c0,-0.83 0.46,-1.52 0.93,-2l0.93,-0.94c0.27,-0.27 0.44,-0.65 0.44,-1.06 0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5L9,11c0,-1.66 1.34,-3 3,-3s3,1.34 3,3c0,0.66 -0.27,1.26 -0.7,1.69z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,3v8h8L11,3L3,3zM9,9L5,9L5,5h4v4zM3,13v8h8v-8L3,13zM9,19L5,19v-4h4v4zM13,3v8h8L21,3h-8zM19,9h-4L15,5h4v4zM13,13v8h8v-8h-8zM19,19h-4v-4h4v4z"
android:fillType="evenOdd" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,20c-0.29,0 -0.56,-0.06 -0.76,-0.15 -0.71,-0.37 -1.21,-0.88 -1.71,-2.38 -0.51,-1.56 -1.47,-2.29 -2.39,-3 -0.79,-0.61 -1.61,-1.24 -2.32,-2.53C9.29,10.98 9,9.93 9,9c0,-2.8 2.2,-5 5,-5s5,2.2 5,5h2c0,-3.93 -3.07,-7 -7,-7S7,5.07 7,9c0,1.26 0.38,2.65 1.07,3.9 0.91,1.65 1.98,2.48 2.85,3.15 0.81,0.62 1.39,1.07 1.71,2.05 0.6,1.82 1.37,2.84 2.73,3.55 0.51,0.23 1.07,0.35 1.64,0.35 2.21,0 4,-1.79 4,-4h-2c0,1.1 -0.9,2 -2,2zM7.64,2.64L6.22,1.22C4.23,3.21 3,5.96 3,9s1.23,5.79 3.22,7.78l1.41,-1.41C6.01,13.74 5,11.49 5,9s1.01,-4.74 2.64,-6.36zM11.5,9c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5 -1.12,-2.5 -2.5,-2.5 -2.5,1.12 -2.5,2.5z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6.03,3.2C7.15,2.44 8.51,2 10,2c3.93,0 7,3.07 7,7c0,1.26 -0.38,2.65 -1.07,3.9c-0.02,0.04 -0.05,0.08 -0.08,0.13l-1.48,-1.48C14.77,10.69 15,9.8 15,9c0,-2.8 -2.2,-5 -5,-5C9.08,4 8.24,4.26 7.5,4.67L6.03,3.2zM17.21,14.38l1.43,1.43C20.11,13.93 21,11.57 21,9c0,-3.04 -1.23,-5.79 -3.22,-7.78l-1.42,1.42C17.99,4.26 19,6.51 19,9C19,11.02 18.33,12.88 17.21,14.38zM10,6.5c-0.21,0 -0.4,0.03 -0.59,0.08l3.01,3.01C12.47,9.4 12.5,9.21 12.5,9C12.5,7.62 11.38,6.5 10,6.5zM21.19,21.19L2.81,2.81L1.39,4.22l2.13,2.13C3.19,7.16 3,8.05 3,9h2c0,-0.36 0.05,-0.71 0.12,-1.05l6.61,6.61c-0.88,0.68 -1.78,1.41 -2.27,2.9c-0.5,1.5 -1,2.01 -1.71,2.38C7.56,19.94 7.29,20 7,20c-1.1,0 -2,-0.9 -2,-2H3c0,2.21 1.79,4 4,4c0.57,0 1.13,-0.12 1.64,-0.35c1.36,-0.71 2.13,-1.73 2.73,-3.55c0.32,-0.98 0.9,-1.43 1.71,-2.05c0.03,-0.02 0.05,-0.04 0.08,-0.06l6.62,6.62L21.19,21.19z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,15c1.66,0 2.99,-1.34 2.99,-3L15,6c0,-1.66 -1.34,-3 -3,-3S9,4.34 9,6v6c0,1.66 1.34,3 3,3zM17.3,12c0,3 -2.54,5.1 -5.3,5.1S6.7,15 6.7,12L5,12c0,3.42 2.72,6.23 6,6.72L11,22h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14,17h-2L12,9h-2L10,7h4v10z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M16.01,7L16,3h-2v4h-4V3H8v4h-0.01C7,6.99 6,7.99 6,8.99v5.49L9.5,18v3h5v-3l3.5,-3.51v-5.5c0,-1 -1,-2 -1.99,-1.99z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,5c-3.87,0 -7,3.13 -7,7h2c0,-2.76 2.24,-5 5,-5s5,2.24 5,5h2c0,-3.87 -3.13,-7 -7,-7zM13,14.29c0.88,-0.39 1.5,-1.26 1.5,-2.29 0,-1.38 -1.12,-2.5 -2.5,-2.5S9.5,10.62 9.5,12c0,1.02 0.62,1.9 1.5,2.29v3.3L7.59,21 9,22.41l3,-3 3,3L16.41,21 13,17.59v-3.3zM12,1C5.93,1 1,5.93 1,12h2c0,-4.97 4.03,-9 9,-9s9,4.03 9,9h2c0,-6.07 -4.93,-11 -11,-11z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03c1.74,-1.94 2.98,-4.17 3.71,-6.53L17,6L17,4h-7L10,2L8,2v2L1,4v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
</vector>
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:layout_marginHorizontal="8dp" android:layout_marginHorizontal="8dp"
android:orientation="horizontal"> android:orientation="horizontal">
@@ -11,43 +11,97 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<TextView <LinearLayout
android:id="@+id/pod_left"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:orientation="horizontal">
android:drawableStart="@drawable/ic_airpod_left_24"
tools:text="100%" /> <ImageView
android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_left_24" />
<TextView
android:id="@+id/pod_left_label"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="100%" />
<ImageView
android:id="@+id/pod_left_charging"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
android:id="@+id/pod_left_ear"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
<FrameLayout <FrameLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<TextView <LinearLayout
android:id="@+id/pod_case"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:orientation="horizontal">
android:layout_marginHorizontal="8dp"
android:drawableStart="@drawable/ic_airpod_case_24" <ImageView
tools:text="100%" /> android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_case_24" />
<TextView
android:id="@+id/pod_case_label"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="100%" />
<ImageView
android:id="@+id/pod_case_charging"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
<FrameLayout <FrameLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<TextView <LinearLayout
android:id="@+id/pod_right"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:orientation="horizontal">
android:drawableStart="@drawable/ic_airpod_right_24"
tools:text="100%" /> <ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_right_24" />
<TextView
android:id="@+id/pod_right_label"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="100%" />
<ImageView
android:id="@+id/pod_right_charging"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
android:id="@+id/pod_right_ear"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
<FrameLayout <FrameLayout
android:layout_width="0dp" android:layout_width="0dp"
@@ -2,7 +2,8 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:gravity="center_horizontal">
<TextView <TextView
android:id="@+id/headphones_label" android:id="@+id/headphones_label"
@@ -10,16 +11,27 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
tools:text="AirPods Max" /> tools:text="Beats Solo 3" />
<TextView <LinearLayout
android:id="@+id/headphones"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="8dp"
android:drawableStart="@drawable/ic_device_generic_headphones" android:orientation="horizontal">
android:gravity="center"
tools:text="100%" /> <ImageView
android:id="@+id/headphones_battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24" />
<TextView
android:id="@+id/headphones_battery_label"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
tools:text="100%" />
</LinearLayout>
</LinearLayout> </LinearLayout>
@@ -2,7 +2,8 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:gravity="center_horizontal">
<TextView <TextView
android:id="@+id/headphones_label" android:id="@+id/headphones_label"
@@ -12,14 +13,35 @@
android:gravity="center" android:gravity="center"
tools:text="AirPods Max" /> tools:text="AirPods Max" />
<TextView <LinearLayout
android:id="@+id/headphones"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="8dp"
android:drawableStart="@drawable/ic_device_generic_headphones" android:orientation="horizontal">
android:gravity="center"
tools:text="100%" /> <ImageView
android:id="@+id/headphones_battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24" />
<TextView
android:id="@+id/headphones_battery_label"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
tools:text="100%" />
<ImageView
android:id="@+id/headphones_charging"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
android:id="@+id/headphones_worn"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
</LinearLayout> </LinearLayout>
@@ -49,6 +49,18 @@
app:layout_constraintTop_toBottomOf="@id/permission_label" app:layout_constraintTop_toBottomOf="@id/permission_label"
tools:text="Lorem Ipsum" /> tools:text="Lorem Ipsum" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/privacy_policy"
style="@style/TextAppearance.MaterialComponents.Tooltip"
android:layout_width="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginTop="4dp"
android:text="@string/settings_privacy_policy_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/grant_action" android:id="@+id/grant_action"
style="@style/Widget.MaterialComponents.Button" style="@style/Widget.MaterialComponents.Button"
@@ -60,7 +72,7 @@
android:text="@string/general_grant_permission_action" android:text="@string/general_grant_permission_action"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" /> app:layout_constraintTop_toBottomOf="@id/privacy_policy" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -45,12 +45,24 @@
style="@style/TextAppearance.MaterialComponents.Caption" style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/first_seen"
app:layout_constraintEnd_toEndOf="@id/name"
app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintTop_toBottomOf="@id/name"
app:layout_goneMarginBottom="8dp"
tools:text="Last seen: 3s ago" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/first_seen"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/barrier_top" app:layout_constraintBottom_toTopOf="@id/barrier_top"
app:layout_constraintEnd_toEndOf="@id/name" app:layout_constraintEnd_toEndOf="@id/name"
app:layout_constraintStart_toStartOf="@id/name" app:layout_constraintStart_toStartOf="@id/name"
app:layout_constraintTop_toBottomOf="@id/name" app:layout_constraintTop_toBottomOf="@id/last_seen"
tools:text="3s ago" /> tools:text="First seen: 3s ago" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/reception" android:id="@+id/reception"
@@ -79,7 +91,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception" app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24" app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" /> tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
@@ -87,61 +99,334 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:barrierDirection="top" app:barrierDirection="top"
app:constraint_referenced_ids="pod_left,pod_case,pod_right" /> app:constraint_referenced_ids="pod_left_container,pod_case_container,pod_right_container" />
<com.google.android.material.textview.MaterialTextView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/pod_left" android:id="@+id/pod_left_container"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginEnd="8dp" android:layout_marginEnd="4dp"
android:gravity="center_horizontal" android:minHeight="108dp"
app:drawableTopCompat="@drawable/ic_airpod_left_24"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom" app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toStartOf="@id/pod_case" app:layout_constraintEnd_toStartOf="@id/pod_case_container"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top" app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0" app:layout_constraintVertical_bias="0.0">
tools:text="Left pod\n100%\n(Primary)" />
<com.google.android.material.textview.MaterialTextView <ImageView
android:id="@+id/pod_case" android:id="@+id/pod_left_icon"
style="@style/TextAppearance.MaterialComponents.Body2" style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_left_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_left_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_left_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/pods_dual_left_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_left_icon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/pod_left_battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_left_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_left_battery_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/general_value_unknown_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_left_battery_icon"
app:layout_constraintTop_toBottomOf="@id/pod_left_label" />
<ImageView
android:id="@+id/pod_left_charging_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_charging_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_left_charging_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_left_charging_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_charging_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_left_charging_icon"
app:layout_constraintTop_toBottomOf="@id/pod_left_battery_label" />
<ImageView
android:id="@+id/pod_left_microphone_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_keyboard_voice_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_microphone_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_left_microphone_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_left_microphone_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_microphone_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_left_microphone_icon"
app:layout_constraintTop_toBottomOf="@id/pod_left_charging_label" />
<ImageView
android:id="@+id/pod_left_wear_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_hearing_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_wear_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_left_wear_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_left_wear_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_inear_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_left_wear_icon"
app:layout_constraintTop_toBottomOf="@id/pod_left_microphone_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/pod_case_container"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp" android:layout_marginHorizontal="4dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_airpod_case_24"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom" app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toStartOf="@id/pod_right" app:layout_constraintEnd_toStartOf="@id/pod_right_container"
app:layout_constraintStart_toEndOf="@id/pod_left" app:layout_constraintStart_toEndOf="@id/pod_left_container"
app:layout_constraintTop_toBottomOf="@id/barrier_top" app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0" app:layout_constraintVertical_bias="0.0">
tools:text="Case\n50%" />
<com.google.android.material.textview.MaterialTextView <ImageView
android:id="@+id/pod_right" android:id="@+id/pod_case_icon"
style="@style/TextAppearance.MaterialComponents.Body2" style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_case_24"
app:layout_constraintBottom_toBottomOf="@id/pod_case_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_case_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_case_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/pods_case_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case_icon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/pod_case_battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_case_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_case_battery_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/general_value_unknown_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case_battery_icon"
app:layout_constraintTop_toBottomOf="@id/pod_case_label" />
<ImageView
android:id="@+id/pod_case_charging_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24"
app:layout_constraintBottom_toBottomOf="@id/pod_case_charging_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_case_charging_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_case_charging_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_charging_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case_charging_icon"
app:layout_constraintTop_toBottomOf="@id/pod_case_battery_label" />
<ImageView
android:id="@+id/pod_case_lid_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_grid_view_24"
app:layout_constraintBottom_toBottomOf="@id/pod_case_lid_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_case_lid_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_case_lid_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_case_status_closed_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case_lid_icon"
app:layout_constraintTop_toBottomOf="@id/pod_case_charging_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/pod_right_container"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="4dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_airpod_right_24"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom" app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case" app:layout_constraintStart_toEndOf="@id/pod_case_container"
app:layout_constraintTop_toBottomOf="@id/barrier_top" app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0" app:layout_constraintVertical_bias="0.0">
tools:text="R 90%" />
<ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_right_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_right_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/pods_dual_right_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_right_icon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/pod_right_battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_right_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right_battery_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/general_value_unknown_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_right_battery_icon"
app:layout_constraintTop_toBottomOf="@id/pod_right_label" />
<ImageView
android:id="@+id/pod_right_charging_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_charging_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_right_charging_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right_charging_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_charging_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_right_charging_icon"
app:layout_constraintTop_toBottomOf="@id/pod_right_battery_label" />
<ImageView
android:id="@+id/pod_right_microphone_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_keyboard_voice_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_microphone_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_right_microphone_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right_microphone_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_microphone_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_right_microphone_icon"
app:layout_constraintTop_toBottomOf="@id/pod_right_charging_label" />
<ImageView
android:id="@+id/pod_right_wear_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_hearing_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_wear_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_right_wear_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right_wear_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_inear_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_right_wear_icon"
app:layout_constraintTop_toBottomOf="@id/pod_right_microphone_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_bottom" android:id="@+id/barrier_bottom"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:barrierDirection="bottom" app:barrierDirection="bottom"
app:constraint_referenced_ids="pod_left,pod_case,pod_right" /> app:constraint_referenced_ids="pod_left_container,pod_case_container,pod_right_container" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/status" android:id="@+id/status"
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception" app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24" app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" /> tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
@@ -90,21 +90,40 @@
app:constraint_referenced_ids="headphones" app:constraint_referenced_ids="headphones"
tools:layout_editor_absoluteY="60dp" /> tools:layout_editor_absoluteY="60dp" />
<com.google.android.material.textview.MaterialTextView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headphones" android:id="@+id/headphones"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginStart="16dp"
android:gravity="center_horizontal" android:layout_marginEnd="8dp"
app:drawableTopCompat="@drawable/ic_device_generic_headphones"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom" app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_goneMarginBottom="16dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top" app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0" app:layout_constraintVertical_bias="0.0"
tools:text="Headphones\n50%\nCharging" /> app:layout_goneMarginBottom="16dp">
<ImageView
android:id="@+id/battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/battery_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/battery_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/general_value_unknown_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/battery_icon"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_bottom" android:id="@+id/barrier_bottom"
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception" app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24" app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" /> tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
@@ -90,21 +90,81 @@
app:constraint_referenced_ids="headphones" app:constraint_referenced_ids="headphones"
tools:layout_editor_absoluteY="60dp" /> tools:layout_editor_absoluteY="60dp" />
<com.google.android.material.textview.MaterialTextView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headphones" android:id="@+id/headphones"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginStart="16dp"
android:gravity="center_horizontal" android:layout_marginEnd="8dp"
app:drawableTopCompat="@drawable/ic_device_generic_headphones" android:minHeight="64dp"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom" app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_goneMarginBottom="16dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top" app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0" app:layout_constraintVertical_bias="0.0"
tools:text="Headphones\n50%\nCharging" /> app:layout_goneMarginBottom="16dp">
<ImageView
android:id="@+id/battery_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/battery_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/battery_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/general_value_unknown_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/battery_icon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/charging_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_power_24"
app:layout_constraintBottom_toBottomOf="@id/charging_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/charging_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/charging_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/pods_charging_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/charging_icon"
app:layout_constraintTop_toBottomOf="@id/battery_label" />
<ImageView
android:id="@+id/wear_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_baseline_hearing_24"
app:layout_constraintBottom_toBottomOf="@id/wear_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/wear_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/wear_label"
style="@style/PodInfoItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:text="@string/headset_being_worn_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/wear_icon"
app:layout_constraintTop_toBottomOf="@id/charging_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_bottom" android:id="@+id/barrier_bottom"
@@ -66,7 +66,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception" app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception" app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24" app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" /> tools:text="Yours (RSSI -61)" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
@@ -1,72 +1,182 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:orientation="vertical">
<TextView <ImageView
android:id="@+id/pod_label" android:id="@+id/pod_icon"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:src="@drawable/ic_device_generic_earbuds"
app:layout_constraintBottom_toBottomOf="@id/pod_label"
app:layout_constraintEnd_toStartOf="@id/pod_label"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_marginBottom="4dp" app:layout_constraintEnd_toStartOf="@id/signal"
app:layout_constraintStart_toEndOf="@id/pod_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="AirPods Pro" /> tools:text="AirPods Pro" />
<LinearLayout <com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent" android:id="@+id/signal"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp" android:drawableEnd="@drawable/ic_baseline_signal_cellular_alt_24"
android:orientation="horizontal"> android:gravity="center"
android:minWidth="44dp"
app:layout_constraintBottom_toBottomOf="@+id/pod_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/pod_label"
tools:text="-90%" />
<FrameLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp" android:id="@id/pod_left_container"
android:layout_height="0dp" android:layout_width="wrap_content"
android:layout_weight="1" /> android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toStartOf="@id/pod_case_container"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/pod_label">
<TextView <ImageView
android:id="@+id/pod_left" android:id="@+id/pod_left_icon"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:drawableStart="@drawable/ic_airpod_left_24" android:src="@drawable/ic_airpod_left_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_left_battery_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_left_battery_label" />
<ImageView
android:id="@id/pod_left_battery_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_left_battery_label"
app:layout_constraintStart_toEndOf="@id/pod_left_icon"
app:layout_constraintTop_toTopOf="@id/pod_left_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_left_battery_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_left_battery_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="100%" /> tools:text="100%" />
<FrameLayout </androidx.constraintlayout.widget.ConstraintLayout>
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/pod_case" android:id="@+id/pod_case_container"
style="@style/TextAppearance.Compat.Notification.Title" android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toStartOf="@id/pod_right_container"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/pod_left_container"
app:layout_constraintTop_toBottomOf="@id/pod_label">
<ImageView
android:id="@+id/pod_case_icon"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginHorizontal="8dp" android:src="@drawable/ic_airpod_case_24"
android:drawableStart="@drawable/ic_airpod_case_24" app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_case_battery_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_case_battery_label" />
<ImageView
android:id="@id/pod_case_battery_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_case_battery_label"
app:layout_constraintStart_toEndOf="@id/pod_case_icon"
app:layout_constraintTop_toTopOf="@id/pod_case_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_case_battery_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case_battery_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="100%" /> tools:text="100%" />
<FrameLayout </androidx.constraintlayout.widget.ConstraintLayout>
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/pod_right" android:id="@id/pod_right_container"
style="@style/TextAppearance.Compat.Notification.Title" android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/pod_case_container"
app:layout_constraintTop_toBottomOf="@id/pod_label">
<ImageView
android:id="@+id/pod_right_icon"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:drawableStart="@drawable/ic_airpod_right_24" android:src="@drawable/ic_airpod_right_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_right_battery_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pod_right_battery_label" />
<ImageView
android:id="@id/pod_right_battery_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
app:layout_constraintEnd_toStartOf="@id/pod_right_battery_label"
app:layout_constraintStart_toEndOf="@id/pod_right_icon"
app:layout_constraintTop_toTopOf="@id/pod_right_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right_battery_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_right_battery_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="100%" /> tools:text="100%" />
<FrameLayout </androidx.constraintlayout.widget.ConstraintLayout>
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" /> </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</LinearLayout>
@@ -1,29 +1,81 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:orientation="vertical">
<TextView <ImageView
android:id="@+id/headphones_label" android:id="@+id/headphones_icon"
style="@style/TextAppearance.Compat.Notification.Title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:src="@drawable/ic_device_generic_earbuds"
app:layout_constraintBottom_toBottomOf="@id/headphones_label"
app:layout_constraintEnd_toStartOf="@id/headphones_label"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/headphones_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/headphones_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_marginBottom="4dp" app:layout_constraintEnd_toStartOf="@id/signal"
android:gravity="center" app:layout_constraintStart_toEndOf="@id/headphones_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="AirPods Max" /> tools:text="AirPods Max" />
<TextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/headphones" android:id="@+id/signal"
style="@style/TextAppearance.Compat.Notification.Title" style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:drawableEnd="@drawable/ic_baseline_signal_cellular_alt_24"
android:layout_marginHorizontal="16dp" android:gravity="center"
android:drawableStart="@drawable/ic_device_generic_headphones" android:minWidth="44dp"
tools:text="100%" /> app:layout_constraintBottom_toBottomOf="@+id/headphones_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/headphones_label"
tools:text="-90%" />
</LinearLayout> <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headphones_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headphones_label">
<ImageView
android:id="@id/headphones_battery_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_baseline_battery_unknown_24"
app:layout_constraintBottom_toBottomOf="@id/headphones_battery_label"
app:layout_constraintEnd_toStartOf="@id/headphones_battery_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/headphones_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/headphones_battery_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/headphones_battery_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="100%" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,38 +1,81 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:orientation="vertical">
<TextView <ImageView
android:id="@+id/headphones_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_device_generic_earbuds"
app:layout_constraintBottom_toBottomOf="@id/headphones_label"
app:layout_constraintEnd_toStartOf="@id/headphones_label"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/headphones_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/headphones_label" android:id="@+id/headphones_label"
style="@style/TextAppearance.Compat.Notification.Title" style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:layout_marginBottom="4dp" app:layout_constraintEnd_toStartOf="@id/signal"
android:gravity="center" app:layout_constraintStart_toEndOf="@id/headphones_icon"
tools:text="AirPods Max" /> app:layout_constraintTop_toTopOf="parent"
tools:text="Beats Solo 3" />
<LinearLayout <com.google.android.material.textview.MaterialTextView
android:id="@+id/signal"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_gravity="center"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:drawableEnd="@drawable/ic_baseline_signal_cellular_alt_24"
android:gravity="center"
android:minWidth="44dp"
app:layout_constraintBottom_toBottomOf="@+id/headphones_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/headphones_label"
tools:text="-90%" />
<TextView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headphones" android:id="@+id/headphones_container"
style="@style/TextAppearance.Compat.Notification.Title" android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headphones_label">
<ImageView
android:id="@id/headphones_battery_icon"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:src="@drawable/ic_baseline_battery_unknown_24"
android:layout_marginHorizontal="16dp" app:layout_constraintBottom_toBottomOf="@id/headphones_battery_label"
android:drawableStart="@drawable/ic_device_generic_headphones" app:layout_constraintEnd_toStartOf="@id/headphones_battery_label"
android:gravity="center" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/headphones_battery_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/headphones_battery_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/headphones_battery_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="100%" /> tools:text="100%" />
</LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minWidth="320dp"
android:minHeight="96dp"
android:layout_margin="8dp">
<FrameLayout
android:id="@+id/popup_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toTopOf="@id/close_action"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1.0" />
<com.google.android.material.button.MaterialButton
android:id="@+id/close_action"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/general_close_action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/popup_content" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

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