Compare commits

...
57 Commits
Author SHA1 Message Date
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
darken 1a3c2b3d23 Version bump (1.0.1) 2022-01-19 19:38:17 +01:00
darken 7cccff8a71 Remove NoPairedDevice card, too missleading. 2022-01-19 19:37:32 +01:00
darken 4a3e5056b6 Fix permission refreshing and add card if bluetooth is disabled. 2022-01-19 18:30:15 +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
darken 76c1b65b89 Add screenshots and banner 2022-01-19 16:54:37 +01:00
darken d7902bc86e Catch billing client exception 2022-01-19 15:31:26 +01:00
136 changed files with 2713 additions and 1090 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

+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
+24 -9
View File
@@ -1,11 +1,23 @@
<img src="https://github.com/d4rken/capod/raw/main/.assets/banner.png" width="400">
# Companion App for AirPods (CAPod)
![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)
A companion app that adds support for AirPod specific features to Android.
A companion app that adds support for AirPod specific features to Android:
Supported models:
* 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:
* AirPods Gen1
* AirPods Gen2
@@ -18,17 +30,17 @@ Supported models:
* Beats X
* 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)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
* Help translate CAPod [on Crowdin](https://crowdin.com/project/airpod-companion)
## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [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
* [Github Issues](https://github.com/d4rken/capod/issues)
@@ -36,6 +48,8 @@ Supported models:
## Screenshots
<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">
## Thanks to
* The [OpenPods](https://github.com/adolfintel/OpenPods) project,
@@ -49,8 +63,9 @@ Supported models:
CAPod's code is available under a GPL v3 license, this excludes:
* CAPod icons, logos, mascots and marketing materials.
* CAPod icons, logos, mascots and marketing materials/assets.
* CAPod animations and videos.
* CAPod documentation.
* Google Play screenshots.
* Google Play texts & descriptions.
* Translations.
@@ -14,10 +14,7 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@@ -114,6 +111,6 @@ class BillingClientConnectionProvider @Inject constructor(
}
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientProvider")
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
}
}
@@ -28,6 +28,7 @@ class BillingDataRepo @Inject constructor(
) {
private val connectionProvider = billingClientConnectionProvider.connection
.catch { log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } }
.replayingShare(scope)
val billingData: Flow<BillingData> = connectionProvider
+2
View File
@@ -28,6 +28,8 @@
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
@@ -16,5 +16,6 @@ object BuildConfigWrap {
val VERSION_NAME: String = BuildConfig.VERSION_NAME
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 UByte.toHex(): String = this.toByte().toHex()
val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toByte()
val Byte.lowerNibble get() = (this.toInt() and 0b1111).toByte()
val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUByte()
val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUByte()
val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toShort()
val Byte.lowerNibble get() = (this.toInt() and 0b1111).toShort()
val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUShort()
val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUShort()
fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos)
fun UByte.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
fun 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"
}
@@ -72,8 +72,8 @@ class BleScanner @Inject constructor(
)
if (adapter.isOffloadedScanBatchingSupported) {
when (scannerMode) {
ScannerMode.LOW_POWER -> setReportDelay(5000)
ScannerMode.BALANCED -> setReportDelay(2500)
ScannerMode.LOW_POWER -> setReportDelay(2000)
ScannerMode.BALANCED -> setReportDelay(1000)
ScannerMode.LOW_LATENCY -> setReportDelay(500)
}
} else {
@@ -87,7 +87,7 @@ class BleScanner @Inject constructor(
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2500)
ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
@@ -17,19 +17,12 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@Singleton
class BluetoothManager2 @Inject constructor(
@@ -68,90 +61,96 @@ class BluetoothManager2 @Inject constructor(
awaitClose { context.unregisterReceiver(receiver) }
}
suspend fun getBluetoothProfile(
profile: Int = BluetoothProfile.HEADSET
): BluetoothProfile2 = withContext(dispatcherProvider.IO) {
fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
suspendCancellableCoroutine {
val connectionState = AtomicBoolean(false)
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
connectionState.set(true)
BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
isConnectedAtomic = connectionState
).run { it.resume(this) }
}
var profileProxy: BluetoothProfile2? = null
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
connectionState.set(false)
it.cancel(IOException("BluetoothProfile service disconnected (profile=$profile)"))
}
profileProxy = BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
).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 {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
}
private fun monitorDevicesForProfile(
profile: Int = BluetoothProfile.HEADSET
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(bluetoothProfile.connectedDevices)
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handler = Handler(handlerThread.looper)
val filter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
}
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
return
}
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handler = Handler(handlerThread.looper)
this@callbackFlow.launch {
val currentDevices = getBluetoothProfile(profile).connectedDevices
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
return
}
when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device))
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
this@callbackFlow.launch {
val currentDevices = bluetoothProfile.connectedDevices
when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device))
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
}
}
}
}
}
}
context.registerReceiver(receiver, filter, null, handler)
context.registerReceiver(receiver, filter, null, handler)
awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
}
}
}
fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
.flatMapLatest { connectedDevicesForProfile(BluetoothProfile.HEADSET) }
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices ->
devices.filter { device ->
featureFilter.any { feature ->
@@ -162,21 +161,23 @@ class BluetoothManager2 @Inject constructor(
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = try {
log(TAG) { "Nudging Android connection to $device" }
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
connectMethod.invoke(getBluetoothProfile().profile, device)
connectMethod.invoke(bluetoothProfile.proxy, device)
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
}.first()
companion object {
private val TAG = logTag("Bluetooth", "Manager2")
@@ -2,23 +2,15 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile
import java.util.concurrent.atomic.AtomicBoolean
data class BluetoothProfile2(
private val profileType: Int,
internal val profileType: Int,
private val profileProxy: BluetoothProfile,
private val isConnectedAtomic: AtomicBoolean,
) {
val profile: BluetoothProfile
get() {
if (!isConnected) throw IllegalStateException("Proxy is not connected")
return profileProxy
}
val proxy: BluetoothProfile
get() = profileProxy
val connectedDevices: Set<BluetoothDevice>
get() = profile.connectedDevices.toSet()
val isConnected: Boolean
get() = isConnectedAtomic.get()
get() = proxy.connectedDevices.toSet()
}
@@ -58,7 +58,7 @@ class RecorderModule @Inject constructor(
context.startServiceCompat(Intent(context, RecorderService::class.java))
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(
recorder = newRecorder
@@ -89,7 +89,7 @@ class RecorderActivityVM @Inject constructor(
type = "application/zip"
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.")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -34,7 +34,7 @@ fun <T : Any> Flow<T>.shareLatest(
)
.filterNotNull()
fun <T : Any> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
scope = scope,
replay = 1,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
@@ -62,6 +62,15 @@ enum class Permission(
val pwm = it.getSystemService(Context.POWER_SERVICE) as PowerManager
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(
"core.scanner.mode",
ScannerMode.BALANCED,
ScannerMode.LOW_LATENCY,
moshi
)
@@ -1,20 +1,48 @@
package eu.darken.capod.main.core
import android.content.Context
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isRequired
import eu.darken.capod.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Reusable
@Singleton
class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val reactionSettings: ReactionSettings,
) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
suspend fun missingPermissions(): Set<Permission> = Permission.values()
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || generalSettings.monitorMode.value == MonitorMode.ALWAYS }
.filter { it.isRequired(context) }
.toSet()
fun recheck() {
log(TAG) { "recheck()" }
permissionCheckTrigger.value = UUID.randomUUID()
}
val missingPermissions: Flow<Set<Permission>> = combine(
permissionCheckTrigger,
generalSettings.monitorMode.flow,
reactionSettings.showPopUpOnCaseOpen.flow
) { _, monitorMode, showPopUp ->
Permission.values()
.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) }
.toSet()
}
.onEach { log(TAG) { "Missing permission: $it" } }
companion object {
private val TAG = logTag("PermissionTool")
}
}
@@ -11,8 +11,8 @@ import eu.darken.capod.common.lists.differ.setupDiffer
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.NoPairedDeviceCardVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.BasicSingleApplePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualApplePodsCardVH
@@ -33,8 +33,8 @@ class OverviewAdapter @Inject constructor() :
modules.add(TypedVHCreatorMod({ data[it] is SingleApplePodsCardVH.Item }) { SingleApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is NoPairedDeviceCardVH.Item }) { NoPairedDeviceCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
}
override fun getItemCount(): Int = data.size
@@ -33,11 +33,11 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
lateinit var adapter: OverviewAdapter
lateinit var permissionLauncher: ActivityResultLauncher<String>
var awaitingIgnoreBatteryOptimization = false
var awaitingPermission = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
awaitingIgnoreBatteryOptimization = savedInstanceState?.getBoolean("awaitingIgnoreBatteryOptimization") ?: false
awaitingPermission = savedInstanceState?.getBoolean("awaitingPermission") ?: false
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
log { "Request for $id was granted=$granted" }
@@ -70,21 +70,35 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
}
}
vm.listItems.observe2(ui) {
adapter.update(it)
vm.listItems.observe2(ui) { adapter.update(it) }
vm.workerAutolaunch.observe2 {
// While UI is active, subscribe to the autolaunch routine
}
vm.requestPermissionEvent.observe2(ui) {
if (it == Permission.IGNORE_BATTERY_OPTIMIZATION) {
awaitingIgnoreBatteryOptimization = true
startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${requireContext().packageName}")
when (it) {
Permission.IGNORE_BATTERY_OPTIMIZATION -> {
awaitingPermission = true
startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${requireContext().packageName}")
)
)
)
} else {
permissionLauncher.launch(it.permissionId)
}
Permission.SYSTEM_ALERT_WINDOW -> {
awaitingPermission = true
startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${requireContext().packageName}")
)
)
}
else -> {
permissionLauncher.launch(it.permissionId)
}
}
}
@@ -133,15 +147,15 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("awaitingIgnoreBatteryOptimization", awaitingIgnoreBatteryOptimization)
outState.putBoolean("awaitingPermission", awaitingPermission)
super.onSaveInstanceState(outState)
}
override fun onResume() {
super.onResume()
if (awaitingIgnoreBatteryOptimization) {
awaitingIgnoreBatteryOptimization = false
log { "awaitingIgnoreBatteryOptimization=true" }
if (awaitingPermission) {
awaitingPermission = false
log { "awaitingPermission=true" }
vm.onPermissionResult(true)
}
}
@@ -4,10 +4,12 @@ import android.app.Activity
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.navigation.navVia
import eu.darken.capod.common.permissions.Permission
@@ -16,8 +18,8 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.NoPairedDeviceCardVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.*
import eu.darken.capod.monitor.core.PodMonitor
@@ -26,7 +28,6 @@ import eu.darken.capod.pods.core.PodDevice
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 kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
@@ -45,6 +46,7 @@ class OverviewFragmentVM @Inject constructor(
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val webpageTool: WebpageTool,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
val upgradeState = upgradeRepo.upgradeInfo.asLiveData2()
@@ -57,9 +59,7 @@ class OverviewFragmentVM @Inject constructor(
}
}
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
private val requiredPermissions: Flow<Collection<Permission>> = permissionCheckTrigger
.map { permissionTool.missingPermissions() }
val workerAutolaunch: LiveData<Unit> = permissionTool.missingPermissions
.onEach {
if (it.isNotEmpty()) {
log(TAG) { "Missing permissions: $it" }
@@ -76,26 +76,24 @@ class OverviewFragmentVM @Inject constructor(
monitorControl.startMonitor()
}
}
.map { Unit }
.asLiveData2()
val requestPermissionEvent = SingleLiveEvent<Permission>()
private val pods: Flow<List<PodDevice>> = requiredPermissions
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions ->
if (permissions.isEmpty()) {
generalSettings.showAll.flow
.flatMapLatest { showAll ->
if (showAll) {
podMonitor.devices
} else {
podMonitor.mainDevice.map { mainDevice ->
mainDevice?.let { listOf(it) } ?: emptyList()
}
}
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
}
generalSettings.showAll.flow.flatMapLatest { showAll ->
if (showAll) {
podMonitor.devices
} else {
podMonitor.mainDevice.map { mainDevice ->
mainDevice?.let { listOf(it) } ?: emptyList()
}
} else {
channelFlow {
send(emptyList())
awaitClose()
}
}
}
@@ -103,17 +101,35 @@ class OverviewFragmentVM @Inject constructor(
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
updateTicker,
requiredPermissions,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow,
) { tick, permissions, pods, isDebugMode, showAll ->
bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled, mainPod ->
val items = mutableListOf<OverviewAdapter.Item>()
val mainPod = podMonitor.mainDevice.first()
val now = Instant.now()
pods
.map {
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)
}
}
if (permissions.isEmpty() && isBluetoothEnabled) {
pods.map {
val now = Instant.now()
when (it) {
is DualApplePods -> DualApplePodsCardVH.Item(
now = now,
@@ -140,24 +156,7 @@ class OverviewFragmentVM @Inject constructor(
isMainPod = it == mainPod,
)
}
}
.run { items.addAll(this) }
permissions
.map {
PermissionCardVH.Item(
permission = it,
onRequest = { requestPermissionEvent.postValue(it) }
)
}
.run { items.addAll(this) }
if (!showAll && items.none { it is PodDeviceVH.Item } && permissions.isEmpty()) {
NoPairedDeviceCardVH.Item {
generalSettings.showAll.value = true
}.run { items.add(this) }
} else if (showAll && mainPod == null && permissions.isEmpty()) {
items.add(0, MissingMainDeviceVH.Item)
}.run { items.addAll(this) }
}
items
@@ -166,7 +165,7 @@ class OverviewFragmentVM @Inject constructor(
.asLiveData2()
fun onPermissionResult(granted: Boolean) {
if (granted) permissionCheckTrigger.value = UUID.randomUUID()
if (granted) permissionTool.recheck()
}
fun goToSettings() = launch {
@@ -4,29 +4,27 @@ import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.databinding.OverviewNopaireddeviceItemBinding
import eu.darken.capod.databinding.OverviewBluetoothDisabledItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
class NoPairedDeviceCardVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<NoPairedDeviceCardVH.Item, OverviewNopaireddeviceItemBinding>(
R.layout.overview_nopaireddevice_item,
class BluetoothDisabledVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<BluetoothDisabledVH.Item, OverviewBluetoothDisabledItemBinding>(
R.layout.overview_bluetooth_disabled_item,
parent
) {
override val viewBinding = lazy {
OverviewNopaireddeviceItemBinding.bind(itemView)
OverviewBluetoothDisabledItemBinding.bind(itemView)
}
override val onBindData: OverviewNopaireddeviceItemBinding.(
override val onBindData: OverviewBluetoothDisabledItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
showAllAction.setOnClickListener { item.onShowAll() }
}
data class Item(
val onShowAll: () -> Unit
) : OverviewAdapter.Item {
object Item : OverviewAdapter.Item {
override val stableId: Long = Item::class.hashCode().toLong()
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
@@ -1,7 +1,10 @@
package eu.darken.capod.main.ui.overview.cards
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.lists.binding
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.permissions.Permission
@@ -25,6 +28,12 @@ class PermissionCardVH(parent: ViewGroup) :
permissionLabel.setText(item.permission.labelRes)
permissionDescription.setText(item.permission.descriptionRes)
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(
@@ -7,6 +7,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleSingleBasicItemBinding
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
@@ -38,11 +39,8 @@ class BasicSingleApplePodsCardVH(parent: ViewGroup) :
reception.append("\n(${getString(R.string.pods_yours)})")
}
headphones.apply {
val sb = StringBuilder(context.getString(R.string.pods_single_headphones_label))
sb.append("\n").append(device.getBatteryLevelHeadset(context))
text = sb
}
batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
status.apply {
val sb = StringBuilder()
@@ -2,11 +2,11 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isInvisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding
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.DeviceColor
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
@@ -24,61 +24,71 @@ class DualApplePodsCardVH(parent: ViewGroup) :
val device = item.device
name.apply {
val sb = StringBuilder(device.getLabel(context))
if (device.deviceColor != DeviceColor.UNKNOWN) {
sb.append(" (${device.getDeviceColorLabel(context)})")
if (!listOf(DeviceColor.WHITE, DeviceColor.UNKNOWN).contains(device.deviceColor)) {
sb.append(" (${device.deviceColor.name})")
}
text = sb
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL)
if (item.showDebug) {
append(" [${device.primaryPod.name}]")
}
}
deviceIcon.setImageResource(device.iconRes)
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context)
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
reception.text = device.getSignalQuality(context).let {
if (item.isMainPod) "$it\n(${getString(R.string.pods_yours)})" else it
}
podLeft.apply {
val sb = StringBuilder(context.getString(R.string.pods_dual_left_label))
sb.append("\n").append(device.getBatteryLevelLeftPod(context))
when {
device.isLeftPodCharging -> sb.append("\n").append(getString(R.string.pods_charging_label))
device.isLeftPodInEar -> sb.append("\n").append(getString(R.string.pods_inear_label))
else -> {}
// 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
}
// Right Pod
device.apply {
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
podRightChargingIcon.isInvisible = !isRightPodCharging
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 sb = StringBuilder(getString(R.string.pods_dual_right_label))
sb.append("\n").append(device.getBatteryLevelRightPod(context))
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
val hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
podCaseLidIcon.isInvisible = hideInfo
podCaseLidLabel.isInvisible = hideInfo
}
status.apply {
@@ -3,10 +3,12 @@ package eu.darken.capod.main.ui.overview.cards.pods
import android.graphics.Typeface
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsAppleSingleItemBinding
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.getBatteryLevelHeadset
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
@@ -38,16 +40,14 @@ class SingleApplePodsCardVH(parent: ViewGroup) :
reception.append("\n(${getString(R.string.pods_yours)})")
}
headphones.apply {
val sb = StringBuilder(context.getString(R.string.pods_single_headphones_label))
sb.append("\n").append(device.getBatteryLevelHeadset(context))
when {
device.isHeadsetBeingCharged -> sb.append("\n").append("Charging")
device.isHeadphonesBeingWorn -> sb.append("\n").append("In ear")
else -> {}
}
text = sb
}
batteryLabel.text = device.getBatteryLevelHeadset(context)
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
chargingIcon.isInvisible = device.isHeadsetBeingCharged
chargingLabel.isInvisible = device.isHeadsetBeingCharged
wearIcon.isInvisible = device.isHeadphonesBeingWorn
wearLabel.isInvisible = device.isHeadphonesBeingWorn
status.apply {
val sb = StringBuilder()
@@ -68,7 +68,7 @@ class SettingsFragment : Fragment2(R.layout.settings_fragment),
}
ui.toolbar.apply {
subtitle = BuildConfigWrap.VERSION_DESCRIPTION
subtitle = BuildConfigWrap.VERSION_DESCRIPTION_SHORT
setNavigationOnClickListener { requireActivity().onBackPressed() }
}
@@ -7,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.androidstarter.common.preferences.Settings
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.uix.PreferenceFragment2
import eu.darken.capod.main.core.GeneralSettings
@@ -34,7 +35,11 @@ class SettingsIndexFragment : PreferenceFragment2() {
}
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()
}
@@ -29,7 +29,7 @@ class SupportFragmentVM @Inject constructor(
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("Install ID: ").append(installId.id).append("\n")
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope
@@ -9,9 +10,11 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.ScannerMode
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -32,34 +35,44 @@ class PodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex()
val devices: Flow<List<PodDevice>> = generalSettings.scannerMode.flow
.flatMapLatest { scannerMode ->
bluetoothManager.isBluetoothEnabled.flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) {
bleScanner.scan(scannerMode = scannerMode)
} else {
log(TAG, WARN) { "Bluetooth is current disabled" }
emptyFlow()
}
private suspend fun List<BleScanResult>.preFilterAndMap(
scannerMode: ScannerMode
): List<PodDevice> = this
.groupBy { it.address }
.values
.map { sameAdrDevs ->
// For each address we only want the newest result, upstream may batch data
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest
}
.mapNotNull { podFactory.createPod(it) }
val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled
.flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) {
log(TAG) { "Bluetooth is enabled" }
generalSettings.scannerMode.flow
.flatMapLatest { mode ->
bleScanner.scan(scannerMode = mode).map { it.preFilterAndMap(mode) }
}
} else {
log(TAG, WARN) { "Bluetooth is currently disabled" }
flowOf(null)
}
}
.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) }
.map { newPods ->
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
return@map emptyList()
}
val now = Instant.now()
deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.lastSeenAt, now) > Duration.ofSeconds(20)) {
@@ -76,47 +89,52 @@ class PodMonitor @Inject constructor(
}
}
val aboveThreshold = mutableListOf<PodDevice>()
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
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
val mainDeviceModel = generalSettings.mainDeviceModel.value
val aboveTresholdSorted = pods.values
.filter { it.signalQuality > minimumSignalQuality }
.sortedWith(
compareByDescending<PodDevice> { it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN }
.thenBy {
val age = Duration.between(it.lastSeenAt, now).seconds
if (age < 3) 0L else age
}
.thenByDescending { it.signalQuality }
)
}.thenByDescending { it.rssi })
.plus(belowThreshold.sortedWith(compareBy<PodDevice> {
Duration.between(
it.lastSeenAt,
now
)
}.thenByDescending { it.rssi }))
// pods.values.sortedByDescending { it.rssi }
val belowThresholdSorted = pods.values
.filter { it.signalQuality <= minimumSignalQuality }
.sortedWith(
compareBy<PodDevice> { Duration.between(it.lastSeenAt, now) }.thenByDescending { it.signalQuality }
)
val presorted = (aboveTresholdSorted + belowThresholdSorted)
val main = presorted.determineMainDevice()
presorted.sortedByDescending { it == main }
}
.onStart { emit(emptyList()) }
.catch {
log(TAG, ERROR) { "PodMonitor failed:\n${it.asLog()}" }
.retryWhen { cause, attempt ->
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
}
.replayingShare(appScope)
val mainDevice: Flow<PodDevice?>
get() = devices.map { devices ->
devices.maxByOrNull { it.rssi }?.let filter@{ device ->
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
get() = devices
.map { it.determineMainDevice() }
.replayingShare(appScope)
generalSettings.mainDeviceModel.value.let {
if (device.model != it && it != PodDevice.Model.UNKNOWN) return@filter null
}
if (device.signalQuality > minimumSignalQuality) device else null
}
private fun List<PodDevice>.determineMainDevice(): PodDevice? = this.firstOrNull()?.let {
val mainDeviceModel = generalSettings.mainDeviceModel.value
when {
it.model == PodDevice.Model.UNKNOWN -> null
mainDeviceModel != PodDevice.Model.UNKNOWN && it.model != mainDeviceModel -> null
it.signalQuality <= generalSettings.minimumSignalQuality.value -> null
else -> it
}
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
@@ -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.ui.MonitorNotifications
import eu.darken.capod.reaction.ReactionHub
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
@@ -68,15 +69,19 @@ class MonitorWorker @AssistedInject constructor(
Result.success(inputData)
} catch (e: Throwable) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
if (e !is CancellationException) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
} else {
Result.success()
}
} finally {
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
}
private suspend fun doDoWork() {
val permissionsMissingOnStart = permissionTool.missingPermissions()
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
return
@@ -99,7 +104,7 @@ class MonitorWorker @AssistedInject constructor(
generalSettings.monitorMode.flow
.flatMapLatest { monitorMode ->
val missingPermsFlow = permissionTool.missingPermissions()
val missingPermsFlow = permissionTool.missingPermissions.first()
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" }
workerScope.coroutineContext.cancelChildren()
@@ -1,6 +1,7 @@
package eu.darken.capod.monitor.ui
import android.content.Context
import android.view.View
import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
@@ -26,38 +27,45 @@ class MonitorNotificationViewFactory @Inject constructor(
context.packageName,
R.layout.monitor_notification_dual_pods_small
).apply {
device.getBatteryLevelLeftPod(context)
.let { if (device.isLeftPodCharging) "$it" else it }
.run { setTextViewText(R.id.pod_left, this) }
device.apply {
// Left
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)
.let { if (device.isCaseCharging) "$it" else it }
.run { setTextViewText(R.id.pod_case, this) }
// Case
setTextViewText(R.id.pod_case_label, getBatteryLevelCase(context))
setViewVisibility(R.id.pod_case_charging, if (isCaseCharging) View.VISIBLE else View.GONE)
device.getBatteryLevelRightPod(context)
.let { if (device.isRightPodCharging) "$it" else it }
.run { setTextViewText(R.id.pod_right, this) }
// Right
setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
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(
context.packageName,
R.layout.monitor_notification_single_pods_small
).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) }
device.apply {
setTextViewText(R.id.headphones_label, getLabel(context))
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
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(
context.packageName,
R.layout.monitor_notification_single_pods_basic_small
).apply {
setTextViewText(R.id.headphones_label, device.getLabel(context))
device.getBatteryLevelHeadset(context)
.run { setTextViewText(R.id.headphones, this) }
device.apply {
setTextViewText(R.id.headphones_label, getLabel(context))
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
}
}
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.util.*
import kotlin.math.abs
import kotlin.math.max
interface PodDevice {
@@ -16,6 +17,9 @@ interface PodDevice {
val model: Model
val address: String
get() = scanResult.address
val lastSeenAt: Instant
val scanResult: BleScanResult
@@ -23,12 +27,14 @@ interface PodDevice {
val rssi: Int
get() = scanResult.rssi
val confidence: Float
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val signalQuality: Float
get() = (100 - abs(rssi)) / 100f
get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
val rawData: ByteArray
@@ -91,4 +97,8 @@ interface PodDevice {
"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.icu.text.RelativeDateTimeFormatter
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import java.time.Duration
import java.time.Instant
@@ -28,6 +29,19 @@ fun PodDevice.getSignalQuality(context: Context): String {
return "~${percentage.roundToInt()}%"
}
@DrawableRes
fun getBatteryDrawable(percent: Float?): Int = when {
percent == null -> R.drawable.ic_baseline_battery_unknown_24
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
percent > 0.50f -> R.drawable.ic_baseline_battery_4_bar_24
percent > 0.35f -> R.drawable.ic_baseline_battery_3_bar_24
percent > 0.20f -> R.drawable.ic_baseline_battery_2_bar_24
percent > 0.05f -> R.drawable.ic_baseline_battery_1_bar_24
else -> R.drawable.ic_baseline_battery_0_bar_24
}
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
fun PodDevice.lastSeenFormatted(now: Instant): String {
@@ -21,7 +21,7 @@ import javax.inject.Singleton
class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val podFactories: @JvmSuppressWildcards Set<ApplePods.Factory>,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
) {
@@ -57,24 +57,27 @@ class AppleFactory @Inject constructor(
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(
tag = TAG,
message = "Unknown proximity message type",
exception = IllegalArgumentException("Unknown ProximityMessage: $pm")
)
factory = unknownAppleFactory
}
return factory.create(
scanResult = scanResult,
proximityMessage = pm,
)
return@withLock device
}
companion object {
private val SILENCED_PMS = mutableSetOf<UShort>()
private val TAG = logTag("Pod", "Apple", "Factory")
}
}
@@ -12,17 +12,17 @@ import eu.darken.capod.pods.core.apple.beats.*
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
}
@@ -1,14 +1,11 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.toHex
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
interface ApplePods : PodDevice {
@@ -27,11 +24,20 @@ interface ApplePods : PodDevice {
val rawStatus: UByte
get() = proximityMessage.data[3]
val rawStatusHex: String
get() = rawStatus.toHex()
val rawPodsBattery: UByte
get() = proximityMessage.data[4]
val rawCaseBattery: UByte
get() = proximityMessage.data[5]
val rawPodsBatteryHex: String
get() = rawPodsBattery.toHex()
val rawFlags: UShort
get() = proximityMessage.data[5].upperNibble
val rawCaseBattery: UShort
get() = proximityMessage.data[5].lowerNibble
val rawCaseLidState: UByte
get() = proximityMessage.data[6]
@@ -43,102 +49,4 @@ interface ApplePods : PodDevice {
get() = proximityMessage.data[8]
abstract class Factory(private val tag: String) {
internal data class ValueCache(
val caseBatteryPercentage: Float?
)
internal val cachedValues = mutableMapOf<PodDevice.Id, ValueCache>()
internal data class KnownDevice(
val identifier: PodDevice.Id,
val scanResult: BleScanResult,
val message: ProximityPairing.Message,
) {
val 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,136 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.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
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 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).average().toInt()
val timestampNanos: Duration
get() = Duration.ofNanos(history.last().scanResult.generatedAtNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
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(history = existing.history.plus(device))
}
else -> {
log(tag) { "searchHistory1: Creating new history for $device" }
KnownDevice(
id = device.identifier,
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
import android.content.Context
import android.util.Range
import androidx.annotation.StringRes
import eu.darken.capod.R
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.upperNibble
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.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)) {
true -> Pod.LEFT
false -> Pod.RIGHT
}
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.lowerNibble.toInt()
Pod.RIGHT -> rawPodsBattery.upperNibble.toInt()
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
@@ -40,9 +46,9 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
override val batteryRightPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.upperNibble.toInt()
Pod.RIGHT -> rawPodsBattery.lowerNibble.toInt()
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
@@ -55,35 +61,55 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
}
}
val isLeftPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(1)
Pod.RIGHT -> rawStatus.isBitSet(3)
val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
val isOnePodInCase: Boolean
get() = rawStatus.isBitSet(4)
val areBothPodsInCase: Boolean
get() = rawStatus.isBitSet(2)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
val isRightPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(3)
Pod.RIGHT -> rawStatus.isBitSet(1)
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> 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
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1)
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
val isRightPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0)
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) {
get() = when (val value = rawCaseBattery.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
@@ -94,11 +120,12 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
}
override val isCaseCharging: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(2)
get() = rawFlags.isBitSet(2)
val caseLidState: LidState
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.
* The upper limits are not the maximums but are only reached if playing with the case.
*/
enum class LidState(val rawRange: Range<UByte>) {
OPEN(0x30, 0x37),
CLOSED(0x38, 0x3F),
NOT_IN_CASE(0x00, 0x03),
UNKNOWN(0xFF, 0xFF);
constructor(vararg raw: Int) : this(Range(raw[0].toUByte(), (raw[1].toUByte())))
enum class LidState(val rawRange: IntRange) {
OPEN(0x30..0x37),
CLOSED(0x38..0x3F),
NOT_IN_CASE(0x00..0x03),
UNKNOWN(0xFF..0xFF);
}
val deviceColor: DeviceColor
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) {
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)
constructor(raw: Int) : this(raw.toUByte())
}
val connectionState: ConnectionState
@@ -160,4 +182,5 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
}
@@ -0,0 +1,84 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = deviceColor,
model = model
)
/**
* Split pods, one in case, one not.
*/
data class SplitPodsMarkings(
val leftPodBattery: Float?,
val rightPodBattery: Float?,
val microPhoneLeft: Boolean,
val microPhoneRight: Boolean,
val chargingLeft: Boolean,
val chargingRight: Boolean,
val color: DualApplePods.DeviceColor,
val model: PodDevice.Model,
)
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings()
return filter { known ->
known.history
.filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target }
}
}
fun KnownDevice.getLatestCaseBattery(): Float? = history
.filterIsInstance<DualApplePods>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>()
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState
}
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
log(tag, DEBUG) { "searchHistory2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
return when (caseIgnored.size) {
0 -> basicResult
1 -> caseIgnored.single()
else -> {
log(tag) { "searchHistory2: More than one result when ignoring case markers." }
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
caseIgnored.minus(oldest).forEach {
log(tag) { "searchHistory2: Removing outlier: $it" }
knownDevices.remove(it.id)
}
oldest
}
}
}
}
@@ -1,7 +1,6 @@
package eu.darken.capod.pods.core.apple
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.HasSinglePod
@@ -11,7 +10,7 @@ interface SingleApplePods : BasicSingleApplePods, HasEarDetection, HasSinglePod
get() = rawStatus.isBitSet(1)
val isHeadsetBeingCharged: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(0)
get() = rawFlags.isBitSet(0)
override val isBeingWorn: Boolean
get() = isHeadphonesBeingWorn
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
@@ -13,26 +13,36 @@ data class UnknownAppleDevice(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
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 {
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
override fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return UnknownAppleDevice(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,7 +15,10 @@ data class AirPodsGen1 constructor(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
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 {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
@@ -22,26 +26,32 @@ data class AirPodsGen1 constructor(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsGen1(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen1(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,7 +15,10 @@ data class AirPodsGen2 constructor(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
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 {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
@@ -22,26 +26,32 @@ data class AirPodsGen2 constructor(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsGen2(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen2(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,33 +15,42 @@ data class AirPodsGen3 constructor(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
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 {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsGen3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,22 +15,32 @@ data class AirPodsMax(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsMax(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return AirPodsMax(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,8 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,7 +16,10 @@ data class AirPodsPro(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
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 {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@@ -22,32 +27,38 @@ data class AirPodsPro(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
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()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
}
}
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -13,23 +14,33 @@ data class BeatsFlex(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
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 {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsFlex(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsFlex(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -13,23 +14,32 @@ data class BeatsSolo3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
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 {
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsSolo3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsSolo3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -13,23 +14,30 @@ data class BeatsStudio3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
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 {
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsStudio3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsStudio3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -13,23 +14,33 @@ data class BeatsX(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
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 {
override val model: PodDevice.Model = PodDevice.Model.BEATS_X
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsX(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsX(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -13,22 +14,32 @@ data class PowerBeats3(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val lastSeenAt: Instant = Instant.now(),
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 {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val basic = PowerBeats3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return PowerBeats3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,7 +15,10 @@ data class PowerBeatsPro(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
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 {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
@@ -22,26 +26,33 @@ data class PowerBeatsPro(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = PowerBeatsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
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 dagger.Reusable
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import javax.inject.Inject
@@ -15,22 +13,11 @@ object ProximityPairing {
val length: Int,
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(
vendor = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
batteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
deviceColor = data[7]
)
override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
}
}
@Reusable
@@ -74,6 +61,6 @@ object ProximityPairing {
}
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
private val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
private const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
internal const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
}
@@ -1,11 +1,14 @@
package eu.darken.capod.reaction.autoconnect
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.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.main.core.GeneralSettings
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.apple.DualApplePods
import eu.darken.capod.reaction.settings.ReactionSettings
@@ -22,29 +25,36 @@ class AutoConnect @Inject constructor(
) {
fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() }
.filterNotNull()
.distinctUntilChanged()
.map { podDevice ->
log(TAG) { "mainPodDevice is $podDevice" }
.flatMapLatest { isAutoConnectEnabled ->
if (isAutoConnectEnabled) {
combine(
bluetoothManager.connectedDevices().distinctUntilChanged(),
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
if (mainDeviceAddr.isNullOrEmpty()) {
log(TAG) { "mainDeviceAddress is null" }
log(TAG, WARN) { "mainDeviceAddress is null" }
return@map
} else {
log(TAG) { "mainDeviceAddress is $mainDeviceAddr" }
}
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
if (bondedDevice == null) {
log(TAG) { "No bonded device matches $mainDeviceAddr" }
log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
return@map
} 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
}
@@ -57,12 +67,18 @@ class AutoConnect @Inject constructor(
log(TAG) { "Checking condition $condition" }
val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (podDevice) {
is DualApplePods -> podDevice.caseLidState == DualApplePods.LidState.OPEN
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true
}
AutoConnectCondition.IN_EAR -> when (podDevice) {
is HasEarDetection -> podDevice.isBeingWorn
AutoConnectCondition.IN_EAR -> when (mainDevice) {
is HasEarDetection -> {
if (mainDevice is HasDualEarDetection && reactionSettings.onePodMode.value) {
mainDevice.isEitherPodInEar
} else {
mainDevice.isBeingWorn
}
}
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.withPrevious
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.reaction.settings.ReactionSettings
import kotlinx.coroutines.flow.*
@@ -25,7 +26,8 @@ class PlayPause @Inject constructor(
fun monitor() = combine(
reactionSettings.autoPlay.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.isEmpty()) {
@@ -42,7 +44,28 @@ class PlayPause @Inject constructor(
if (previous is HasEarDetection && current is HasEarDetection) {
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
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." }
if (current.isBeingWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
@@ -1,7 +1,7 @@
package eu.darken.capod.reaction.popup
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.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.pods.core.PodDevice
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -22,16 +22,25 @@ import javax.inject.Singleton
class PopUpReaction @Inject constructor(
private val podMonitor: PodMonitor,
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
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() }
.flatMapLatest { isEnabled ->
if (isEnabled) {
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }
} else {
emptyFlow()
}
}
.withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" }
.map { (previous, current) ->
if (previous is DualApplePods? && current is DualApplePods) {
log(TAG) {
log(TAG, VERBOSE) {
val prev = previous?.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})"
@@ -46,17 +55,48 @@ class PopUpReaction @Inject constructor(
if (isSameDeviceWithCaseNowOpen || isNewDeviceWithJustOpenedCase) {
log(TAG) { "Case lid status changed for monitored device." }
if (current.caseLidState == DualApplePods.LidState.OPEN && current.model != PodDevice.Model.UNKNOWN) {
log(TAG, INFO) { "Show popup" }
popUpNotification.showNotification(current)
} else if (current.caseLidState != DualApplePods.LidState.OPEN) {
log(TAG, INFO) { "Hide popup" }
popUpNotification.hideNotification()
}
tryPopWindow(current)
}
}
}
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 {
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
)
val onePodMode = preferences.createFlowPreference(
"reaction.onepod.enabled",
false
)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
autoPause,
autoPlay,
autoConnect,
autoConnectCondition,
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"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
@@ -11,43 +11,97 @@
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/pod_left"
style="@style/TextAppearance.Compat.Notification.Title"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:drawableStart="@drawable/ic_airpod_left_24"
tools:text="100%" />
android:orientation="horizontal">
<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
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/pod_case"
style="@style/TextAppearance.Compat.Notification.Title"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
android:drawableStart="@drawable/ic_airpod_case_24"
tools:text="100%" />
android:orientation="horizontal">
<ImageView
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
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/pod_right"
style="@style/TextAppearance.Compat.Notification.Title"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:drawableStart="@drawable/ic_airpod_right_24"
tools:text="100%" />
android:orientation="horizontal">
<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
android:layout_width="0dp"
@@ -2,7 +2,8 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:gravity="center_horizontal">
<TextView
android:id="@+id/headphones_label"
@@ -10,16 +11,27 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
tools:text="AirPods Max" />
tools:text="Beats Solo 3" />
<TextView
android:id="@+id/headphones"
style="@style/TextAppearance.Compat.Notification.Title"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:drawableStart="@drawable/ic_device_generic_headphones"
android:gravity="center"
tools:text="100%" />
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
<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>
@@ -2,7 +2,8 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:gravity="center_horizontal">
<TextView
android:id="@+id/headphones_label"
@@ -12,14 +13,35 @@
android:gravity="center"
tools:text="AirPods Max" />
<TextView
android:id="@+id/headphones"
style="@style/TextAppearance.Compat.Notification.Title"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:drawableStart="@drawable/ic_device_generic_headphones"
android:gravity="center"
tools:text="100%" />
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
<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>
@@ -14,42 +14,30 @@
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_label"
android:id="@+id/bluetooth_label"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:text="@string/overview_bluetooth_disabled_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/overview_nopaireddevices_label" />
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/permission_description"
android:id="@+id/bluetooth_description"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="4dp"
android:layout_marginHorizontal="16dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_label"
android:text="@string/overview_nopaireddevices_description" />
<com.google.android.material.button.MaterialButton
android:id="@+id/show_all_action"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginTop="16dp"
android:text="@string/settings_showall_label"
android:layout_marginBottom="16dp"
android:text="@string/overview_bluetooth_disabled_description"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/bluetooth_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -49,6 +49,18 @@
app:layout_constraintTop_toBottomOf="@id/permission_label"
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
android:id="@+id/grant_action"
style="@style/Widget.MaterialComponents.Button"
@@ -60,7 +72,7 @@
android:text="@string/general_grant_permission_action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" />
app:layout_constraintTop_toBottomOf="@id/privacy_policy" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -87,61 +87,334 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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
android:id="@+id/pod_left"
style="@style/TextAppearance.MaterialComponents.Body2"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/pod_left_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_airpod_left_24"
android:layout_marginEnd="4dp"
android:minHeight="108dp"
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_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0"
tools:text="Left pod\n100%\n(Primary)" />
app:layout_constraintVertical_bias="0.0">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_case"
style="@style/TextAppearance.MaterialComponents.Body2"
<ImageView
android:id="@+id/pod_left_icon"
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_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_airpod_case_24"
android:layout_marginHorizontal="4dp"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toStartOf="@id/pod_right"
app:layout_constraintStart_toEndOf="@id/pod_left"
app:layout_constraintEnd_toStartOf="@id/pod_right_container"
app:layout_constraintStart_toEndOf="@id/pod_left_container"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0"
tools:text="Case\n50%" />
app:layout_constraintVertical_bias="0.0">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right"
style="@style/TextAppearance.MaterialComponents.Body2"
<ImageView
android:id="@+id/pod_case_icon"
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_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_airpod_right_24"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
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_constraintVertical_bias="0.0"
tools:text="R 90%" />
app:layout_constraintVertical_bias="0.0">
<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
android:id="@+id/barrier_bottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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
android:id="@+id/status"
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -90,21 +90,40 @@
app:constraint_referenced_ids="headphones"
tools:layout_editor_absoluteY="60dp" />
<com.google.android.material.textview.MaterialTextView
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headphones"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_device_generic_headphones"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_goneMarginBottom="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
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
android:id="@+id/barrier_bottom"
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -90,21 +90,81 @@
app:constraint_referenced_ids="headphones"
tools:layout_editor_absoluteY="60dp" />
<com.google.android.material.textview.MaterialTextView
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headphones"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_device_generic_headphones"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:minHeight="64dp"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_goneMarginBottom="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
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
android:id="@+id/barrier_bottom"
@@ -66,7 +66,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<com.google.android.material.textview.MaterialTextView
@@ -1,72 +1,182 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_height="wrap_content">
<TextView
android:id="@+id/pod_label"
style="@style/TextAppearance.Compat.Notification.Title"
<ImageView
android:id="@+id/pod_icon"
android:layout_width="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_marginHorizontal="8dp"
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" />
<LinearLayout
android:layout_width="match_parent"
<com.google.android.material.textview.MaterialTextView
android:id="@+id/signal"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
android:drawableEnd="@drawable/ic_baseline_signal_cellular_alt_24"
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
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@id/pod_left_container"
android:layout_width="wrap_content"
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
android:id="@+id/pod_left"
style="@style/TextAppearance.Compat.Notification.Title"
<ImageView
android:id="@+id/pod_left_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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%" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/pod_case"
style="@style/TextAppearance.Compat.Notification.Title"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/pod_case_container"
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_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
android:drawableStart="@drawable/ic_airpod_case_24"
android:src="@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%" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/pod_right"
style="@style/TextAppearance.Compat.Notification.Title"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@id/pod_right_container"
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_height="wrap_content"
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%" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,29 +1,81 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_height="wrap_content">
<TextView
android:id="@+id/headphones_label"
style="@style/TextAppearance.Compat.Notification.Title"
<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"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:gravity="center"
app:layout_constraintEnd_toStartOf="@id/signal"
app:layout_constraintStart_toEndOf="@id/headphones_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="AirPods Max" />
<TextView
android:id="@+id/headphones"
style="@style/TextAppearance.Compat.Notification.Title"
<com.google.android.material.textview.MaterialTextView
android:id="@+id/signal"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="16dp"
android:drawableStart="@drawable/ic_device_generic_headphones"
tools:text="100%" />
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%" />
</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>

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