mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adef0b39e9 | ||
|
|
af12a0e5cb | ||
|
|
90fc2fac24 | ||
|
|
3be7d8b43f | ||
|
|
a35d8c57b4 | ||
|
|
f792431710 | ||
|
|
7ff20acfc0 | ||
|
|
8e20e0e108 | ||
|
|
a6fda04640 | ||
|
|
0bbcfb5e88 | ||
|
|
e79ba6bfe5 | ||
|
|
369b0207c4 | ||
|
|
ce737b45b9 | ||
|
|
cde3afdcd9 | ||
|
|
07b3f4320e | ||
|
|
e1c71cf6b6 | ||
|
|
c233cd2445 | ||
|
|
8f609ca989 | ||
|
|
2de2369600 | ||
|
|
d5d2ae784c | ||
|
|
56bedb64c4 | ||
|
|
3b48ca90d3 | ||
|
|
272ecc006f | ||
|
|
ddacfa6841 | ||
|
|
c5b1c417a4 | ||
|
|
38bc3be5b7 | ||
|
|
57cd823b63 | ||
|
|
6cd799130e | ||
|
|
a1c58e0a32 | ||
|
|
9cd4cea63d | ||
|
|
1d5e13cc24 | ||
|
|
6d2c4ba8ca | ||
|
|
357baa12e6 | ||
|
|
909a9ba194 | ||
|
|
79f68c3a0d | ||
|
|
5eb97cc966 | ||
|
|
e4f405ec65 | ||
|
|
f392df05f9 | ||
|
|
0f8e441a92 | ||
|
|
edbc944724 | ||
|
|
65eb92dcb1 | ||
|
|
7df24f9c76 | ||
|
|
5d72d0116b | ||
|
|
91a16f55d9 | ||
|
|
9ae3516a83 | ||
|
|
f3c20caa13 | ||
|
|
680bcbad2a | ||
|
|
5f60f2b5eb | ||
|
|
7efe6619d2 | ||
|
|
1a3c2b3d23 | ||
|
|
7cccff8a71 | ||
|
|
4a3e5056b6 | ||
|
|
16a880a92d | ||
|
|
79db1e2412 | ||
|
|
b6e8d69eff | ||
|
|
76c1b65b89 | ||
|
|
d7902bc86e |
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 |
@@ -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
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
|
<img src="https://github.com/d4rken/capod/raw/main/.assets/banner.png" width="400">
|
||||||
|
|
||||||
# Companion App for AirPods (CAPod)
|
# Companion App for AirPods (CAPod)
|
||||||
|
|
||||||

|

|
||||||
[](https://github.com/d4rken/capod/releases/latest)
|
[](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 Gen1
|
||||||
* AirPods Gen2
|
* AirPods Gen2
|
||||||
@@ -18,17 +30,17 @@ Supported models:
|
|||||||
* Beats X
|
* Beats X
|
||||||
* Beats Flex
|
* Beats Flex
|
||||||
|
|
||||||
## Support the project
|
|
||||||
|
|
||||||
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
|
|
||||||
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
|
|
||||||
* Help translate CAPod [on Crowdin](https://crowdin.com/project/airpod-companion)
|
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
|
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
|
||||||
* [GitHub](https://github.com/d4rken/capod/releases/latest)
|
* [GitHub](https://github.com/d4rken/capod/releases/latest)
|
||||||
|
|
||||||
|
## Support the project
|
||||||
|
|
||||||
|
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
|
||||||
|
* Help translate CAPod [on Crowdin](https://crowdin.com/project/capod)
|
||||||
|
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
|
||||||
|
|
||||||
## Get help
|
## Get help
|
||||||
|
|
||||||
* [Github Issues](https://github.com/d4rken/capod/issues)
|
* [Github Issues](https://github.com/d4rken/capod/issues)
|
||||||
@@ -36,6 +48,8 @@ Supported models:
|
|||||||
|
|
||||||
## Screenshots
|
## 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
|
## Thanks to
|
||||||
|
|
||||||
* The [OpenPods](https://github.com/adolfintel/OpenPods) project,
|
* 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'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 animations and videos.
|
||||||
* CAPod documentation.
|
* CAPod documentation.
|
||||||
* Google Play screenshots.
|
* Google Play screenshots.
|
||||||
* Google Play texts & descriptions.
|
* Google Play texts & descriptions.
|
||||||
|
* Translations.
|
||||||
|
|||||||
+2
-5
@@ -14,10 +14,7 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
|
||||||
import kotlinx.coroutines.flow.retryWhen
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -114,6 +111,6 @@ class BillingClientConnectionProvider @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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
|
private val connectionProvider = billingClientConnectionProvider.connection
|
||||||
|
.catch { log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } }
|
||||||
.replayingShare(scope)
|
.replayingShare(scope)
|
||||||
|
|
||||||
val billingData: Flow<BillingData> = connectionProvider
|
val billingData: Flow<BillingData> = connectionProvider
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.bluetooth_le"
|
android:name="android.hardware.bluetooth_le"
|
||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ object BuildConfigWrap {
|
|||||||
val VERSION_NAME: String = BuildConfig.VERSION_NAME
|
val VERSION_NAME: String = BuildConfig.VERSION_NAME
|
||||||
val GIT_SHA: String = BuildConfig.GITSHA
|
val GIT_SHA: String = BuildConfig.GITSHA
|
||||||
|
|
||||||
val VERSION_DESCRIPTION: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
|
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
|
||||||
|
val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import java.util.*
|
|||||||
fun Byte.toHex(): String = String.format("%02X", this)
|
fun Byte.toHex(): String = String.format("%02X", this)
|
||||||
fun UByte.toHex(): String = this.toByte().toHex()
|
fun UByte.toHex(): String = this.toByte().toHex()
|
||||||
|
|
||||||
val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toByte()
|
val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toShort()
|
||||||
val Byte.lowerNibble get() = (this.toInt() and 0b1111).toByte()
|
val Byte.lowerNibble get() = (this.toInt() and 0b1111).toShort()
|
||||||
val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUByte()
|
val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUShort()
|
||||||
val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUByte()
|
val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUShort()
|
||||||
|
|
||||||
fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos)
|
fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos)
|
||||||
fun UByte.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
|
fun UByte.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
|
||||||
|
|
||||||
|
fun Short.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
|
||||||
|
fun UShort.isBitSet(pos: Int): Boolean = this.toShort().isBitSet(pos)
|
||||||
|
|
||||||
|
fun UShort.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(4, '0')
|
||||||
|
fun UByte.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(8, '0')
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package eu.darken.capod.common
|
||||||
|
|
||||||
|
object PrivacyPolicy {
|
||||||
|
const val URL = "https://raw.githubusercontent.com/d4rken/capod-public/main/PRIVACY_POLICY.md"
|
||||||
|
}
|
||||||
@@ -72,8 +72,8 @@ class BleScanner @Inject constructor(
|
|||||||
)
|
)
|
||||||
if (adapter.isOffloadedScanBatchingSupported) {
|
if (adapter.isOffloadedScanBatchingSupported) {
|
||||||
when (scannerMode) {
|
when (scannerMode) {
|
||||||
ScannerMode.LOW_POWER -> setReportDelay(5000)
|
ScannerMode.LOW_POWER -> setReportDelay(2000)
|
||||||
ScannerMode.BALANCED -> setReportDelay(2500)
|
ScannerMode.BALANCED -> setReportDelay(1000)
|
||||||
ScannerMode.LOW_LATENCY -> setReportDelay(500)
|
ScannerMode.LOW_LATENCY -> setReportDelay(500)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -87,7 +87,7 @@ class BleScanner @Inject constructor(
|
|||||||
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
|
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
|
||||||
when (scannerMode) {
|
when (scannerMode) {
|
||||||
ScannerMode.LOW_POWER -> break
|
ScannerMode.LOW_POWER -> break
|
||||||
ScannerMode.BALANCED -> delay(2500)
|
ScannerMode.BALANCED -> delay(1000)
|
||||||
ScannerMode.LOW_LATENCY -> delay(500)
|
ScannerMode.LOW_LATENCY -> delay(500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,19 +17,12 @@ import eu.darken.capod.common.debug.logging.log
|
|||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class BluetoothManager2 @Inject constructor(
|
class BluetoothManager2 @Inject constructor(
|
||||||
@@ -68,90 +61,96 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
awaitClose { context.unregisterReceiver(receiver) }
|
awaitClose { context.unregisterReceiver(receiver) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getBluetoothProfile(
|
fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
|
||||||
profile: Int = BluetoothProfile.HEADSET
|
|
||||||
): BluetoothProfile2 = withContext(dispatcherProvider.IO) {
|
|
||||||
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
|
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
|
||||||
|
|
||||||
suspendCancellableCoroutine {
|
var profileProxy: BluetoothProfile2? = null
|
||||||
val connectionState = AtomicBoolean(false)
|
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
|
||||||
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
|
|
||||||
connectionState.set(true)
|
|
||||||
BluetoothProfile2(
|
|
||||||
profileType = profile,
|
|
||||||
profileProxy = proxy,
|
|
||||||
isConnectedAtomic = connectionState
|
|
||||||
).run { it.resume(this) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(profile: Int) {
|
profileProxy = BluetoothProfile2(
|
||||||
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
|
profileType = profile,
|
||||||
connectionState.set(false)
|
profileProxy = proxy,
|
||||||
it.cancel(IOException("BluetoothProfile service disconnected (profile=$profile)"))
|
).also { trySend(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}, profile)
|
override fun onServiceDisconnected(profile: Int) {
|
||||||
|
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
|
||||||
|
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
}, profile)
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
log(TAG) { "Closing BluetoothProfile: $profileProxy" }
|
||||||
|
profileProxy?.let {
|
||||||
|
manager.adapter.closeProfileProxy(it.profileType, it.proxy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectedDevicesForProfile(profile: Int): Flow<Set<BluetoothDevice>> = callbackFlow {
|
|
||||||
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
|
|
||||||
trySend(getBluetoothProfile(profile).connectedDevices)
|
|
||||||
|
|
||||||
val filter = IntentFilter().apply {
|
private fun monitorDevicesForProfile(
|
||||||
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
|
profile: Int = BluetoothProfile.HEADSET
|
||||||
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
|
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
|
||||||
}
|
callbackFlow {
|
||||||
|
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
|
||||||
|
trySend(bluetoothProfile.connectedDevices)
|
||||||
|
|
||||||
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
|
val filter = IntentFilter().apply {
|
||||||
start()
|
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
|
||||||
}
|
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
|
||||||
val handler = Handler(handlerThread.looper)
|
}
|
||||||
|
|
||||||
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
start()
|
||||||
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
|
}
|
||||||
val action = intent.action
|
val handler = Handler(handlerThread.looper)
|
||||||
if (action == null) {
|
|
||||||
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
|
|
||||||
if (device == null) {
|
|
||||||
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this@callbackFlow.launch {
|
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||||
val currentDevices = getBluetoothProfile(profile).connectedDevices
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
|
||||||
|
val action = intent.action
|
||||||
|
if (action == null) {
|
||||||
|
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
|
||||||
|
if (device == null) {
|
||||||
|
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
when (action) {
|
this@callbackFlow.launch {
|
||||||
BluetoothDevice.ACTION_ACL_CONNECTED -> {
|
val currentDevices = bluetoothProfile.connectedDevices
|
||||||
log(TAG) { "Adding $device to current devices $currentDevices" }
|
|
||||||
trySend(currentDevices.plus(device))
|
when (action) {
|
||||||
}
|
BluetoothDevice.ACTION_ACL_CONNECTED -> {
|
||||||
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
|
log(TAG) { "Adding $device to current devices $currentDevices" }
|
||||||
log(TAG) { "Removing $device from current devices $currentDevices" }
|
trySend(currentDevices.plus(device))
|
||||||
trySend(currentDevices.minus(device))
|
}
|
||||||
|
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
|
||||||
|
log(TAG) { "Removing $device from current devices $currentDevices" }
|
||||||
|
trySend(currentDevices.minus(device))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
context.registerReceiver(receiver, filter, null, handler)
|
||||||
context.registerReceiver(receiver, filter, null, handler)
|
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
|
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
|
||||||
context.unregisterReceiver(receiver)
|
context.unregisterReceiver(receiver)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connectedDevices(
|
fun connectedDevices(
|
||||||
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
|
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
|
||||||
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
|
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
|
||||||
.flatMapLatest { connectedDevicesForProfile(BluetoothProfile.HEADSET) }
|
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
|
||||||
.map { devices ->
|
.map { devices ->
|
||||||
devices.filter { device ->
|
devices.filter { device ->
|
||||||
featureFilter.any { feature ->
|
featureFilter.any { feature ->
|
||||||
@@ -162,21 +161,23 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
|
|
||||||
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
|
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
|
||||||
|
|
||||||
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = try {
|
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
try {
|
||||||
|
log(TAG) { "Nudging Android connection to $device" }
|
||||||
|
|
||||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||||
"connect", BluetoothDevice::class.java
|
"connect", BluetoothDevice::class.java
|
||||||
).apply { isAccessible = true }
|
).apply { isAccessible = true }
|
||||||
|
|
||||||
connectMethod.invoke(getBluetoothProfile().profile, device)
|
connectMethod.invoke(bluetoothProfile.proxy, device)
|
||||||
|
|
||||||
log(TAG) { "Nudged connection to $device" }
|
log(TAG) { "Nudged connection to $device" }
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
}.first()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Bluetooth", "Manager2")
|
private val TAG = logTag("Bluetooth", "Manager2")
|
||||||
|
|||||||
@@ -2,23 +2,15 @@ package eu.darken.capod.common.bluetooth
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothProfile
|
import android.bluetooth.BluetoothProfile
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
|
|
||||||
data class BluetoothProfile2(
|
data class BluetoothProfile2(
|
||||||
private val profileType: Int,
|
internal val profileType: Int,
|
||||||
private val profileProxy: BluetoothProfile,
|
private val profileProxy: BluetoothProfile,
|
||||||
private val isConnectedAtomic: AtomicBoolean,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val profile: BluetoothProfile
|
val proxy: BluetoothProfile
|
||||||
get() {
|
get() = profileProxy
|
||||||
if (!isConnected) throw IllegalStateException("Proxy is not connected")
|
|
||||||
return profileProxy
|
|
||||||
}
|
|
||||||
|
|
||||||
val connectedDevices: Set<BluetoothDevice>
|
val connectedDevices: Set<BluetoothDevice>
|
||||||
get() = profile.connectedDevices.toSet()
|
get() = proxy.connectedDevices.toSet()
|
||||||
|
|
||||||
val isConnected: Boolean
|
|
||||||
get() = isConnectedAtomic.get()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class RecorderModule @Inject constructor(
|
|||||||
context.startServiceCompat(Intent(context, RecorderService::class.java))
|
context.startServiceCompat(Intent(context, RecorderService::class.java))
|
||||||
|
|
||||||
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
|
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
|
||||||
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
|
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION_LONG}" }
|
||||||
|
|
||||||
copy(
|
copy(
|
||||||
recorder = newRecorder
|
recorder = newRecorder
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class RecorderActivityVM @Inject constructor(
|
|||||||
type = "application/zip"
|
type = "application/zip"
|
||||||
|
|
||||||
addCategory(Intent.CATEGORY_DEFAULT)
|
addCategory(Intent.CATEGORY_DEFAULT)
|
||||||
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION})")
|
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION_LONG})")
|
||||||
putExtra(Intent.EXTRA_TEXT, "Your text here.")
|
putExtra(Intent.EXTRA_TEXT, "Your text here.")
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ fun <T : Any> Flow<T>.shareLatest(
|
|||||||
)
|
)
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
|
|
||||||
fun <T : Any> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
|
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
replay = 1,
|
replay = 1,
|
||||||
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
|
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ enum class Permission(
|
|||||||
val pwm = it.getSystemService(Context.POWER_SERVICE) as PowerManager
|
val pwm = it.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
pwm.isIgnoringBatteryOptimizations(BuildConfigWrap.APPLICATION_ID)
|
pwm.isIgnoringBatteryOptimizations(BuildConfigWrap.APPLICATION_ID)
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
SYSTEM_ALERT_WINDOW(
|
||||||
|
minApiLevel = Build.VERSION_CODES.BASE,
|
||||||
|
labelRes = R.string.permission_system_alert_window_label,
|
||||||
|
descriptionRes = R.string.permission_system_alert_window_description,
|
||||||
|
permissionId = "android.permission.SYSTEM_ALERT_WINDOW",
|
||||||
|
isGranted = {
|
||||||
|
android.provider.Settings.canDrawOverlays(it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class GeneralSettings @Inject constructor(
|
|||||||
|
|
||||||
val scannerMode = preferences.createFlowPreference(
|
val scannerMode = preferences.createFlowPreference(
|
||||||
"core.scanner.mode",
|
"core.scanner.mode",
|
||||||
ScannerMode.BALANCED,
|
ScannerMode.LOW_LATENCY,
|
||||||
moshi
|
moshi
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
package eu.darken.capod.main.core
|
package eu.darken.capod.main.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dagger.Reusable
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.Permission
|
||||||
import eu.darken.capod.common.permissions.isRequired
|
import eu.darken.capod.common.permissions.isRequired
|
||||||
|
import eu.darken.capod.reaction.settings.ReactionSettings
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Reusable
|
@Singleton
|
||||||
class PermissionTool @Inject constructor(
|
class PermissionTool @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
|
private val reactionSettings: ReactionSettings,
|
||||||
) {
|
) {
|
||||||
|
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
||||||
|
|
||||||
suspend fun missingPermissions(): Set<Permission> = Permission.values()
|
fun recheck() {
|
||||||
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || generalSettings.monitorMode.value == MonitorMode.ALWAYS }
|
log(TAG) { "recheck()" }
|
||||||
.filter { it.isRequired(context) }
|
permissionCheckTrigger.value = UUID.randomUUID()
|
||||||
.toSet()
|
}
|
||||||
|
|
||||||
|
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.ModularAdapter
|
||||||
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
|
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
|
||||||
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
|
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.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.PermissionCardVH
|
||||||
import eu.darken.capod.main.ui.overview.cards.pods.BasicSingleApplePodsCardVH
|
import eu.darken.capod.main.ui.overview.cards.pods.BasicSingleApplePodsCardVH
|
||||||
import eu.darken.capod.main.ui.overview.cards.pods.DualApplePodsCardVH
|
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 SingleApplePodsCardVH.Item }) { SingleApplePodsCardVH(it) })
|
||||||
modules.add(TypedVHCreatorMod({ data[it] is BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(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 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 MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) })
|
||||||
|
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int = data.size
|
override fun getItemCount(): Int = data.size
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
|
|||||||
lateinit var adapter: OverviewAdapter
|
lateinit var adapter: OverviewAdapter
|
||||||
|
|
||||||
lateinit var permissionLauncher: ActivityResultLauncher<String>
|
lateinit var permissionLauncher: ActivityResultLauncher<String>
|
||||||
var awaitingIgnoreBatteryOptimization = false
|
var awaitingPermission = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
awaitingIgnoreBatteryOptimization = savedInstanceState?.getBoolean("awaitingIgnoreBatteryOptimization") ?: false
|
awaitingPermission = savedInstanceState?.getBoolean("awaitingPermission") ?: false
|
||||||
|
|
||||||
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
log { "Request for $id was granted=$granted" }
|
log { "Request for $id was granted=$granted" }
|
||||||
@@ -70,21 +70,35 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vm.listItems.observe2(ui) {
|
vm.listItems.observe2(ui) { adapter.update(it) }
|
||||||
adapter.update(it)
|
|
||||||
|
vm.workerAutolaunch.observe2 {
|
||||||
|
// While UI is active, subscribe to the autolaunch routine
|
||||||
}
|
}
|
||||||
|
|
||||||
vm.requestPermissionEvent.observe2(ui) {
|
vm.requestPermissionEvent.observe2(ui) {
|
||||||
if (it == Permission.IGNORE_BATTERY_OPTIMIZATION) {
|
when (it) {
|
||||||
awaitingIgnoreBatteryOptimization = true
|
Permission.IGNORE_BATTERY_OPTIMIZATION -> {
|
||||||
startActivity(
|
awaitingPermission = true
|
||||||
Intent(
|
startActivity(
|
||||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
Intent(
|
||||||
Uri.parse("package:${requireContext().packageName}")
|
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||||
|
Uri.parse("package:${requireContext().packageName}")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
} else {
|
Permission.SYSTEM_ALERT_WINDOW -> {
|
||||||
permissionLauncher.launch(it.permissionId)
|
awaitingPermission = true
|
||||||
|
startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||||
|
Uri.parse("package:${requireContext().packageName}")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
permissionLauncher.launch(it.permissionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,15 +147,15 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
outState.putBoolean("awaitingIgnoreBatteryOptimization", awaitingIgnoreBatteryOptimization)
|
outState.putBoolean("awaitingPermission", awaitingPermission)
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (awaitingIgnoreBatteryOptimization) {
|
if (awaitingPermission) {
|
||||||
awaitingIgnoreBatteryOptimization = false
|
awaitingPermission = false
|
||||||
log { "awaitingIgnoreBatteryOptimization=true" }
|
log { "awaitingPermission=true" }
|
||||||
vm.onPermissionResult(true)
|
vm.onPermissionResult(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import android.app.Activity
|
|||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import eu.darken.capod.common.WebpageTool
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.debug.autoreport.DebugSettings
|
import eu.darken.capod.common.debug.autoreport.DebugSettings
|
||||||
import eu.darken.capod.common.debug.logging.log
|
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.livedata.SingleLiveEvent
|
||||||
import eu.darken.capod.common.navigation.navVia
|
import eu.darken.capod.common.navigation.navVia
|
||||||
import eu.darken.capod.common.permissions.Permission
|
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.GeneralSettings
|
||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
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.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.PermissionCardVH
|
||||||
import eu.darken.capod.main.ui.overview.cards.pods.*
|
import eu.darken.capod.main.ui.overview.cards.pods.*
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
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.BasicSingleApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -45,6 +46,7 @@ class OverviewFragmentVM @Inject constructor(
|
|||||||
debugSettings: DebugSettings,
|
debugSettings: DebugSettings,
|
||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
|
private val webpageTool: WebpageTool,
|
||||||
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
|
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
|
||||||
|
|
||||||
val upgradeState = upgradeRepo.upgradeInfo.asLiveData2()
|
val upgradeState = upgradeRepo.upgradeInfo.asLiveData2()
|
||||||
@@ -57,9 +59,7 @@ class OverviewFragmentVM @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
val workerAutolaunch: LiveData<Unit> = permissionTool.missingPermissions
|
||||||
private val requiredPermissions: Flow<Collection<Permission>> = permissionCheckTrigger
|
|
||||||
.map { permissionTool.missingPermissions() }
|
|
||||||
.onEach {
|
.onEach {
|
||||||
if (it.isNotEmpty()) {
|
if (it.isNotEmpty()) {
|
||||||
log(TAG) { "Missing permissions: $it" }
|
log(TAG) { "Missing permissions: $it" }
|
||||||
@@ -76,26 +76,24 @@ class OverviewFragmentVM @Inject constructor(
|
|||||||
monitorControl.startMonitor()
|
monitorControl.startMonitor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.map { Unit }
|
||||||
|
.asLiveData2()
|
||||||
|
|
||||||
val requestPermissionEvent = SingleLiveEvent<Permission>()
|
val requestPermissionEvent = SingleLiveEvent<Permission>()
|
||||||
|
|
||||||
private val pods: Flow<List<PodDevice>> = requiredPermissions
|
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
|
||||||
.flatMapLatest { permissions ->
|
.flatMapLatest { permissions ->
|
||||||
if (permissions.isEmpty()) {
|
if (permissions.isNotEmpty()) {
|
||||||
generalSettings.showAll.flow
|
return@flatMapLatest flowOf(emptyList())
|
||||||
.flatMapLatest { showAll ->
|
}
|
||||||
if (showAll) {
|
|
||||||
podMonitor.devices
|
generalSettings.showAll.flow.flatMapLatest { showAll ->
|
||||||
} else {
|
if (showAll) {
|
||||||
podMonitor.mainDevice.map { mainDevice ->
|
podMonitor.devices
|
||||||
mainDevice?.let { listOf(it) } ?: emptyList()
|
} 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(
|
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
|
||||||
updateTicker,
|
updateTicker,
|
||||||
requiredPermissions,
|
permissionTool.missingPermissions,
|
||||||
pods,
|
pods,
|
||||||
debugSettings.isDebugModeEnabled.flow,
|
debugSettings.isDebugModeEnabled.flow,
|
||||||
generalSettings.showAll.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 items = mutableListOf<OverviewAdapter.Item>()
|
||||||
|
|
||||||
val mainPod = podMonitor.mainDevice.first()
|
permissions
|
||||||
val now = Instant.now()
|
.map { perm ->
|
||||||
pods
|
PermissionCardVH.Item(
|
||||||
.map {
|
permission = perm,
|
||||||
|
onRequest = { requestPermissionEvent.postValue(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.run { items.addAll(this) }
|
||||||
|
|
||||||
|
if (permissions.isEmpty()) {
|
||||||
|
if (!isBluetoothEnabled) {
|
||||||
|
items.add(0, BluetoothDisabledVH.Item)
|
||||||
|
} else if (mainPod == null) {
|
||||||
|
items.add(0, MissingMainDeviceVH.Item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.isEmpty() && isBluetoothEnabled) {
|
||||||
|
pods.map {
|
||||||
|
val now = Instant.now()
|
||||||
when (it) {
|
when (it) {
|
||||||
is DualApplePods -> DualApplePodsCardVH.Item(
|
is DualApplePods -> DualApplePodsCardVH.Item(
|
||||||
now = now,
|
now = now,
|
||||||
@@ -140,24 +156,7 @@ class OverviewFragmentVM @Inject constructor(
|
|||||||
isMainPod = it == mainPod,
|
isMainPod = it == mainPod,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}.run { items.addAll(this) }
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
@@ -166,7 +165,7 @@ class OverviewFragmentVM @Inject constructor(
|
|||||||
.asLiveData2()
|
.asLiveData2()
|
||||||
|
|
||||||
fun onPermissionResult(granted: Boolean) {
|
fun onPermissionResult(granted: Boolean) {
|
||||||
if (granted) permissionCheckTrigger.value = UUID.randomUUID()
|
if (granted) permissionTool.recheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToSettings() = launch {
|
fun goToSettings() = launch {
|
||||||
|
|||||||
+8
-10
@@ -4,29 +4,27 @@ import android.view.ViewGroup
|
|||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.lists.binding
|
import eu.darken.capod.common.lists.binding
|
||||||
import eu.darken.capod.common.lists.differ.DifferItem
|
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
|
import eu.darken.capod.main.ui.overview.OverviewAdapter
|
||||||
|
|
||||||
class NoPairedDeviceCardVH(parent: ViewGroup) :
|
class BluetoothDisabledVH(parent: ViewGroup) :
|
||||||
OverviewAdapter.BaseVH<NoPairedDeviceCardVH.Item, OverviewNopaireddeviceItemBinding>(
|
OverviewAdapter.BaseVH<BluetoothDisabledVH.Item, OverviewBluetoothDisabledItemBinding>(
|
||||||
R.layout.overview_nopaireddevice_item,
|
R.layout.overview_bluetooth_disabled_item,
|
||||||
parent
|
parent
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override val viewBinding = lazy {
|
override val viewBinding = lazy {
|
||||||
OverviewNopaireddeviceItemBinding.bind(itemView)
|
OverviewBluetoothDisabledItemBinding.bind(itemView)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val onBindData: OverviewNopaireddeviceItemBinding.(
|
override val onBindData: OverviewBluetoothDisabledItemBinding.(
|
||||||
item: Item,
|
item: Item,
|
||||||
payloads: List<Any>
|
payloads: List<Any>
|
||||||
) -> Unit = binding(payload = true) { item ->
|
) -> Unit = binding(payload = true) { item ->
|
||||||
showAllAction.setOnClickListener { item.onShowAll() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Item(
|
object Item : OverviewAdapter.Item {
|
||||||
val onShowAll: () -> Unit
|
|
||||||
) : OverviewAdapter.Item {
|
|
||||||
override val stableId: Long = Item::class.hashCode().toLong()
|
override val stableId: Long = Item::class.hashCode().toLong()
|
||||||
|
|
||||||
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package eu.darken.capod.main.ui.overview.cards
|
package eu.darken.capod.main.ui.overview.cards
|
||||||
|
|
||||||
|
import android.text.Html
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.PrivacyPolicy
|
||||||
import eu.darken.capod.common.lists.binding
|
import eu.darken.capod.common.lists.binding
|
||||||
import eu.darken.capod.common.lists.differ.DifferItem
|
import eu.darken.capod.common.lists.differ.DifferItem
|
||||||
import eu.darken.capod.common.permissions.Permission
|
import eu.darken.capod.common.permissions.Permission
|
||||||
@@ -25,6 +28,12 @@ class PermissionCardVH(parent: ViewGroup) :
|
|||||||
permissionLabel.setText(item.permission.labelRes)
|
permissionLabel.setText(item.permission.labelRes)
|
||||||
permissionDescription.setText(item.permission.descriptionRes)
|
permissionDescription.setText(item.permission.descriptionRes)
|
||||||
grantAction.setOnClickListener { item.onRequest(item.permission) }
|
grantAction.setOnClickListener { item.onRequest(item.permission) }
|
||||||
|
privacyPolicy.apply {
|
||||||
|
movementMethod = LinkMovementMethod.getInstance()
|
||||||
|
val ppText = getString(R.string.settings_privacy_policy_label)
|
||||||
|
val ppLink = PrivacyPolicy.URL
|
||||||
|
text = Html.fromHtml("<html><a href=\"$ppLink\">$ppText</a></html>", 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Item(
|
data class Item(
|
||||||
|
|||||||
+3
-5
@@ -7,6 +7,7 @@ import eu.darken.capod.R
|
|||||||
import eu.darken.capod.common.lists.binding
|
import eu.darken.capod.common.lists.binding
|
||||||
import eu.darken.capod.databinding.OverviewPodsAppleSingleBasicItemBinding
|
import eu.darken.capod.databinding.OverviewPodsAppleSingleBasicItemBinding
|
||||||
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||||
import eu.darken.capod.pods.core.getBatteryLevelHeadset
|
import eu.darken.capod.pods.core.getBatteryLevelHeadset
|
||||||
import eu.darken.capod.pods.core.getSignalQuality
|
import eu.darken.capod.pods.core.getSignalQuality
|
||||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||||
@@ -38,11 +39,8 @@ class BasicSingleApplePodsCardVH(parent: ViewGroup) :
|
|||||||
reception.append("\n(${getString(R.string.pods_yours)})")
|
reception.append("\n(${getString(R.string.pods_yours)})")
|
||||||
}
|
}
|
||||||
|
|
||||||
headphones.apply {
|
batteryLabel.text = device.getBatteryLevelHeadset(context)
|
||||||
val sb = StringBuilder(context.getString(R.string.pods_single_headphones_label))
|
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
|
||||||
sb.append("\n").append(device.getBatteryLevelHeadset(context))
|
|
||||||
text = sb
|
|
||||||
}
|
|
||||||
|
|
||||||
status.apply {
|
status.apply {
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
|
|||||||
+53
-43
@@ -2,11 +2,11 @@ package eu.darken.capod.main.ui.overview.cards.pods
|
|||||||
|
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.lists.binding
|
import eu.darken.capod.common.lists.binding
|
||||||
import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding
|
import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding
|
||||||
import eu.darken.capod.pods.core.*
|
import eu.darken.capod.pods.core.*
|
||||||
import eu.darken.capod.pods.core.HasDualPods.Pod
|
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor
|
import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||||
@@ -24,61 +24,71 @@ class DualApplePodsCardVH(parent: ViewGroup) :
|
|||||||
val device = item.device
|
val device = item.device
|
||||||
name.apply {
|
name.apply {
|
||||||
val sb = StringBuilder(device.getLabel(context))
|
val sb = StringBuilder(device.getLabel(context))
|
||||||
if (device.deviceColor != DeviceColor.UNKNOWN) {
|
if (!listOf(DeviceColor.WHITE, DeviceColor.UNKNOWN).contains(device.deviceColor)) {
|
||||||
sb.append(" (${device.getDeviceColorLabel(context)})")
|
sb.append(" (${device.deviceColor.name})")
|
||||||
}
|
}
|
||||||
text = sb
|
text = sb
|
||||||
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
|
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
|
||||||
else setTypeface(typeface, Typeface.NORMAL)
|
else setTypeface(typeface, Typeface.NORMAL)
|
||||||
|
if (item.showDebug) {
|
||||||
|
append(" [${device.primaryPod.name}]")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
deviceIcon.setImageResource(device.iconRes)
|
deviceIcon.setImageResource(device.iconRes)
|
||||||
|
|
||||||
lastSeen.text = device.lastSeenFormatted(item.now)
|
lastSeen.text = device.lastSeenFormatted(item.now)
|
||||||
|
|
||||||
reception.text = device.getSignalQuality(context)
|
reception.text = device.getSignalQuality(context).let {
|
||||||
if (item.isMainPod) {
|
if (item.isMainPod) "$it\n(${getString(R.string.pods_yours)})" else it
|
||||||
reception.append("\n(${getString(R.string.pods_yours)})")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
podLeft.apply {
|
// Left Pod
|
||||||
val sb = StringBuilder(context.getString(R.string.pods_dual_left_label))
|
device.apply {
|
||||||
sb.append("\n").append(device.getBatteryLevelLeftPod(context))
|
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
|
||||||
when {
|
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
|
||||||
device.isLeftPodCharging -> sb.append("\n").append(getString(R.string.pods_charging_label))
|
|
||||||
device.isLeftPodInEar -> sb.append("\n").append(getString(R.string.pods_inear_label))
|
podLeftChargingIcon.isInvisible = !isLeftPodCharging
|
||||||
else -> {}
|
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 hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
|
||||||
val sb = StringBuilder(getString(R.string.pods_dual_right_label))
|
podCaseLidIcon.isInvisible = hideInfo
|
||||||
sb.append("\n").append(device.getBatteryLevelRightPod(context))
|
podCaseLidLabel.isInvisible = hideInfo
|
||||||
when {
|
|
||||||
device.isRightPodCharging -> sb.append("\n").append(getString(R.string.pods_charging_label))
|
|
||||||
device.isRightPodInEar -> sb.append("\n").append(getString(R.string.pods_inear_label))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
text = sb
|
|
||||||
}
|
|
||||||
|
|
||||||
when (device.microPhonePod) {
|
|
||||||
Pod.LEFT -> podLeft.append("\n(${context.getString(R.string.pods_microphone_label)})")
|
|
||||||
Pod.RIGHT -> podRight.append("\n(${context.getString(R.string.pods_microphone_label)})")
|
|
||||||
}
|
|
||||||
|
|
||||||
podCase.apply {
|
|
||||||
val sb = StringBuilder(getString(R.string.pods_case_label))
|
|
||||||
sb.append("\n").append(device.getBatteryLevelCase(context))
|
|
||||||
if (device.isCaseCharging) sb.append("\n").append(getString(R.string.pods_charging_label))
|
|
||||||
when (device.caseLidState) {
|
|
||||||
LidState.OPEN -> sb.append("\n").append(context.getString(R.string.pods_case_status_open_label))
|
|
||||||
LidState.CLOSED -> sb.append("\n").append(context.getString(R.string.pods_case_status_closed_label))
|
|
||||||
LidState.NOT_IN_CASE,
|
|
||||||
LidState.UNKNOWN -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text = sb
|
|
||||||
}
|
}
|
||||||
|
|
||||||
status.apply {
|
status.apply {
|
||||||
|
|||||||
+10
-10
@@ -3,10 +3,12 @@ package eu.darken.capod.main.ui.overview.cards.pods
|
|||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.lists.binding
|
import eu.darken.capod.common.lists.binding
|
||||||
import eu.darken.capod.databinding.OverviewPodsAppleSingleItemBinding
|
import eu.darken.capod.databinding.OverviewPodsAppleSingleItemBinding
|
||||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||||
import eu.darken.capod.pods.core.getBatteryLevelHeadset
|
import eu.darken.capod.pods.core.getBatteryLevelHeadset
|
||||||
import eu.darken.capod.pods.core.getSignalQuality
|
import eu.darken.capod.pods.core.getSignalQuality
|
||||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||||
@@ -38,16 +40,14 @@ class SingleApplePodsCardVH(parent: ViewGroup) :
|
|||||||
reception.append("\n(${getString(R.string.pods_yours)})")
|
reception.append("\n(${getString(R.string.pods_yours)})")
|
||||||
}
|
}
|
||||||
|
|
||||||
headphones.apply {
|
batteryLabel.text = device.getBatteryLevelHeadset(context)
|
||||||
val sb = StringBuilder(context.getString(R.string.pods_single_headphones_label))
|
batteryIcon.setImageResource(getBatteryDrawable(device.batteryHeadsetPercent))
|
||||||
sb.append("\n").append(device.getBatteryLevelHeadset(context))
|
|
||||||
when {
|
chargingIcon.isInvisible = device.isHeadsetBeingCharged
|
||||||
device.isHeadsetBeingCharged -> sb.append("\n").append("Charging")
|
chargingLabel.isInvisible = device.isHeadsetBeingCharged
|
||||||
device.isHeadphonesBeingWorn -> sb.append("\n").append("In ear")
|
|
||||||
else -> {}
|
wearIcon.isInvisible = device.isHeadphonesBeingWorn
|
||||||
}
|
wearLabel.isInvisible = device.isHeadphonesBeingWorn
|
||||||
text = sb
|
|
||||||
}
|
|
||||||
|
|
||||||
status.apply {
|
status.apply {
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class SettingsFragment : Fragment2(R.layout.settings_fragment),
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui.toolbar.apply {
|
ui.toolbar.apply {
|
||||||
subtitle = BuildConfigWrap.VERSION_DESCRIPTION
|
subtitle = BuildConfigWrap.VERSION_DESCRIPTION_SHORT
|
||||||
setNavigationOnClickListener { requireActivity().onBackPressed() }
|
setNavigationOnClickListener { requireActivity().onBackPressed() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import eu.darken.androidstarter.common.preferences.Settings
|
import eu.darken.androidstarter.common.preferences.Settings
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
|
import eu.darken.capod.common.PrivacyPolicy
|
||||||
import eu.darken.capod.common.WebpageTool
|
import eu.darken.capod.common.WebpageTool
|
||||||
import eu.darken.capod.common.uix.PreferenceFragment2
|
import eu.darken.capod.common.uix.PreferenceFragment2
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
@@ -34,7 +35,11 @@ class SettingsIndexFragment : PreferenceFragment2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreferencesCreated() {
|
override fun onPreferencesCreated() {
|
||||||
findPreference<Preference>("core.changelog")!!.summary = BuildConfigWrap.VERSION_DESCRIPTION
|
findPreference<Preference>("core.changelog")!!.summary = BuildConfigWrap.VERSION_DESCRIPTION_LONG
|
||||||
|
findPreference<Preference>("core.privacy")!!.setOnPreferenceClickListener {
|
||||||
|
webpageTool.open(PrivacyPolicy.URL)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
super.onPreferencesCreated()
|
super.onPreferencesCreated()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class SupportFragmentVM @Inject constructor(
|
|||||||
|
|
||||||
bodyInfo.append("--- Infos for the developer ---\n")
|
bodyInfo.append("--- Infos for the developer ---\n")
|
||||||
|
|
||||||
bodyInfo.append("App version: ").append(BuildConfigWrap.VERSION_DESCRIPTION).append("\n")
|
bodyInfo.append("App version: ").append(BuildConfigWrap.VERSION_DESCRIPTION_LONG).append("\n")
|
||||||
|
|
||||||
bodyInfo.append("Device: ").append(Build.FINGERPRINT).append("\n")
|
bodyInfo.append("Device: ").append(Build.FINGERPRINT).append("\n")
|
||||||
bodyInfo.append("Install ID: ").append(installId.id).append("\n")
|
bodyInfo.append("Install ID: ").append(installId.id).append("\n")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package eu.darken.capod.monitor.core
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.bluetooth.BleScanner
|
import eu.darken.capod.common.bluetooth.BleScanner
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
@@ -9,9 +10,11 @@ import eu.darken.capod.common.debug.logging.log
|
|||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.replayingShare
|
import eu.darken.capod.common.flow.replayingShare
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
|
import eu.darken.capod.main.core.ScannerMode
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.PodFactory
|
import eu.darken.capod.pods.core.PodFactory
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -32,34 +35,44 @@ class PodMonitor @Inject constructor(
|
|||||||
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
|
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
|
||||||
private val cacheLock = Mutex()
|
private val cacheLock = Mutex()
|
||||||
|
|
||||||
val devices: Flow<List<PodDevice>> = generalSettings.scannerMode.flow
|
private suspend fun List<BleScanResult>.preFilterAndMap(
|
||||||
.flatMapLatest { scannerMode ->
|
scannerMode: ScannerMode
|
||||||
bluetoothManager.isBluetoothEnabled.flatMapLatest { isBluetoothEnabled ->
|
): List<PodDevice> = this
|
||||||
if (isBluetoothEnabled) {
|
.groupBy { it.address }
|
||||||
bleScanner.scan(scannerMode = scannerMode)
|
.values
|
||||||
} else {
|
.map { sameAdrDevs ->
|
||||||
log(TAG, WARN) { "Bluetooth is current disabled" }
|
// For each address we only want the newest result, upstream may batch data
|
||||||
emptyFlow()
|
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 ->
|
.map { newPods ->
|
||||||
// For each address we only want the newest result, upstream may batch data
|
|
||||||
result.groupBy { it.address }
|
|
||||||
.values
|
|
||||||
.map { sameAdrDevs ->
|
|
||||||
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
|
|
||||||
log(TAG, VERBOSE) { "Discarding stale results: ${sameAdrDevs.minus(newest)}" }
|
|
||||||
newest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.map { scanResults ->
|
|
||||||
val newPods = scanResults
|
|
||||||
.mapNotNull { podFactory.createPod(it) }
|
|
||||||
|
|
||||||
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
|
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
|
||||||
|
|
||||||
cacheLock.withLock {
|
cacheLock.withLock {
|
||||||
|
if (newPods == null) {
|
||||||
|
log(TAG) { "Null result, Bluetooth is disabled." }
|
||||||
|
deviceCache.clear()
|
||||||
|
return@map emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
deviceCache.toList().forEach { (key, value) ->
|
deviceCache.toList().forEach { (key, value) ->
|
||||||
if (Duration.between(value.lastSeenAt, now) > Duration.ofSeconds(20)) {
|
if (Duration.between(value.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()
|
val now = Instant.now()
|
||||||
aboveThreshold.sortedWith(compareBy<PodDevice> {
|
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
|
||||||
Duration.between(
|
val mainDeviceModel = generalSettings.mainDeviceModel.value
|
||||||
it.lastSeenAt,
|
|
||||||
now
|
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 })
|
val belowThresholdSorted = pods.values
|
||||||
.plus(belowThreshold.sortedWith(compareBy<PodDevice> {
|
.filter { it.signalQuality <= minimumSignalQuality }
|
||||||
Duration.between(
|
.sortedWith(
|
||||||
it.lastSeenAt,
|
compareBy<PodDevice> { Duration.between(it.lastSeenAt, now) }.thenByDescending { it.signalQuality }
|
||||||
now
|
)
|
||||||
)
|
|
||||||
}.thenByDescending { it.rssi }))
|
val presorted = (aboveTresholdSorted + belowThresholdSorted)
|
||||||
// pods.values.sortedByDescending { it.rssi }
|
val main = presorted.determineMainDevice()
|
||||||
|
presorted.sortedByDescending { it == main }
|
||||||
}
|
}
|
||||||
.onStart { emit(emptyList()) }
|
.onStart { emit(emptyList()) }
|
||||||
.catch {
|
.retryWhen { cause, attempt ->
|
||||||
log(TAG, ERROR) { "PodMonitor failed:\n${it.asLog()}" }
|
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||||
|
delay(3000)
|
||||||
|
true
|
||||||
}
|
}
|
||||||
.replayingShare(appScope)
|
.replayingShare(appScope)
|
||||||
|
|
||||||
val mainDevice: Flow<PodDevice?>
|
val mainDevice: Flow<PodDevice?>
|
||||||
get() = devices.map { devices ->
|
get() = devices
|
||||||
devices.maxByOrNull { it.rssi }?.let filter@{ device ->
|
.map { it.determineMainDevice() }
|
||||||
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
|
.replayingShare(appScope)
|
||||||
|
|
||||||
generalSettings.mainDeviceModel.value.let {
|
private fun List<PodDevice>.determineMainDevice(): PodDevice? = this.firstOrNull()?.let {
|
||||||
if (device.model != it && it != PodDevice.Model.UNKNOWN) return@filter null
|
val mainDeviceModel = generalSettings.mainDeviceModel.value
|
||||||
}
|
when {
|
||||||
|
it.model == PodDevice.Model.UNKNOWN -> null
|
||||||
if (device.signalQuality > minimumSignalQuality) device else null
|
mainDeviceModel != PodDevice.Model.UNKNOWN && it.model != mainDeviceModel -> null
|
||||||
}
|
it.signalQuality <= generalSettings.minimumSignalQuality.value -> null
|
||||||
|
else -> it
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Monitor", "PodMonitor")
|
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.core.*
|
||||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||||
import eu.darken.capod.reaction.ReactionHub
|
import eu.darken.capod.reaction.ReactionHub
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.cancelChildren
|
import kotlinx.coroutines.cancelChildren
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -68,15 +69,19 @@ class MonitorWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
Result.success(inputData)
|
Result.success(inputData)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Bugs.report(tag = TAG, "Execution failed", exception = e)
|
if (e !is CancellationException) {
|
||||||
finishedWithError = true
|
Bugs.report(tag = TAG, "Execution failed", exception = e)
|
||||||
Result.failure(inputData)
|
finishedWithError = true
|
||||||
|
Result.failure(inputData)
|
||||||
|
} else {
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
|
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun doDoWork() {
|
private suspend fun doDoWork() {
|
||||||
val permissionsMissingOnStart = permissionTool.missingPermissions()
|
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
|
||||||
if (permissionsMissingOnStart.isNotEmpty()) {
|
if (permissionsMissingOnStart.isNotEmpty()) {
|
||||||
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
|
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
|
||||||
return
|
return
|
||||||
@@ -99,7 +104,7 @@ class MonitorWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
generalSettings.monitorMode.flow
|
generalSettings.monitorMode.flow
|
||||||
.flatMapLatest { monitorMode ->
|
.flatMapLatest { monitorMode ->
|
||||||
val missingPermsFlow = permissionTool.missingPermissions()
|
val missingPermsFlow = permissionTool.missingPermissions.first()
|
||||||
if (missingPermsFlow.isNotEmpty()) {
|
if (missingPermsFlow.isNotEmpty()) {
|
||||||
log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" }
|
log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" }
|
||||||
workerScope.coroutineContext.cancelChildren()
|
workerScope.coroutineContext.cancelChildren()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eu.darken.capod.monitor.ui
|
package eu.darken.capod.monitor.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.view.View
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
@@ -26,38 +27,45 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
context.packageName,
|
context.packageName,
|
||||||
R.layout.monitor_notification_dual_pods_small
|
R.layout.monitor_notification_dual_pods_small
|
||||||
).apply {
|
).apply {
|
||||||
device.getBatteryLevelLeftPod(context)
|
device.apply {
|
||||||
.let { if (device.isLeftPodCharging) "$it⚡" else it }
|
// Left
|
||||||
.run { setTextViewText(R.id.pod_left, this) }
|
setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context))
|
||||||
|
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||||
|
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
|
||||||
|
|
||||||
device.getBatteryLevelCase(context)
|
// Case
|
||||||
.let { if (device.isCaseCharging) "$it⚡" else it }
|
setTextViewText(R.id.pod_case_label, getBatteryLevelCase(context))
|
||||||
.run { setTextViewText(R.id.pod_case, this) }
|
setViewVisibility(R.id.pod_case_charging, if (isCaseCharging) View.VISIBLE else View.GONE)
|
||||||
|
|
||||||
device.getBatteryLevelRightPod(context)
|
// Right
|
||||||
.let { if (device.isRightPodCharging) "$it⚡" else it }
|
setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
|
||||||
.run { setTextViewText(R.id.pod_right, this) }
|
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||||
|
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSingleApplePods(device: SingleApplePods): RemoteViews = RemoteViews(
|
private fun createSingleApplePods(device: SingleApplePods): RemoteViews = RemoteViews(
|
||||||
context.packageName,
|
context.packageName,
|
||||||
R.layout.monitor_notification_single_pods_small
|
R.layout.monitor_notification_single_pods_small
|
||||||
).apply {
|
).apply {
|
||||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
device.apply {
|
||||||
|
setTextViewText(R.id.headphones_label, getLabel(context))
|
||||||
device.getBatteryLevelHeadset(context)
|
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
|
||||||
.let { if (!device.isHeadsetBeingCharged) "$it⚡" else it }
|
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
|
||||||
.run { setTextViewText(R.id.headphones, this) }
|
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
|
||||||
|
setViewVisibility(R.id.headphones_worn, if (isHeadphonesBeingWorn) View.VISIBLE else View.GONE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSingleBasicApplePods(device: BasicSingleApplePods): RemoteViews = RemoteViews(
|
private fun createSingleBasicApplePods(device: BasicSingleApplePods): RemoteViews = RemoteViews(
|
||||||
context.packageName,
|
context.packageName,
|
||||||
R.layout.monitor_notification_single_pods_basic_small
|
R.layout.monitor_notification_single_pods_basic_small
|
||||||
).apply {
|
).apply {
|
||||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
device.apply {
|
||||||
|
setTextViewText(R.id.headphones_label, getLabel(context))
|
||||||
device.getBatteryLevelHeadset(context)
|
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
|
||||||
.run { setTextViewText(R.id.headphones, this) }
|
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
|
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package eu.darken.capod.pods.core
|
||||||
|
|
||||||
|
interface HasDualEarDetection : HasEarDetection {
|
||||||
|
|
||||||
|
val isLeftPodInEar: Boolean
|
||||||
|
|
||||||
|
val isRightPodInEar: Boolean
|
||||||
|
|
||||||
|
val isEitherPodInEar: Boolean
|
||||||
|
get() = isLeftPodInEar || isRightPodInEar
|
||||||
|
|
||||||
|
override val isBeingWorn: Boolean
|
||||||
|
get() = isLeftPodInEar && isRightPodInEar
|
||||||
|
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
interface PodDevice {
|
interface PodDevice {
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ interface PodDevice {
|
|||||||
|
|
||||||
val model: Model
|
val model: Model
|
||||||
|
|
||||||
|
val address: String
|
||||||
|
get() = scanResult.address
|
||||||
|
|
||||||
val lastSeenAt: Instant
|
val lastSeenAt: Instant
|
||||||
|
|
||||||
val scanResult: BleScanResult
|
val scanResult: BleScanResult
|
||||||
@@ -23,12 +27,14 @@ interface PodDevice {
|
|||||||
val rssi: Int
|
val rssi: Int
|
||||||
get() = scanResult.rssi
|
get() = scanResult.rssi
|
||||||
|
|
||||||
|
val confidence: Float
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is not correct but it works ¯\_(ツ)_/¯
|
* This is not correct but it works ¯\_(ツ)_/¯
|
||||||
* The range of the RSSI is device specific (ROMs).
|
* The range of the RSSI is device specific (ROMs).
|
||||||
*/
|
*/
|
||||||
val signalQuality: Float
|
val signalQuality: Float
|
||||||
get() = (100 - abs(rssi)) / 100f
|
get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
|
||||||
|
|
||||||
val rawData: ByteArray
|
val rawData: ByteArray
|
||||||
|
|
||||||
@@ -91,4 +97,8 @@ interface PodDevice {
|
|||||||
"Unknown"
|
"Unknown"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BASE_CONFIDENCE = 0.5f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package eu.darken.capod.pods.core
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.icu.text.RelativeDateTimeFormatter
|
import android.icu.text.RelativeDateTimeFormatter
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -28,6 +29,19 @@ fun PodDevice.getSignalQuality(context: Context): String {
|
|||||||
return "~${percentage.roundToInt()}%"
|
return "~${percentage.roundToInt()}%"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun getBatteryDrawable(percent: Float?): Int = when {
|
||||||
|
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
||||||
|
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||||
|
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
||||||
|
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
||||||
|
percent > 0.50f -> R.drawable.ic_baseline_battery_4_bar_24
|
||||||
|
percent > 0.35f -> R.drawable.ic_baseline_battery_3_bar_24
|
||||||
|
percent > 0.20f -> R.drawable.ic_baseline_battery_2_bar_24
|
||||||
|
percent > 0.05f -> R.drawable.ic_baseline_battery_1_bar_24
|
||||||
|
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||||
|
}
|
||||||
|
|
||||||
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
|
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
|
||||||
|
|
||||||
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import javax.inject.Singleton
|
|||||||
class AppleFactory @Inject constructor(
|
class AppleFactory @Inject constructor(
|
||||||
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
|
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
|
||||||
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
||||||
private val podFactories: @JvmSuppressWildcards Set<ApplePods.Factory>,
|
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
|
||||||
private val unknownAppleFactory: UnknownAppleDevice.Factory,
|
private val unknownAppleFactory: UnknownAppleDevice.Factory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -57,24 +57,27 @@ class AppleFactory @Inject constructor(
|
|||||||
|
|
||||||
log(TAG, INFO) { "Decoding $scanResult" }
|
log(TAG, INFO) { "Decoding $scanResult" }
|
||||||
|
|
||||||
var factory = podFactories.firstOrNull { it.isResponsible(pm) }
|
val factory = podFactories.firstOrNull { it.isResponsible(pm) }
|
||||||
|
|
||||||
if (factory == null) {
|
val device = (factory ?: unknownAppleFactory).create(
|
||||||
|
scanResult = scanResult,
|
||||||
|
proximityMessage = pm,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (factory == null && !SILENCED_PMS.contains(device.rawDeviceModel) && scanResult.address != "6E:9E:D1:49:D2:6D") {
|
||||||
|
SILENCED_PMS.add(device.rawDeviceModel)
|
||||||
Bugs.report(
|
Bugs.report(
|
||||||
tag = TAG,
|
tag = TAG,
|
||||||
message = "Unknown proximity message type",
|
message = "Unknown proximity message type",
|
||||||
exception = IllegalArgumentException("Unknown ProximityMessage: $pm")
|
exception = IllegalArgumentException("Unknown ProximityMessage: $pm")
|
||||||
)
|
)
|
||||||
factory = unknownAppleFactory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return factory.create(
|
return@withLock device
|
||||||
scanResult = scanResult,
|
|
||||||
proximityMessage = pm,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private val SILENCED_PMS = mutableSetOf<UShort>()
|
||||||
private val TAG = logTag("Pod", "Apple", "Factory")
|
private val TAG = logTag("Pod", "Apple", "Factory")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,17 +12,17 @@ import eu.darken.capod.pods.core.apple.beats.*
|
|||||||
@Module
|
@Module
|
||||||
abstract class AppleFactoryModule {
|
abstract class AppleFactoryModule {
|
||||||
|
|
||||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
|
||||||
|
|
||||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
|
||||||
|
|
||||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
|
||||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePods.Factory
|
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
package eu.darken.capod.pods.core.apple
|
package eu.darken.capod.pods.core.apple
|
||||||
|
|
||||||
import eu.darken.capod.common.SystemClockWrap
|
import eu.darken.capod.common.lowerNibble
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.toHex
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
import eu.darken.capod.common.upperNibble
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Duration
|
|
||||||
|
|
||||||
interface ApplePods : PodDevice {
|
interface ApplePods : PodDevice {
|
||||||
|
|
||||||
@@ -27,11 +24,20 @@ interface ApplePods : PodDevice {
|
|||||||
val rawStatus: UByte
|
val rawStatus: UByte
|
||||||
get() = proximityMessage.data[3]
|
get() = proximityMessage.data[3]
|
||||||
|
|
||||||
|
val rawStatusHex: String
|
||||||
|
get() = rawStatus.toHex()
|
||||||
|
|
||||||
val rawPodsBattery: UByte
|
val rawPodsBattery: UByte
|
||||||
get() = proximityMessage.data[4]
|
get() = proximityMessage.data[4]
|
||||||
|
|
||||||
val rawCaseBattery: UByte
|
val rawPodsBatteryHex: String
|
||||||
get() = proximityMessage.data[5]
|
get() = rawPodsBattery.toHex()
|
||||||
|
|
||||||
|
val rawFlags: UShort
|
||||||
|
get() = proximityMessage.data[5].upperNibble
|
||||||
|
|
||||||
|
val rawCaseBattery: UShort
|
||||||
|
get() = proximityMessage.data[5].lowerNibble
|
||||||
|
|
||||||
val rawCaseLidState: UByte
|
val rawCaseLidState: UByte
|
||||||
get() = proximityMessage.data[6]
|
get() = proximityMessage.data[6]
|
||||||
@@ -43,102 +49,4 @@ interface ApplePods : PodDevice {
|
|||||||
get() = proximityMessage.data[8]
|
get() = proximityMessage.data[8]
|
||||||
|
|
||||||
|
|
||||||
abstract class Factory(private val tag: String) {
|
|
||||||
|
|
||||||
internal data class ValueCache(
|
|
||||||
val caseBatteryPercentage: Float?
|
|
||||||
)
|
|
||||||
|
|
||||||
internal val cachedValues = mutableMapOf<PodDevice.Id, ValueCache>()
|
|
||||||
|
|
||||||
internal data class KnownDevice(
|
|
||||||
val identifier: PodDevice.Id,
|
|
||||||
val scanResult: BleScanResult,
|
|
||||||
val message: ProximityPairing.Message,
|
|
||||||
) {
|
|
||||||
val address: String
|
|
||||||
get() = scanResult.address
|
|
||||||
|
|
||||||
val rssi: Int
|
|
||||||
get() = scanResult.rssi
|
|
||||||
|
|
||||||
val timestampNanos: Duration
|
|
||||||
get() = Duration.ofNanos(scanResult.generatedAtNanos)
|
|
||||||
|
|
||||||
fun isOlderThan(age: Duration): Boolean {
|
|
||||||
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
|
|
||||||
return now - timestampNanos > age
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val knownDevs = mutableMapOf<PodDevice.Id, KnownDevice>()
|
|
||||||
|
|
||||||
internal fun recognizeDevice(scanResult: BleScanResult, message: ProximityPairing.Message): PodDevice.Id {
|
|
||||||
val address = scanResult.address
|
|
||||||
|
|
||||||
var identifier: PodDevice.Id? = null
|
|
||||||
|
|
||||||
knownDevs.values.firstOrNull { it.address == address }?.let {
|
|
||||||
log(tag, VERBOSE) { "recognizeDevice: Recovered previous ID via address: $it" }
|
|
||||||
knownDevs[it.identifier] = it.copy(
|
|
||||||
scanResult = scanResult,
|
|
||||||
message = message,
|
|
||||||
)
|
|
||||||
identifier = it.identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
if (identifier == null) {
|
|
||||||
val currentMarkers = message.getRecogMarkers()
|
|
||||||
knownDevs.values
|
|
||||||
.firstOrNull { it.message.getRecogMarkers() == currentMarkers }
|
|
||||||
?.let {
|
|
||||||
log(tag, DEBUG) { "recognizeDevice: Close match based similarity markers." }
|
|
||||||
log(tag, DEBUG) { "recognizeDevice: Old marker: ${it.message.getRecogMarkers()}" }
|
|
||||||
log(tag, DEBUG) { "recognizeDevice: New marker: $currentMarkers" }
|
|
||||||
knownDevs[it.identifier] = it.copy(
|
|
||||||
scanResult = scanResult,
|
|
||||||
message = message,
|
|
||||||
)
|
|
||||||
identifier = it.identifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (identifier == null) {
|
|
||||||
log(tag, VERBOSE) { "recognizeDevice: Mapping as new device" }
|
|
||||||
identifier = PodDevice.Id()
|
|
||||||
}
|
|
||||||
|
|
||||||
knownDevs[identifier!!] = KnownDevice(
|
|
||||||
identifier = identifier!!,
|
|
||||||
scanResult = scanResult,
|
|
||||||
message = message
|
|
||||||
)
|
|
||||||
|
|
||||||
knownDevs.values.toList().forEach { knownDevice ->
|
|
||||||
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
|
|
||||||
log(tag, VERBOSE) { "recognizeDevice: Removing stale known device: $knownDevice" }
|
|
||||||
knownDevs.remove(knownDevice.identifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return identifier!!
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ModelInfo(
|
|
||||||
val full: UShort,
|
|
||||||
val dirty: UByte,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
|
|
||||||
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
|
|
||||||
dirty = data[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
|
|
||||||
|
|
||||||
abstract fun create(
|
|
||||||
scanResult: BleScanResult,
|
|
||||||
proximityMessage: ProximityPairing.Message,
|
|
||||||
): ApplePods
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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
|
package eu.darken.capod.pods.core.apple
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Range
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
@@ -9,23 +8,30 @@ import eu.darken.capod.common.isBitSet
|
|||||||
import eu.darken.capod.common.lowerNibble
|
import eu.darken.capod.common.lowerNibble
|
||||||
import eu.darken.capod.common.upperNibble
|
import eu.darken.capod.common.upperNibble
|
||||||
import eu.darken.capod.pods.core.HasCase
|
import eu.darken.capod.pods.core.HasCase
|
||||||
|
import eu.darken.capod.pods.core.HasDualEarDetection
|
||||||
import eu.darken.capod.pods.core.HasDualPods
|
import eu.darken.capod.pods.core.HasDualPods
|
||||||
import eu.darken.capod.pods.core.HasDualPods.Pod
|
import eu.darken.capod.pods.core.HasDualPods.Pod
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
|
||||||
|
|
||||||
interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
|
interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase {
|
||||||
|
|
||||||
val microPhonePod: Pod
|
val primaryPod: Pod
|
||||||
get() = when (rawStatus.isBitSet(5)) {
|
get() = when (rawStatus.isBitSet(5)) {
|
||||||
true -> Pod.LEFT
|
true -> Pod.LEFT
|
||||||
false -> Pod.RIGHT
|
false -> Pod.RIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
|
||||||
|
* If the right pod is the primary, the values are flipped.
|
||||||
|
*/
|
||||||
|
val areValuesFlipped: Boolean
|
||||||
|
get() = !rawStatus.isBitSet(5)
|
||||||
|
|
||||||
override val batteryLeftPodPercent: Float?
|
override val batteryLeftPodPercent: Float?
|
||||||
get() {
|
get() {
|
||||||
val value = when (microPhonePod) {
|
val value = when (areValuesFlipped) {
|
||||||
Pod.LEFT -> rawPodsBattery.lowerNibble.toInt()
|
true -> rawPodsBattery.upperNibble.toInt()
|
||||||
Pod.RIGHT -> rawPodsBattery.upperNibble.toInt()
|
false -> rawPodsBattery.lowerNibble.toInt()
|
||||||
}
|
}
|
||||||
return when (value) {
|
return when (value) {
|
||||||
15 -> null
|
15 -> null
|
||||||
@@ -40,9 +46,9 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
|
|||||||
|
|
||||||
override val batteryRightPodPercent: Float?
|
override val batteryRightPodPercent: Float?
|
||||||
get() {
|
get() {
|
||||||
val value = when (microPhonePod) {
|
val value = when (areValuesFlipped) {
|
||||||
Pod.LEFT -> rawPodsBattery.upperNibble.toInt()
|
true -> rawPodsBattery.lowerNibble.toInt()
|
||||||
Pod.RIGHT -> rawPodsBattery.lowerNibble.toInt()
|
false -> rawPodsBattery.upperNibble.toInt()
|
||||||
}
|
}
|
||||||
return when (value) {
|
return when (value) {
|
||||||
15 -> null
|
15 -> null
|
||||||
@@ -55,35 +61,55 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isLeftPodInEar: Boolean
|
val isThisPodInThecase: Boolean
|
||||||
get() = when (microPhonePod) {
|
get() = rawStatus.isBitSet(6)
|
||||||
Pod.LEFT -> rawStatus.isBitSet(1)
|
|
||||||
Pod.RIGHT -> rawStatus.isBitSet(3)
|
val isOnePodInCase: Boolean
|
||||||
|
get() = rawStatus.isBitSet(4)
|
||||||
|
|
||||||
|
val areBothPodsInCase: Boolean
|
||||||
|
get() = rawStatus.isBitSet(2)
|
||||||
|
|
||||||
|
override val isLeftPodInEar: Boolean
|
||||||
|
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||||
|
true -> rawStatus.isBitSet(3)
|
||||||
|
false -> rawStatus.isBitSet(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isRightPodInEar: Boolean
|
override val isRightPodInEar: Boolean
|
||||||
get() = when (microPhonePod) {
|
get() = when (areValuesFlipped xor isThisPodInThecase) {
|
||||||
Pod.LEFT -> rawStatus.isBitSet(3)
|
true -> rawStatus.isBitSet(1)
|
||||||
Pod.RIGHT -> rawStatus.isBitSet(1)
|
false -> rawStatus.isBitSet(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val isBeingWorn: Boolean
|
/**
|
||||||
get() = isLeftPodInEar && isRightPodInEar
|
* The data flip bit is set if the left pod is primary.
|
||||||
|
* For the pod that is in the case, this is flipped again though.
|
||||||
|
*/
|
||||||
|
val isLeftPodMicrophone: Boolean
|
||||||
|
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data flip bit is UNset if the right pod is primary.
|
||||||
|
* For the pod that is in the case, this is flipped again though.
|
||||||
|
*/
|
||||||
|
val isRightPodMicrophone: Boolean
|
||||||
|
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
|
||||||
|
|
||||||
val isLeftPodCharging: Boolean
|
val isLeftPodCharging: Boolean
|
||||||
get() = when (microPhonePod) {
|
get() = when (areValuesFlipped) {
|
||||||
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0)
|
false -> rawFlags.isBitSet(0)
|
||||||
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1)
|
true -> rawFlags.isBitSet(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isRightPodCharging: Boolean
|
val isRightPodCharging: Boolean
|
||||||
get() = when (microPhonePod) {
|
get() = when (areValuesFlipped) {
|
||||||
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1)
|
false -> rawFlags.isBitSet(1)
|
||||||
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0)
|
true -> rawFlags.isBitSet(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) {
|
get() = when (val value = rawCaseBattery.toInt()) {
|
||||||
15 -> null
|
15 -> null
|
||||||
else -> if (value > 10) {
|
else -> if (value > 10) {
|
||||||
log { "Case: Above 100% battery: $value" }
|
log { "Case: Above 100% battery: $value" }
|
||||||
@@ -94,11 +120,12 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override val isCaseCharging: Boolean
|
override val isCaseCharging: Boolean
|
||||||
get() = rawCaseBattery.upperNibble.isBitSet(2)
|
get() = rawFlags.isBitSet(2)
|
||||||
|
|
||||||
val caseLidState: LidState
|
val caseLidState: LidState
|
||||||
get() {
|
get() {
|
||||||
return LidState.values().firstOrNull { it.rawRange.contains(rawCaseLidState) } ?: LidState.UNKNOWN
|
val rawstate = rawCaseLidState
|
||||||
|
return LidState.values().firstOrNull { it.rawRange.contains(rawstate.toInt()) } ?: LidState.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,39 +136,34 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
|
|||||||
* They reset after some time to their start values.
|
* They reset after some time to their start values.
|
||||||
* The upper limits are not the maximums but are only reached if playing with the case.
|
* The upper limits are not the maximums but are only reached if playing with the case.
|
||||||
*/
|
*/
|
||||||
enum class LidState(val rawRange: Range<UByte>) {
|
enum class LidState(val rawRange: IntRange) {
|
||||||
OPEN(0x30, 0x37),
|
OPEN(0x30..0x37),
|
||||||
CLOSED(0x38, 0x3F),
|
CLOSED(0x38..0x3F),
|
||||||
NOT_IN_CASE(0x00, 0x03),
|
NOT_IN_CASE(0x00..0x03),
|
||||||
UNKNOWN(0xFF, 0xFF);
|
UNKNOWN(0xFF..0xFF);
|
||||||
|
|
||||||
constructor(vararg raw: Int) : this(Range(raw[0].toUByte(), (raw[1].toUByte())))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val deviceColor: DeviceColor
|
val deviceColor: DeviceColor
|
||||||
get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN
|
get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN
|
||||||
|
|
||||||
|
enum class DeviceColor(val raw: UByte?) {
|
||||||
|
|
||||||
fun getDeviceColorLabel(context: Context): String = context.getString(deviceColor.labelRes)
|
WHITE(0x00),
|
||||||
|
BLACK(0x01),
|
||||||
|
RED(0x02),
|
||||||
|
BLUE(0x03),
|
||||||
|
PINK(0x04),
|
||||||
|
GRAY(0x05),
|
||||||
|
SILVER(0x06),
|
||||||
|
GOLD(0x07),
|
||||||
|
ROSE_GOLD(0x08),
|
||||||
|
SPACE_GRAY(0x09),
|
||||||
|
DARK_BLUE(0x0a),
|
||||||
|
LIGHT_BLUE(0x0b),
|
||||||
|
YELLOW(0x0c),
|
||||||
|
UNKNOWN(null);
|
||||||
|
|
||||||
enum class DeviceColor(val raw: UByte?, @StringRes val labelRes: Int) {
|
constructor(raw: Int) : this(raw.toUByte())
|
||||||
|
|
||||||
WHITE(0x00, R.string.pods_device_color_white_label),
|
|
||||||
BLACK(0x01, R.string.pods_device_color_black_label),
|
|
||||||
RED(0x02, R.string.pods_device_color_red_label),
|
|
||||||
BLUE(0x03, R.string.pods_device_color_blue_label),
|
|
||||||
PINK(0x04, R.string.pods_device_color_pink_label),
|
|
||||||
GRAY(0x05, R.string.pods_device_color_gray_label),
|
|
||||||
SILVER(0x06, R.string.pods_device_color_silver_label),
|
|
||||||
GOLD(0x07, R.string.pods_device_color_gold_label),
|
|
||||||
ROSE_GOLD(0x08, R.string.pods_device_color_rose_gold_label),
|
|
||||||
SPACE_GRAY(0x09, R.string.pods_device_color_space_gray_label),
|
|
||||||
DARK_BLUE(0x0a, R.string.pods_device_color_dark_blue_label),
|
|
||||||
LIGHT_BLUE(0x0b, R.string.pods_device_color_light_blue_label),
|
|
||||||
YELLOW(0x0c, R.string.pods_device_color_yellow_label),
|
|
||||||
UNKNOWN(null, R.string.general_value_unknown_label);
|
|
||||||
|
|
||||||
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val connectionState: ConnectionState
|
val connectionState: ConnectionState
|
||||||
@@ -160,4 +182,5 @@ interface DualApplePods : ApplePods, HasDualPods, HasEarDetection, HasCase {
|
|||||||
|
|
||||||
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
|
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package eu.darken.capod.pods.core.apple
|
||||||
|
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
|
||||||
|
|
||||||
|
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
|
||||||
|
|
||||||
|
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
|
||||||
|
leftPodBattery = batteryLeftPodPercent,
|
||||||
|
rightPodBattery = batteryRightPodPercent,
|
||||||
|
microPhoneLeft = isLeftPodMicrophone,
|
||||||
|
microPhoneRight = isRightPodMicrophone,
|
||||||
|
chargingLeft = isLeftPodCharging,
|
||||||
|
chargingRight = isRightPodCharging,
|
||||||
|
color = deviceColor,
|
||||||
|
model = model
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split pods, one in case, one not.
|
||||||
|
*/
|
||||||
|
data class SplitPodsMarkings(
|
||||||
|
val leftPodBattery: Float?,
|
||||||
|
val rightPodBattery: Float?,
|
||||||
|
val microPhoneLeft: Boolean,
|
||||||
|
val microPhoneRight: Boolean,
|
||||||
|
val chargingLeft: Boolean,
|
||||||
|
val chargingRight: Boolean,
|
||||||
|
val color: DualApplePods.DeviceColor,
|
||||||
|
val model: PodDevice.Model,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
|
||||||
|
val target = device.getCaseMatchMarkings()
|
||||||
|
|
||||||
|
return filter { known ->
|
||||||
|
known.history
|
||||||
|
.filterIsInstance<DualApplePods>()
|
||||||
|
.any { it.getCaseMatchMarkings() == target }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun KnownDevice.getLatestCaseBattery(): Float? = history
|
||||||
|
.filterIsInstance<DualApplePods>()
|
||||||
|
.mapNotNull { it.batteryCasePercent }
|
||||||
|
.lastOrNull()
|
||||||
|
|
||||||
|
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
|
||||||
|
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
|
||||||
|
if (definitive.contains(basic.caseLidState)) return null
|
||||||
|
|
||||||
|
return history
|
||||||
|
.filterIsInstance<AirPodsPro>()
|
||||||
|
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
|
||||||
|
?.caseLidState
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchHistory(current: DualApplePods): KnownDevice? {
|
||||||
|
val basicResult = super.searchHistory(current)
|
||||||
|
|
||||||
|
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
|
||||||
|
|
||||||
|
log(tag, DEBUG) { "searchHistory2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
|
||||||
|
|
||||||
|
return when (caseIgnored.size) {
|
||||||
|
0 -> basicResult
|
||||||
|
1 -> caseIgnored.single()
|
||||||
|
else -> {
|
||||||
|
log(tag) { "searchHistory2: More than one result when ignoring case markers." }
|
||||||
|
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
|
||||||
|
|
||||||
|
caseIgnored.minus(oldest).forEach {
|
||||||
|
log(tag) { "searchHistory2: Removing outlier: $it" }
|
||||||
|
knownDevices.remove(it.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package eu.darken.capod.pods.core.apple
|
package eu.darken.capod.pods.core.apple
|
||||||
|
|
||||||
import eu.darken.capod.common.isBitSet
|
import eu.darken.capod.common.isBitSet
|
||||||
import eu.darken.capod.common.upperNibble
|
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.pods.core.HasEarDetection
|
||||||
import eu.darken.capod.pods.core.HasSinglePod
|
import eu.darken.capod.pods.core.HasSinglePod
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ interface SingleApplePods : BasicSingleApplePods, HasEarDetection, HasSinglePod
|
|||||||
get() = rawStatus.isBitSet(1)
|
get() = rawStatus.isBitSet(1)
|
||||||
|
|
||||||
val isHeadsetBeingCharged: Boolean
|
val isHeadsetBeingCharged: Boolean
|
||||||
get() = rawCaseBattery.upperNibble.isBitSet(0)
|
get() = rawFlags.isBitSet(0)
|
||||||
|
|
||||||
override val isBeingWorn: Boolean
|
override val isBeingWorn: Boolean
|
||||||
get() = isHeadphonesBeingWorn
|
get() = isHeadphonesBeingWorn
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package eu.darken.capod.pods.core.apple
|
||||||
|
|
||||||
|
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
|
||||||
@@ -13,26 +13,36 @@ data class UnknownAppleDevice(
|
|||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
|
override val confidence: Float = 0f,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
) : ApplePods {
|
) : ApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
|
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
|
||||||
|
|
||||||
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
|
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
|
||||||
|
|
||||||
override fun create(
|
override fun create(
|
||||||
scanResult: BleScanResult,
|
scanResult: BleScanResult,
|
||||||
proximityMessage: ProximityPairing.Message,
|
proximityMessage: ProximityPairing.Message,
|
||||||
): ApplePods {
|
): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
return UnknownAppleDevice(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -14,7 +15,10 @@ data class AirPodsGen1 constructor(
|
|||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message,
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
private val cachedBatteryPercentage: Float?
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods {
|
) : DualApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
|
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
|
||||||
@@ -22,26 +26,32 @@ data class AirPodsGen1 constructor(
|
|||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val caseLidState: DualApplePods.LidState
|
||||||
|
get() = cachedCaseState ?: super.caseLidState
|
||||||
|
|
||||||
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = AirPodsGen1(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
val device = AirPodsGen1(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -14,7 +15,10 @@ data class AirPodsGen2 constructor(
|
|||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message,
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
private val cachedBatteryPercentage: Float?,
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods {
|
) : DualApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
|
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
|
||||||
@@ -22,26 +26,32 @@ data class AirPodsGen2 constructor(
|
|||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val caseLidState: DualApplePods.LidState
|
||||||
|
get() = cachedCaseState ?: super.caseLidState
|
||||||
|
|
||||||
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = AirPodsGen2(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
val device = AirPodsGen2(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -14,33 +15,42 @@ data class AirPodsGen3 constructor(
|
|||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message,
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
private val cachedBatteryPercentage: Float?,
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods {
|
) : DualApplePods {
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
|
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val caseLidState: DualApplePods.LidState
|
||||||
|
get() = cachedCaseState ?: super.caseLidState
|
||||||
|
|
||||||
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = AirPodsGen3(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
val device = AirPodsGen3(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -14,22 +15,32 @@ data class AirPodsMax(
|
|||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message,
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
) : SingleApplePods {
|
) : SingleApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
|
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
|
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = AirPodsMax(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
return AirPodsMax(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||||
|
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -14,7 +16,10 @@ data class AirPodsPro(
|
|||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message,
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
private val cachedBatteryPercentage: Float?,
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
|
private val cachedCaseState: LidState? = null
|
||||||
) : DualApplePods {
|
) : DualApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
|
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
|
||||||
@@ -22,32 +27,38 @@ data class AirPodsPro(
|
|||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val caseLidState: LidState
|
||||||
|
get() = cachedCaseState ?: super.caseLidState
|
||||||
|
|
||||||
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
val device = AirPodsPro(
|
if (result != null) basic = basic.copy(identifier = result.id)
|
||||||
identifier = identifier,
|
updateHistory(basic)
|
||||||
scanResult = scanResult,
|
|
||||||
proximityMessage = proximityMessage,
|
if (result == null) return basic
|
||||||
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
|
|
||||||
|
return basic.copy(
|
||||||
|
identifier = result.id,
|
||||||
|
confidence = result.confidence,
|
||||||
|
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||||
|
rssiAverage = result.averageRssi(basic.rssi),
|
||||||
|
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||||
)
|
)
|
||||||
|
|
||||||
cachedValues[identifier] = ValueCache(
|
|
||||||
caseBatteryPercentage = device.batteryCasePercent
|
|
||||||
)
|
|
||||||
|
|
||||||
return device
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
companion object {
|
||||||
|
private val DEVICE_CODE = 0x0e20.toUShort()
|
||||||
companion object {
|
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
|
||||||
private val DEVICE_CODE = 0x0e20.toUShort()
|
}
|
||||||
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.logTag
|
|||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -13,23 +14,33 @@ data class BeatsFlex(
|
|||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
) : BasicSingleApplePods {
|
) : BasicSingleApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
|
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = BeatsFlex(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
return BeatsFlex(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -13,23 +14,32 @@ data class BeatsSolo3(
|
|||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
) : BasicSingleApplePods {
|
) : BasicSingleApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
|
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
|
||||||
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = BeatsSolo3(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
return BeatsSolo3(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -13,23 +14,30 @@ data class BeatsStudio3(
|
|||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
) : BasicSingleApplePods {
|
) : BasicSingleApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
|
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
|
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = BeatsStudio3(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
return BeatsStudio3(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -13,23 +14,33 @@ data class BeatsX(
|
|||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
) : BasicSingleApplePods {
|
) : BasicSingleApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_X
|
override val model: PodDevice.Model = PodDevice.Model.BEATS_X
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = BeatsX(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
return BeatsX(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -13,22 +14,32 @@ data class PowerBeats3(
|
|||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
override val identifier: PodDevice.Id = PodDevice.Id(),
|
||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
) : BasicSingleApplePods {
|
) : BasicSingleApplePods {
|
||||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
|
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().full == DEVICE_CODE
|
proximityMessage.getModelInfo().full == DEVICE_CODE
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
val basic = PowerBeats3(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
return PowerBeats3(
|
updateHistory(basic)
|
||||||
identifier = identifier,
|
|
||||||
scanResult = scanResult,
|
if (result == null) return basic
|
||||||
proximityMessage = proximityMessage,
|
|
||||||
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -14,7 +15,10 @@ data class PowerBeatsPro(
|
|||||||
override val lastSeenAt: Instant = Instant.now(),
|
override val lastSeenAt: Instant = Instant.now(),
|
||||||
override val scanResult: BleScanResult,
|
override val scanResult: BleScanResult,
|
||||||
override val proximityMessage: ProximityPairing.Message,
|
override val proximityMessage: ProximityPairing.Message,
|
||||||
private val cachedBatteryPercentage: Float?,
|
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
|
||||||
|
private val rssiAverage: Int? = null,
|
||||||
|
private val cachedBatteryPercentage: Float? = null,
|
||||||
|
private val cachedCaseState: DualApplePods.LidState? = null
|
||||||
) : DualApplePods {
|
) : DualApplePods {
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
|
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
|
||||||
@@ -22,26 +26,33 @@ data class PowerBeatsPro(
|
|||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
|
override val caseLidState: DualApplePods.LidState
|
||||||
|
get() = cachedCaseState ?: super.caseLidState
|
||||||
|
|
||||||
|
override val rssi: Int
|
||||||
|
get() = rssiAverage ?: super.rssi
|
||||||
|
|
||||||
|
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
|
||||||
|
|
||||||
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
|
||||||
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
|
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
|
||||||
|
|
||||||
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
|
||||||
val identifier = recognizeDevice(scanResult, proximityMessage)
|
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = proximityMessage)
|
||||||
|
val result = searchHistory(basic)
|
||||||
|
|
||||||
val device = PowerBeatsPro(
|
if (result != null) basic = basic.copy(identifier = result.id)
|
||||||
identifier = identifier,
|
updateHistory(basic)
|
||||||
scanResult = scanResult,
|
|
||||||
proximityMessage = proximityMessage,
|
if (result == null) return basic
|
||||||
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
|
|
||||||
|
return basic.copy(
|
||||||
|
identifier = result.id,
|
||||||
|
confidence = result.confidence,
|
||||||
|
cachedBatteryPercentage = result.getLatestCaseBattery(),
|
||||||
|
rssiAverage = result.averageRssi(basic.rssi),
|
||||||
|
cachedCaseState = result.getLatestCaseLidState(basic)
|
||||||
)
|
)
|
||||||
|
|
||||||
cachedValues[identifier] = ValueCache(
|
|
||||||
caseBatteryPercentage = device.batteryCasePercent
|
|
||||||
)
|
|
||||||
|
|
||||||
return device
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package eu.darken.capod.pods.core.apple.protocol
|
|||||||
import android.bluetooth.le.ScanFilter
|
import android.bluetooth.le.ScanFilter
|
||||||
import dagger.Reusable
|
import dagger.Reusable
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.lowerNibble
|
|
||||||
import eu.darken.capod.common.upperNibble
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
@@ -15,22 +13,11 @@ object ProximityPairing {
|
|||||||
val length: Int,
|
val length: Int,
|
||||||
val data: UByteArray
|
val data: UByteArray
|
||||||
) {
|
) {
|
||||||
data class Markers(
|
|
||||||
val vendor: UByte,
|
|
||||||
val length: UByte,
|
|
||||||
val device: UShort,
|
|
||||||
val batteryData: Set<UByte>,
|
|
||||||
val deviceColor: UByte,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getRecogMarkers(): Markers = Markers(
|
override fun toString(): String {
|
||||||
vendor = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
|
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
|
||||||
length = PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(),
|
return "ProximityPairing.Message(type=$type, length=$length, data=$dataHex)"
|
||||||
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
|
}
|
||||||
// Make comparison order independent
|
|
||||||
batteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
|
|
||||||
deviceColor = data[7]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
@@ -74,6 +61,6 @@ object ProximityPairing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
|
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
|
||||||
private val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
|
internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
|
||||||
private const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
|
internal const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package eu.darken.capod.reaction.autoconnect
|
package eu.darken.capod.reaction.autoconnect
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
import eu.darken.capod.monitor.core.PodMonitor
|
||||||
|
import eu.darken.capod.pods.core.HasDualEarDetection
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.pods.core.HasEarDetection
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
import eu.darken.capod.reaction.settings.ReactionSettings
|
import eu.darken.capod.reaction.settings.ReactionSettings
|
||||||
@@ -22,29 +25,36 @@ class AutoConnect @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow
|
fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow
|
||||||
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() }
|
.flatMapLatest { isAutoConnectEnabled ->
|
||||||
.filterNotNull()
|
if (isAutoConnectEnabled) {
|
||||||
.distinctUntilChanged()
|
combine(
|
||||||
.map { podDevice ->
|
bluetoothManager.connectedDevices().distinctUntilChanged(),
|
||||||
log(TAG) { "mainPodDevice is $podDevice" }
|
podMonitor.mainDevice.filterNotNull().distinctUntilChangedBy { it.rawDataHex },
|
||||||
|
) { connectedDevices, mainDevice ->
|
||||||
|
connectedDevices to mainDevice
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { (connectedDevices, mainDevice) ->
|
||||||
|
log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" }
|
||||||
|
|
||||||
val mainDeviceAddr = generalSettings.mainDeviceAddress.value
|
val mainDeviceAddr = generalSettings.mainDeviceAddress.value
|
||||||
if (mainDeviceAddr.isNullOrEmpty()) {
|
if (mainDeviceAddr.isNullOrEmpty()) {
|
||||||
log(TAG) { "mainDeviceAddress is null" }
|
log(TAG, WARN) { "mainDeviceAddress is null" }
|
||||||
return@map
|
return@map
|
||||||
} else {
|
|
||||||
log(TAG) { "mainDeviceAddress is $mainDeviceAddr" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
|
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
|
||||||
if (bondedDevice == null) {
|
if (bondedDevice == null) {
|
||||||
log(TAG) { "No bonded device matches $mainDeviceAddr" }
|
log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
|
||||||
return@map
|
return@map
|
||||||
} else {
|
} else {
|
||||||
log(TAG) { "Found target device: $bondedDevice" }
|
log(TAG, VERBOSE) { "Found target device: $bondedDevice" }
|
||||||
}
|
}
|
||||||
|
|
||||||
val isAlreadyConnected = bluetoothManager.connectedDevices().first().any {
|
val isAlreadyConnected = connectedDevices.any {
|
||||||
it.address == bondedDevice.address
|
it.address == bondedDevice.address
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +67,18 @@ class AutoConnect @Inject constructor(
|
|||||||
log(TAG) { "Checking condition $condition" }
|
log(TAG) { "Checking condition $condition" }
|
||||||
val conditionFulfilled = when (condition) {
|
val conditionFulfilled = when (condition) {
|
||||||
AutoConnectCondition.WHEN_SEEN -> true
|
AutoConnectCondition.WHEN_SEEN -> true
|
||||||
AutoConnectCondition.CASE_OPEN -> when (podDevice) {
|
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
|
||||||
is DualApplePods -> podDevice.caseLidState == DualApplePods.LidState.OPEN
|
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
AutoConnectCondition.IN_EAR -> when (podDevice) {
|
AutoConnectCondition.IN_EAR -> when (mainDevice) {
|
||||||
is HasEarDetection -> podDevice.isBeingWorn
|
is HasEarDetection -> {
|
||||||
|
if (mainDevice is HasDualEarDetection && reactionSettings.onePodMode.value) {
|
||||||
|
mainDevice.isEitherPodInEar
|
||||||
|
} else {
|
||||||
|
mainDevice.isBeingWorn
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import eu.darken.capod.common.debug.logging.logTag
|
|||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
import eu.darken.capod.common.flow.withPrevious
|
import eu.darken.capod.common.flow.withPrevious
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
import eu.darken.capod.monitor.core.PodMonitor
|
||||||
|
import eu.darken.capod.pods.core.HasDualEarDetection
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.pods.core.HasEarDetection
|
||||||
import eu.darken.capod.reaction.settings.ReactionSettings
|
import eu.darken.capod.reaction.settings.ReactionSettings
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -25,7 +26,8 @@ class PlayPause @Inject constructor(
|
|||||||
fun monitor() = combine(
|
fun monitor() = combine(
|
||||||
reactionSettings.autoPlay.flow,
|
reactionSettings.autoPlay.flow,
|
||||||
reactionSettings.autoPause.flow,
|
reactionSettings.autoPause.flow,
|
||||||
) { play, pause -> play || pause }
|
reactionSettings.onePodMode.flow,
|
||||||
|
) { play, pause, _ -> play || pause }
|
||||||
.flatMapLatest { if (it) bluetoothManager.connectedDevices() else emptyFlow() }
|
.flatMapLatest { if (it) bluetoothManager.connectedDevices() else emptyFlow() }
|
||||||
.flatMapLatest {
|
.flatMapLatest {
|
||||||
if (it.isEmpty()) {
|
if (it.isEmpty()) {
|
||||||
@@ -42,7 +44,28 @@ class PlayPause @Inject constructor(
|
|||||||
if (previous is HasEarDetection && current is HasEarDetection) {
|
if (previous is HasEarDetection && current is HasEarDetection) {
|
||||||
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
|
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
|
||||||
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
|
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
|
||||||
if (previous.identifier == current.identifier && previous.isBeingWorn != current.isBeingWorn) {
|
if (previous.identifier != current.identifier) {
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous is HasDualEarDetection && current is HasDualEarDetection && reactionSettings.onePodMode.value) {
|
||||||
|
log(TAG) { "Ear status changed for dual pod device in single pod mode." }
|
||||||
|
val previousWorn = previous.isEitherPodInEar
|
||||||
|
val currentWorn = current.isEitherPodInEar
|
||||||
|
if (!previousWorn && currentWorn && !mediaControl.isPlaying) {
|
||||||
|
if (reactionSettings.autoPlay.value) {
|
||||||
|
mediaControl.sendPlay()
|
||||||
|
} else {
|
||||||
|
log(TAG) { "autoPlay is disabled" }
|
||||||
|
}
|
||||||
|
} else if (!currentWorn && previousWorn && mediaControl.isPlaying) {
|
||||||
|
if (reactionSettings.autoPause.value) {
|
||||||
|
mediaControl.sendPause()
|
||||||
|
} else {
|
||||||
|
log(TAG) { "autoPause is disabled" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (previous.isBeingWorn != current.isBeingWorn) {
|
||||||
log(TAG) { "Ear status changed for monitored device." }
|
log(TAG) { "Ear status changed for monitored device." }
|
||||||
if (current.isBeingWorn && !mediaControl.isPlaying) {
|
if (current.isBeingWorn && !mediaControl.isPlaying) {
|
||||||
if (reactionSettings.autoPlay.value) {
|
if (reactionSettings.autoPlay.value) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package eu.darken.capod.reaction.popup
|
package eu.darken.capod.reaction.popup
|
||||||
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
@@ -9,12 +9,12 @@ import eu.darken.capod.common.flow.withPrevious
|
|||||||
import eu.darken.capod.monitor.core.PodMonitor
|
import eu.darken.capod.monitor.core.PodMonitor
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
import eu.darken.capod.reaction.popup.ui.PopUpNotification
|
import eu.darken.capod.reaction.popup.ui.PopUpWindow
|
||||||
import eu.darken.capod.reaction.settings.ReactionSettings
|
import eu.darken.capod.reaction.settings.ReactionSettings
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import java.time.Duration
|
||||||
import kotlinx.coroutines.flow.map
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -22,16 +22,25 @@ import javax.inject.Singleton
|
|||||||
class PopUpReaction @Inject constructor(
|
class PopUpReaction @Inject constructor(
|
||||||
private val podMonitor: PodMonitor,
|
private val podMonitor: PodMonitor,
|
||||||
private val reactionSettings: ReactionSettings,
|
private val reactionSettings: ReactionSettings,
|
||||||
private val popUpNotification: PopUpNotification,
|
private val popupWindow: PopUpWindow,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val coolDowns = mutableMapOf<PodDevice.Id, Instant>()
|
||||||
|
|
||||||
fun monitor(): Flow<Unit> = reactionSettings.showPopUpOnCaseOpen.flow
|
fun monitor(): Flow<Unit> = reactionSettings.showPopUpOnCaseOpen.flow
|
||||||
.flatMapLatest { if (it) podMonitor.mainDevice else emptyFlow() }
|
.flatMapLatest { isEnabled ->
|
||||||
|
if (isEnabled) {
|
||||||
|
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }
|
||||||
|
} else {
|
||||||
|
emptyFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
.withPrevious()
|
.withPrevious()
|
||||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
.setupCommonEventHandlers(TAG) { "monitor" }
|
||||||
.map { (previous, current) ->
|
.map { (previous, current) ->
|
||||||
if (previous is DualApplePods? && current is DualApplePods) {
|
if (previous is DualApplePods? && current is DualApplePods) {
|
||||||
log(TAG) {
|
log(TAG, VERBOSE) {
|
||||||
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
|
val prev = previous?.rawCaseLidState?.let { String.format("%02X", it.toByte()) }
|
||||||
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
|
val cur = current.rawCaseLidState.let { String.format("%02X", it.toByte()) }
|
||||||
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
|
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
|
||||||
@@ -46,17 +55,48 @@ class PopUpReaction @Inject constructor(
|
|||||||
if (isSameDeviceWithCaseNowOpen || isNewDeviceWithJustOpenedCase) {
|
if (isSameDeviceWithCaseNowOpen || isNewDeviceWithJustOpenedCase) {
|
||||||
log(TAG) { "Case lid status changed for monitored device." }
|
log(TAG) { "Case lid status changed for monitored device." }
|
||||||
|
|
||||||
if (current.caseLidState == DualApplePods.LidState.OPEN && current.model != PodDevice.Model.UNKNOWN) {
|
tryPopWindow(current)
|
||||||
log(TAG, INFO) { "Show popup" }
|
|
||||||
popUpNotification.showNotification(current)
|
|
||||||
} else if (current.caseLidState != DualApplePods.LidState.OPEN) {
|
|
||||||
log(TAG, INFO) { "Hide popup" }
|
|
||||||
popUpNotification.hideNotification()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun tryPopWindow(current: DualApplePods) {
|
||||||
|
if (current.caseLidState == DualApplePods.LidState.OPEN) {
|
||||||
|
log(TAG, INFO) { "Show popup" }
|
||||||
|
|
||||||
|
val now = Instant.now()
|
||||||
|
val lastShown = coolDowns[current.identifier] ?: Instant.MIN
|
||||||
|
val sinceLastPop = Duration.between(lastShown, now)
|
||||||
|
log(TAG) { "Time since last popup: $sinceLastPop" }
|
||||||
|
if (sinceLastPop < Duration.ofSeconds(10)) {
|
||||||
|
log(TAG, INFO) { "Popup is still on cooldown: $sinceLastPop" }
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
coolDowns[current.identifier] = Instant.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(dispatcherProvider.Main) {
|
||||||
|
popupWindow.show(current)
|
||||||
|
}
|
||||||
|
} else if (current.caseLidState != DualApplePods.LidState.OPEN) {
|
||||||
|
when (current.caseLidState) {
|
||||||
|
DualApplePods.LidState.CLOSED -> {
|
||||||
|
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
|
||||||
|
coolDowns.remove(current.identifier)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
|
||||||
|
coolDowns[current.identifier] = Instant.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(TAG, INFO) { "Hide popup" }
|
||||||
|
withContext(dispatcherProvider.Main) {
|
||||||
|
popupWindow.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Reaction", "PopUp")
|
private val TAG = logTag("Reaction", "PopUp")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
package eu.darken.capod.reaction.popup.ui
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.R
|
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
|
|
||||||
class PopUpNotification @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val notificationManager: NotificationManager,
|
|
||||||
private val notificationViewFactory: PopUpNotificationViewFactory
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val builder: NotificationCompat.Builder
|
|
||||||
|
|
||||||
init {
|
|
||||||
NotificationChannel(
|
|
||||||
NOTIFICATION_CHANNEL_ID,
|
|
||||||
context.getString(R.string.notification_channel_reaction_popup_label),
|
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
|
||||||
).run {
|
|
||||||
vibrationPattern = LongArray(1) { 0 }
|
|
||||||
enableVibration(true)
|
|
||||||
enableLights(false)
|
|
||||||
setSound(null, null)
|
|
||||||
notificationManager.createNotificationChannel(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
val openIntent = Intent(context, MainActivity::class.java)
|
|
||||||
val openPi = PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
openIntent,
|
|
||||||
PendingIntentCompat.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID).apply {
|
|
||||||
setChannelId(NOTIFICATION_CHANNEL_ID)
|
|
||||||
setContentIntent(openPi)
|
|
||||||
priority = NotificationCompat.PRIORITY_LOW
|
|
||||||
setSmallIcon(R.drawable.ic_device_generic_earbuds)
|
|
||||||
setContentTitle(context.getString(R.string.app_name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getBuilder(device: PodDevice): NotificationCompat.Builder = builder.apply {
|
|
||||||
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
|
||||||
setCustomContentView(notificationViewFactory.createContentView(device))
|
|
||||||
setSmallIcon(device.iconRes)
|
|
||||||
setSubText(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showNotification(device: PodDevice) {
|
|
||||||
val notification = getBuilder(device).build()
|
|
||||||
log(TAG) { "showNotification(): $device" }
|
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hideNotification() {
|
|
||||||
log(TAG) { "hideNotification()" }
|
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = logTag("Reaction", "PopUp", "Notifications")
|
|
||||||
private val NOTIFICATION_CHANNEL_ID =
|
|
||||||
"${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.popup"
|
|
||||||
internal const val NOTIFICATION_ID = 9000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package eu.darken.capod.reaction.popup.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.widget.RemoteViews
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.R
|
|
||||||
import eu.darken.capod.pods.core.*
|
|
||||||
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
|
||||||
import eu.darken.capod.pods.core.apple.SingleApplePods
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
|
|
||||||
class PopUpNotificationViewFactory @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun createContentView(device: PodDevice): RemoteViews = when (device) {
|
|
||||||
is DualApplePods -> createDualApplePods(device)
|
|
||||||
is SingleApplePods -> createSingleApplePods(device)
|
|
||||||
is BasicSingleApplePods -> createSingleBasicApplePods(device)
|
|
||||||
else -> throw IllegalArgumentException("Unexpected device: $device")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createDualApplePods(device: DualApplePods): RemoteViews = RemoteViews(
|
|
||||||
context.packageName,
|
|
||||||
R.layout.popup_notification_dual_pods
|
|
||||||
).apply {
|
|
||||||
setTextViewText(R.id.pod_label, device.getLabel(context))
|
|
||||||
|
|
||||||
device.getBatteryLevelLeftPod(context)
|
|
||||||
.let { if (device.isLeftPodCharging) "$it⚡" else it }
|
|
||||||
.run { setTextViewText(R.id.pod_left, this) }
|
|
||||||
|
|
||||||
device.getBatteryLevelCase(context)
|
|
||||||
.let { if (device.isCaseCharging) "$it⚡" else it }
|
|
||||||
.run { setTextViewText(R.id.pod_case, this) }
|
|
||||||
|
|
||||||
device.getBatteryLevelRightPod(context)
|
|
||||||
.let { if (device.isRightPodCharging) "$it⚡" else it }
|
|
||||||
.run { setTextViewText(R.id.pod_right, this) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createSingleApplePods(device: SingleApplePods): RemoteViews = RemoteViews(
|
|
||||||
context.packageName,
|
|
||||||
R.layout.popup_notification_single_pods
|
|
||||||
).apply {
|
|
||||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
|
||||||
|
|
||||||
device.getBatteryLevelHeadset(context)
|
|
||||||
.let { if (!device.isHeadsetBeingCharged) "$it⚡" else it }
|
|
||||||
.run { setTextViewText(R.id.headphones, this) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createSingleBasicApplePods(device: BasicSingleApplePods): RemoteViews = RemoteViews(
|
|
||||||
context.packageName,
|
|
||||||
R.layout.popup_notification_single_pods_basic
|
|
||||||
).apply {
|
|
||||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
|
||||||
|
|
||||||
device.getBatteryLevelHeadset(context)
|
|
||||||
.run { setTextViewText(R.id.headphones, this) }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package eu.darken.capod.reaction.popup.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.debug.autoreport.DebugSettings
|
||||||
|
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
|
||||||
|
import eu.darken.capod.databinding.PopupNotificationSinglePodsBasicBinding
|
||||||
|
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
|
||||||
|
import eu.darken.capod.pods.core.*
|
||||||
|
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.SingleApplePods
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
|
class PopUpPodViewFactory @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val debugSettings: DebugSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val context = ContextThemeWrapper(appContext, R.style.AppTheme)
|
||||||
|
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||||
|
|
||||||
|
fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) {
|
||||||
|
is DualApplePods -> createDualApplePods(parent, device)
|
||||||
|
is SingleApplePods -> createSingleApplePods(parent, device) // Unused, has no case to trigger reaction?
|
||||||
|
is BasicSingleApplePods -> createSingleBasicApplePods(
|
||||||
|
parent,
|
||||||
|
device
|
||||||
|
) // Unused, has no case to trigger reaction?
|
||||||
|
else -> throw IllegalArgumentException("Unexpected device: $device")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDualApplePods(parent: ViewGroup, device: DualApplePods): View =
|
||||||
|
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
|
||||||
|
device.apply {
|
||||||
|
podIcon.setImageResource(iconRes)
|
||||||
|
podLabel.text = getLabel(context)
|
||||||
|
signal.text = getSignalQuality(context)
|
||||||
|
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
||||||
|
|
||||||
|
// Left
|
||||||
|
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
|
||||||
|
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
|
||||||
|
|
||||||
|
// Case
|
||||||
|
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
|
||||||
|
podCaseBatteryLabel.text = getBatteryLevelCase(context)
|
||||||
|
|
||||||
|
// Right
|
||||||
|
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
|
||||||
|
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
|
||||||
|
}
|
||||||
|
}.root
|
||||||
|
|
||||||
|
private fun createSingleApplePods(parent: ViewGroup, device: SingleApplePods): View =
|
||||||
|
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
|
||||||
|
device.apply {
|
||||||
|
headphonesIcon.setImageResource(iconRes)
|
||||||
|
headphonesLabel.text = getLabel(context)
|
||||||
|
signal.text = getSignalQuality(context)
|
||||||
|
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
||||||
|
|
||||||
|
headphonesBatteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
|
||||||
|
headphonesBatteryLabel.text = getBatteryLevelHeadset(context)
|
||||||
|
}
|
||||||
|
}.root
|
||||||
|
|
||||||
|
private fun createSingleBasicApplePods(parent: ViewGroup, device: BasicSingleApplePods): View =
|
||||||
|
PopupNotificationSinglePodsBasicBinding.inflate(layoutInflater, parent, false).apply {
|
||||||
|
device.apply {
|
||||||
|
headphonesIcon.setImageResource(iconRes)
|
||||||
|
headphonesLabel.text = getLabel(context)
|
||||||
|
signal.text = getSignalQuality(context)
|
||||||
|
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
||||||
|
|
||||||
|
headphonesBatteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
|
||||||
|
headphonesBatteryLabel.text = getBatteryLevelHeadset(context)
|
||||||
|
}
|
||||||
|
}.root
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package eu.darken.capod.reaction.popup.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Context.WINDOW_SERVICE
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PopUpWindow @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val podViewFactory: PopUpPodViewFactory
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val context = ContextThemeWrapper(appContext, R.style.AppTheme)
|
||||||
|
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||||
|
private val windowManager: WindowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
|
||||||
|
private val layoutParams = WindowManager.LayoutParams(
|
||||||
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
|
WindowManager.LayoutParams.WRAP_CONTENT, // Display it on top of other application windows
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, // Don't let it grab the input focus
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, // Make the underlying application window visible
|
||||||
|
|
||||||
|
PixelFormat.TRANSLUCENT
|
||||||
|
).apply {
|
||||||
|
gravity = Gravity.BOTTOM
|
||||||
|
}
|
||||||
|
private val popUpView: View = layoutInflater.inflate(R.layout.popup_window_container_layout, null).apply {
|
||||||
|
findViewById<View>(R.id.close_action).setOnClickListener { close() }
|
||||||
|
}
|
||||||
|
private val deviceContainer = popUpView.findViewById<FrameLayout>(R.id.popup_content)
|
||||||
|
fun show(device: PodDevice) = try {
|
||||||
|
log(TAG) { "open()" }
|
||||||
|
if (popUpView.windowToken != null || popUpView.parent != null) {
|
||||||
|
log(TAG) { "View already added." }
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val podView = podViewFactory.createContentView(deviceContainer, device)
|
||||||
|
deviceContainer.removeAllViews()
|
||||||
|
deviceContainer.addView(podView)
|
||||||
|
windowManager.addView(popUpView, layoutParams)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, ERROR) { "open() failed: ${e.asLog()}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() = try {
|
||||||
|
log(TAG) { "close()" }
|
||||||
|
if (popUpView.parent != null) {
|
||||||
|
windowManager.removeView(popUpView)
|
||||||
|
deviceContainer.removeAllViews()
|
||||||
|
} else {
|
||||||
|
log(TAG) { "View was not added" }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, ERROR) { "close() failed: ${e.asLog()}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Reaction", "PopUp", "Window")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,11 +47,17 @@ class ReactionSettings @Inject constructor(
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val onePodMode = preferences.createFlowPreference(
|
||||||
|
"reaction.onepod.enabled",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
||||||
autoPause,
|
autoPause,
|
||||||
autoPlay,
|
autoPlay,
|
||||||
autoConnect,
|
autoConnect,
|
||||||
autoConnectCondition,
|
autoConnectCondition,
|
||||||
showPopUpOnCaseOpen,
|
showPopUpOnCaseOpen,
|
||||||
|
onePodMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v14h6V6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v12h6V6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v10h6V6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v8h6V6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v6h6V6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v4h6V6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,5v16c0,0.55 -0.45,1 -1,1H8c-0.55,0 -1,-0.45 -1,-1V5c0,-0.55 0.45,-1 1,-1h2V2h4v2h2C16.55,4 17,4.45 17,5zM15,6H9v2h6V6z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M15.67,4H14V2h-4v2H8.33C7.6,4 7,4.6 7,5.33v15.33C7,21.4 7.6,22 8.33,22h7.33c0.74,0 1.34,-0.6 1.34,-1.33V5.33C17,4.6 16.4,4 15.67,4z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M15.67,4L14,4L14,2h-4v2L8.33,4C7.6,4 7,4.6 7,5.33v15.33C7,21.4 7.6,22 8.33,22h7.33c0.74,0 1.34,-0.6 1.34,-1.33L17,5.33C17,4.6 16.4,4 15.67,4zM12.95,17.95h-1.9v-1.9h1.9v1.9zM14.3,12.69s-0.38,0.42 -0.67,0.71c-0.48,0.48 -0.83,1.15 -0.83,1.6h-1.6c0,-0.83 0.46,-1.52 0.93,-2l0.93,-0.94c0.27,-0.27 0.44,-0.65 0.44,-1.06 0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5L9,11c0,-1.66 1.34,-3 3,-3s3,1.34 3,3c0,0.66 -0.27,1.26 -0.7,1.69z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M3,3v8h8L11,3L3,3zM9,9L5,9L5,5h4v4zM3,13v8h8v-8L3,13zM9,19L5,19v-4h4v4zM13,3v8h8L21,3h-8zM19,9h-4L15,5h4v4zM13,13v8h8v-8h-8zM19,19h-4v-4h4v4z"
|
||||||
|
android:fillType="evenOdd" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,20c-0.29,0 -0.56,-0.06 -0.76,-0.15 -0.71,-0.37 -1.21,-0.88 -1.71,-2.38 -0.51,-1.56 -1.47,-2.29 -2.39,-3 -0.79,-0.61 -1.61,-1.24 -2.32,-2.53C9.29,10.98 9,9.93 9,9c0,-2.8 2.2,-5 5,-5s5,2.2 5,5h2c0,-3.93 -3.07,-7 -7,-7S7,5.07 7,9c0,1.26 0.38,2.65 1.07,3.9 0.91,1.65 1.98,2.48 2.85,3.15 0.81,0.62 1.39,1.07 1.71,2.05 0.6,1.82 1.37,2.84 2.73,3.55 0.51,0.23 1.07,0.35 1.64,0.35 2.21,0 4,-1.79 4,-4h-2c0,1.1 -0.9,2 -2,2zM7.64,2.64L6.22,1.22C4.23,3.21 3,5.96 3,9s1.23,5.79 3.22,7.78l1.41,-1.41C6.01,13.74 5,11.49 5,9s1.01,-4.74 2.64,-6.36zM11.5,9c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5 -1.12,-2.5 -2.5,-2.5 -2.5,1.12 -2.5,2.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6.03,3.2C7.15,2.44 8.51,2 10,2c3.93,0 7,3.07 7,7c0,1.26 -0.38,2.65 -1.07,3.9c-0.02,0.04 -0.05,0.08 -0.08,0.13l-1.48,-1.48C14.77,10.69 15,9.8 15,9c0,-2.8 -2.2,-5 -5,-5C9.08,4 8.24,4.26 7.5,4.67L6.03,3.2zM17.21,14.38l1.43,1.43C20.11,13.93 21,11.57 21,9c0,-3.04 -1.23,-5.79 -3.22,-7.78l-1.42,1.42C17.99,4.26 19,6.51 19,9C19,11.02 18.33,12.88 17.21,14.38zM10,6.5c-0.21,0 -0.4,0.03 -0.59,0.08l3.01,3.01C12.47,9.4 12.5,9.21 12.5,9C12.5,7.62 11.38,6.5 10,6.5zM21.19,21.19L2.81,2.81L1.39,4.22l2.13,2.13C3.19,7.16 3,8.05 3,9h2c0,-0.36 0.05,-0.71 0.12,-1.05l6.61,6.61c-0.88,0.68 -1.78,1.41 -2.27,2.9c-0.5,1.5 -1,2.01 -1.71,2.38C7.56,19.94 7.29,20 7,20c-1.1,0 -2,-0.9 -2,-2H3c0,2.21 1.79,4 4,4c0.57,0 1.13,-0.12 1.64,-0.35c1.36,-0.71 2.13,-1.73 2.73,-3.55c0.32,-0.98 0.9,-1.43 1.71,-2.05c0.03,-0.02 0.05,-0.04 0.08,-0.06l6.62,6.62L21.19,21.19z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,15c1.66,0 2.99,-1.34 2.99,-3L15,6c0,-1.66 -1.34,-3 -3,-3S9,4.34 9,6v6c0,1.66 1.34,3 3,3zM17.3,12c0,3 -2.54,5.1 -5.3,5.1S6.7,15 6.7,12L5,12c0,3.42 2.72,6.23 6,6.72L11,22h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14,17h-2L12,9h-2L10,7h4v10z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M16.01,7L16,3h-2v4h-4V3H8v4h-0.01C7,6.99 6,7.99 6,8.99v5.49L9.5,18v3h5v-3l3.5,-3.51v-5.5c0,-1 -1,-2 -1.99,-1.99z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,5c-3.87,0 -7,3.13 -7,7h2c0,-2.76 2.24,-5 5,-5s5,2.24 5,5h2c0,-3.87 -3.13,-7 -7,-7zM13,14.29c0.88,-0.39 1.5,-1.26 1.5,-2.29 0,-1.38 -1.12,-2.5 -2.5,-2.5S9.5,10.62 9.5,12c0,1.02 0.62,1.9 1.5,2.29v3.3L7.59,21 9,22.41l3,-3 3,3L16.41,21 13,17.59v-3.3zM12,1C5.93,1 1,5.93 1,12h2c0,-4.97 4.03,-9 9,-9s9,4.03 9,9h2c0,-6.07 -4.93,-11 -11,-11z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12.87,15.07l-2.54,-2.51 0.03,-0.03c1.74,-1.94 2.98,-4.17 3.71,-6.53L17,6L17,4h-7L10,2L8,2v2L1,4v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
|
||||||
|
</vector>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_marginHorizontal="8dp"
|
android:layout_marginHorizontal="8dp"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
@@ -11,43 +11,97 @@
|
|||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/pod_left"
|
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:orientation="horizontal">
|
||||||
android:drawableStart="@drawable/ic_airpod_left_24"
|
|
||||||
tools:text="100%" />
|
<ImageView
|
||||||
|
android:id="@+id/pod_left_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_airpod_left_24" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pod_left_label"
|
||||||
|
style="@style/TextAppearance.Compat.Notification.Title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
tools:text="100%" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_left_charging"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_left_ear"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/pod_case"
|
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:orientation="horizontal">
|
||||||
android:layout_marginHorizontal="8dp"
|
|
||||||
android:drawableStart="@drawable/ic_airpod_case_24"
|
<ImageView
|
||||||
tools:text="100%" />
|
android:id="@+id/pod_case_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_airpod_case_24" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pod_case_label"
|
||||||
|
style="@style/TextAppearance.Compat.Notification.Title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
tools:text="100%" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_case_charging"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/pod_right"
|
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:orientation="horizontal">
|
||||||
android:drawableStart="@drawable/ic_airpod_right_24"
|
|
||||||
tools:text="100%" />
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_airpod_right_24" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pod_right_label"
|
||||||
|
style="@style/TextAppearance.Compat.Notification.Title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
tools:text="100%" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_charging"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_ear"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/headphones_label"
|
android:id="@+id/headphones_label"
|
||||||
@@ -10,16 +11,27 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
tools:text="AirPods Max" />
|
tools:text="Beats Solo 3" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/headphones"
|
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="8dp"
|
||||||
android:drawableStart="@drawable/ic_device_generic_headphones"
|
android:orientation="horizontal">
|
||||||
android:gravity="center"
|
|
||||||
tools:text="100%" />
|
<ImageView
|
||||||
|
android:id="@+id/headphones_battery_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/headphones_battery_label"
|
||||||
|
style="@style/TextAppearance.Compat.Notification.Title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
|
tools:text="100%" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/headphones_label"
|
android:id="@+id/headphones_label"
|
||||||
@@ -12,14 +13,35 @@
|
|||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
tools:text="AirPods Max" />
|
tools:text="AirPods Max" />
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/headphones"
|
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="8dp"
|
||||||
android:drawableStart="@drawable/ic_device_generic_headphones"
|
android:orientation="horizontal">
|
||||||
android:gravity="center"
|
|
||||||
tools:text="100%" />
|
<ImageView
|
||||||
|
android:id="@+id/headphones_battery_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/headphones_battery_label"
|
||||||
|
style="@style/TextAppearance.Compat.Notification.Title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
|
tools:text="100%" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/headphones_charging"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/headphones_worn"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
+10
-22
@@ -14,42 +14,30 @@
|
|||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<com.google.android.material.textview.MaterialTextView
|
||||||
android:id="@+id/permission_label"
|
android:id="@+id/bluetooth_label"
|
||||||
style="@style/TextAppearance.MaterialComponents.Body1"
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/overview_bluetooth_disabled_label"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
android:text="@string/overview_nopaireddevices_label" />
|
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<com.google.android.material.textview.MaterialTextView
|
||||||
android:id="@+id/permission_description"
|
android:id="@+id/bluetooth_description"
|
||||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
style="@style/TextAppearance.MaterialComponents.Body2"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:layout_height="wrap_content"
|
android:text="@string/overview_bluetooth_disabled_description"
|
||||||
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"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/permission_description" />
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/bluetooth_label" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -49,6 +49,18 @@
|
|||||||
app:layout_constraintTop_toBottomOf="@id/permission_label"
|
app:layout_constraintTop_toBottomOf="@id/permission_label"
|
||||||
tools:text="Lorem Ipsum" />
|
tools:text="Lorem Ipsum" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/privacy_policy"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Tooltip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_marginHorizontal="16dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/settings_privacy_policy_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/permission_description" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/grant_action"
|
android:id="@+id/grant_action"
|
||||||
style="@style/Widget.MaterialComponents.Button"
|
style="@style/Widget.MaterialComponents.Button"
|
||||||
@@ -60,7 +72,7 @@
|
|||||||
android:text="@string/general_grant_permission_action"
|
android:text="@string/general_grant_permission_action"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/permission_description" />
|
app:layout_constraintTop_toBottomOf="@id/privacy_policy" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/reception"
|
app:layout_constraintStart_toEndOf="@id/reception"
|
||||||
app:layout_constraintTop_toTopOf="@+id/reception"
|
app:layout_constraintTop_toTopOf="@+id/reception"
|
||||||
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
|
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
|
||||||
tools:text="Yours (RSSI -61)" />
|
tools:text="Yours (RSSI -61)" />
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.Barrier
|
<androidx.constraintlayout.widget.Barrier
|
||||||
@@ -87,61 +87,334 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:barrierDirection="top"
|
app:barrierDirection="top"
|
||||||
app:constraint_referenced_ids="pod_left,pod_case,pod_right" />
|
app:constraint_referenced_ids="pod_left_container,pod_case_container,pod_right_container" />
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/pod_left"
|
android:id="@+id/pod_left_container"
|
||||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="4dp"
|
||||||
android:gravity="center_horizontal"
|
android:minHeight="108dp"
|
||||||
app:drawableTopCompat="@drawable/ic_airpod_left_24"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
||||||
app:layout_constraintEnd_toStartOf="@id/pod_case"
|
app:layout_constraintEnd_toStartOf="@id/pod_case_container"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
||||||
app:layout_constraintVertical_bias="0.0"
|
app:layout_constraintVertical_bias="0.0">
|
||||||
tools:text="Left pod\n100%\n(Primary)" />
|
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<ImageView
|
||||||
android:id="@+id/pod_case"
|
android:id="@+id/pod_left_icon"
|
||||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_airpod_left_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_left_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_left_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_left_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/pods_dual_left_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_left_battery_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_left_battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_left_battery_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/general_value_unknown_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_battery_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_left_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_left_charging_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_left_charging_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_left_charging_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_left_charging_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_charging_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_charging_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_left_battery_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_left_microphone_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_keyboard_voice_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_left_microphone_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_left_microphone_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_left_microphone_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_microphone_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_microphone_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_left_charging_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_left_wear_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_hearing_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_left_wear_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_left_wear_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_left_wear_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_inear_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_wear_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_left_microphone_label" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/pod_case_container"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="8dp"
|
android:layout_marginHorizontal="4dp"
|
||||||
android:gravity="center_horizontal"
|
|
||||||
app:drawableTopCompat="@drawable/ic_airpod_case_24"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
||||||
app:layout_constraintEnd_toStartOf="@id/pod_right"
|
app:layout_constraintEnd_toStartOf="@id/pod_right_container"
|
||||||
app:layout_constraintStart_toEndOf="@id/pod_left"
|
app:layout_constraintStart_toEndOf="@id/pod_left_container"
|
||||||
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
||||||
app:layout_constraintVertical_bias="0.0"
|
app:layout_constraintVertical_bias="0.0">
|
||||||
tools:text="Case\n50%" />
|
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<ImageView
|
||||||
android:id="@+id/pod_right"
|
android:id="@+id/pod_case_icon"
|
||||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_airpod_case_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_case_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_case_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_case_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/pods_case_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_case_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_case_battery_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_case_battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_case_battery_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/general_value_unknown_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_case_battery_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_case_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_case_charging_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_case_charging_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_case_charging_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_case_charging_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_charging_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_case_charging_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_case_battery_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_case_lid_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_grid_view_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_case_lid_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_case_lid_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_case_lid_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_case_status_closed_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_case_lid_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_case_charging_label" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/pod_right_container"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="4dp"
|
||||||
android:layout_marginEnd="16dp"
|
android:layout_marginEnd="16dp"
|
||||||
android:gravity="center_horizontal"
|
|
||||||
app:drawableTopCompat="@drawable/ic_airpod_right_24"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/pod_case"
|
app:layout_constraintStart_toEndOf="@id/pod_case_container"
|
||||||
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
||||||
app:layout_constraintVertical_bias="0.0"
|
app:layout_constraintVertical_bias="0.0">
|
||||||
tools:text="R 90%" />
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_airpod_right_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_right_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_right_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_right_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/pods_dual_right_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_right_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_battery_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_right_battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_right_battery_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/general_value_unknown_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_right_battery_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_right_label" />
|
||||||
|
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_charging_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_right_charging_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_right_charging_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_right_charging_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_charging_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_right_charging_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_right_battery_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_microphone_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_keyboard_voice_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_right_microphone_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_right_microphone_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_right_microphone_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_microphone_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_right_microphone_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_right_charging_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_wear_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_hearing_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_right_wear_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_right_wear_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_right_wear_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_inear_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_right_wear_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_right_microphone_label" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.Barrier
|
<androidx.constraintlayout.widget.Barrier
|
||||||
android:id="@+id/barrier_bottom"
|
android:id="@+id/barrier_bottom"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:barrierDirection="bottom"
|
app:barrierDirection="bottom"
|
||||||
app:constraint_referenced_ids="pod_left,pod_case,pod_right" />
|
app:constraint_referenced_ids="pod_left_container,pod_case_container,pod_right_container" />
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<com.google.android.material.textview.MaterialTextView
|
||||||
android:id="@+id/status"
|
android:id="@+id/status"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/reception"
|
app:layout_constraintStart_toEndOf="@id/reception"
|
||||||
app:layout_constraintTop_toTopOf="@+id/reception"
|
app:layout_constraintTop_toTopOf="@+id/reception"
|
||||||
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
|
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
|
||||||
tools:text="Yours (RSSI -61)" />
|
tools:text="Yours (RSSI -61)" />
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.Barrier
|
<androidx.constraintlayout.widget.Barrier
|
||||||
@@ -90,21 +90,40 @@
|
|||||||
app:constraint_referenced_ids="headphones"
|
app:constraint_referenced_ids="headphones"
|
||||||
tools:layout_editor_absoluteY="60dp" />
|
tools:layout_editor_absoluteY="60dp" />
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/headphones"
|
android:id="@+id/headphones"
|
||||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:gravity="center_horizontal"
|
android:layout_marginEnd="8dp"
|
||||||
app:drawableTopCompat="@drawable/ic_device_generic_headphones"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_goneMarginBottom="16dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
||||||
app:layout_constraintVertical_bias="0.0"
|
app:layout_constraintVertical_bias="0.0"
|
||||||
tools:text="Headphones\n50%\nCharging" />
|
app:layout_goneMarginBottom="16dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/battery_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/battery_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/battery_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/general_value_unknown_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/battery_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.Barrier
|
<androidx.constraintlayout.widget.Barrier
|
||||||
android:id="@+id/barrier_bottom"
|
android:id="@+id/barrier_bottom"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/reception"
|
app:layout_constraintStart_toEndOf="@id/reception"
|
||||||
app:layout_constraintTop_toTopOf="@+id/reception"
|
app:layout_constraintTop_toTopOf="@+id/reception"
|
||||||
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
|
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
|
||||||
tools:text="Yours (RSSI -61)" />
|
tools:text="Yours (RSSI -61)" />
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.Barrier
|
<androidx.constraintlayout.widget.Barrier
|
||||||
@@ -90,21 +90,81 @@
|
|||||||
app:constraint_referenced_ids="headphones"
|
app:constraint_referenced_ids="headphones"
|
||||||
tools:layout_editor_absoluteY="60dp" />
|
tools:layout_editor_absoluteY="60dp" />
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/headphones"
|
android:id="@+id/headphones"
|
||||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:gravity="center_horizontal"
|
android:layout_marginEnd="8dp"
|
||||||
app:drawableTopCompat="@drawable/ic_device_generic_headphones"
|
android:minHeight="64dp"
|
||||||
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_goneMarginBottom="16dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
||||||
app:layout_constraintVertical_bias="0.0"
|
app:layout_constraintVertical_bias="0.0"
|
||||||
tools:text="Headphones\n50%\nCharging" />
|
app:layout_goneMarginBottom="16dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/battery_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/battery_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/battery_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/general_value_unknown_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/battery_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/charging_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_power_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/charging_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/charging_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/charging_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/pods_charging_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/charging_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/battery_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/wear_icon"
|
||||||
|
style="@style/PodInfoItemIcon"
|
||||||
|
android:src="@drawable/ic_baseline_hearing_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/wear_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/wear_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/wear_label"
|
||||||
|
style="@style/PodInfoItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="@string/headset_being_worn_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/wear_icon"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/charging_label" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.Barrier
|
<androidx.constraintlayout.widget.Barrier
|
||||||
android:id="@+id/barrier_bottom"
|
android:id="@+id/barrier_bottom"
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/reception"
|
app:layout_constraintStart_toEndOf="@id/reception"
|
||||||
app:layout_constraintTop_toTopOf="@+id/reception"
|
app:layout_constraintTop_toTopOf="@+id/reception"
|
||||||
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
|
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
|
||||||
tools:text="Yours (RSSI -61)" />
|
tools:text="Yours (RSSI -61)" />
|
||||||
|
|
||||||
<com.google.android.material.textview.MaterialTextView
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
|||||||
@@ -1,72 +1,182 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content">
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
<ImageView
|
||||||
android:id="@+id/pod_label"
|
android:id="@+id/pod_icon"
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/ic_device_generic_earbuds"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_label"
|
||||||
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_label"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginBottom="4dp"
|
app:layout_constraintEnd_toStartOf="@id/signal"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="AirPods Pro" />
|
tools:text="AirPods Pro" />
|
||||||
|
|
||||||
<LinearLayout
|
<com.google.android.material.textview.MaterialTextView
|
||||||
android:layout_width="match_parent"
|
android:id="@+id/signal"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Caption"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="8dp"
|
android:drawableEnd="@drawable/ic_baseline_signal_cellular_alt_24"
|
||||||
android:orientation="horizontal">
|
android:gravity="center"
|
||||||
|
android:minWidth="44dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/pod_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/pod_label"
|
||||||
|
tools:text="-90%" />
|
||||||
|
|
||||||
<FrameLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:layout_width="0dp"
|
android:id="@id/pod_left_container"
|
||||||
android:layout_height="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_weight="1" />
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_case_container"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="spread"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_label">
|
||||||
|
|
||||||
<TextView
|
<ImageView
|
||||||
android:id="@+id/pod_left"
|
android:id="@+id/pod_left_icon"
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:drawableStart="@drawable/ic_airpod_left_24"
|
android:src="@drawable/ic_airpod_left_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_left_battery_icon"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_left_battery_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@id/pod_left_battery_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_left_battery_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_left_battery_label"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_left_battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_left_battery_label"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_battery_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="100%" />
|
tools:text="100%" />
|
||||||
|
|
||||||
<FrameLayout
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1" />
|
|
||||||
|
|
||||||
<TextView
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/pod_case"
|
android:id="@+id/pod_case_container"
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_right_container"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="spread"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_left_container"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_label">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_case_icon"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_marginHorizontal="8dp"
|
android:src="@drawable/ic_airpod_case_24"
|
||||||
android:drawableStart="@drawable/ic_airpod_case_24"
|
app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_case_battery_icon"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_case_battery_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@id/pod_case_battery_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_case_battery_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_case_battery_label"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_case_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_case_battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_case_battery_label"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_case_battery_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="100%" />
|
tools:text="100%" />
|
||||||
|
|
||||||
<FrameLayout
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1" />
|
|
||||||
|
|
||||||
<TextView
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:id="@+id/pod_right"
|
android:id="@id/pod_right_container"
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="spread"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_case_container"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/pod_label">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/pod_right_icon"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:drawableStart="@drawable/ic_airpod_right_24"
|
android:src="@drawable/ic_airpod_right_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_right_battery_icon"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_right_battery_label" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@id/pod_right_battery_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/pod_right_battery_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/pod_right_battery_label"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_right_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/pod_right_battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/pod_right_battery_label"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/pod_right_battery_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="100%" />
|
tools:text="100%" />
|
||||||
|
|
||||||
<FrameLayout
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1" />
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,29 +1,81 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content">
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
<ImageView
|
||||||
android:id="@+id/headphones_label"
|
android:id="@+id/headphones_icon"
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/ic_device_generic_earbuds"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/headphones_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/headphones_label"
|
||||||
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/headphones_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/headphones_label"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginBottom="4dp"
|
app:layout_constraintEnd_toStartOf="@id/signal"
|
||||||
android:gravity="center"
|
app:layout_constraintStart_toEndOf="@id/headphones_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="AirPods Max" />
|
tools:text="AirPods Max" />
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.textview.MaterialTextView
|
||||||
android:id="@+id/headphones"
|
android:id="@+id/signal"
|
||||||
style="@style/TextAppearance.Compat.Notification.Title"
|
style="@style/TextAppearance.MaterialComponents.Caption"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:drawableEnd="@drawable/ic_baseline_signal_cellular_alt_24"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:gravity="center"
|
||||||
android:drawableStart="@drawable/ic_device_generic_headphones"
|
android:minWidth="44dp"
|
||||||
tools:text="100%" />
|
app:layout_constraintBottom_toBottomOf="@+id/headphones_label"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/headphones_label"
|
||||||
|
tools:text="-90%" />
|
||||||
|
|
||||||
</LinearLayout>
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/headphones_container"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_chainStyle="spread"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/headphones_label">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@id/headphones_battery_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@drawable/ic_baseline_battery_unknown_24"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/headphones_battery_label"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/headphones_battery_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/headphones_battery_label" />
|
||||||
|
|
||||||
|
<com.google.android.material.textview.MaterialTextView
|
||||||
|
android:id="@+id/headphones_battery_label"
|
||||||
|
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/headphones_battery_icon"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="100%" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user