Compare commits

...
21 Commits
Author SHA1 Message Date
darken e9fa9f6cb8 Release: 2.7.0-rc0 2023-01-25 16:54:32 +01:00
Matthias Urhahn b98ca86886 Faster CI checks (#83)
* Parallelize CI code checks

* Only assemble debug variant, otherwise we need to setup signing.
2023-01-25 16:47:46 +01:00
Matthias Urhahn d917c8ffe6 New compatibility option for receiving BLE data (#82)
* Improve compat options: Add alternative method for receiving BLE scan results via PendingIntents

* Add missing logtags

* Reduce log spam

* Make the linter happy
2023-01-25 16:27:29 +01:00
Matthias Urhahn 5d44737199 Merge pull request #80 from d4rken-org/compatibility_options
Improve compatibility options
2023-01-25 14:56:10 +01:00
Matthias Urhahn 6e8693a6b0 Merge pull request #81 from d4rken-org/modularize_permissions
Move shared permissions into the common module
2023-01-25 14:55:54 +01:00
darken 6d9e7404a1 Improve compatibility options 2023-01-25 14:46:20 +01:00
darken 70ceb389b5 Move shared permissions into the common module. 2023-01-25 14:44:38 +01:00
darken 832e364a2f Update translations 2023-01-25 09:26:50 +01:00
darken 51f9c5c7d8 Release: 2.6.1-rc0 2023-01-20 12:55:28 +01:00
Matthias Urhahn 15141c8528 More granular pod features (#76)
* Make pod features more granular
Don't assume all AirPods/Beats return a correct connection state.

* Refactoring

* Fix refactoring regression

* AirPods Gen1 don't support state detection
2023-01-18 22:15:38 +01:00
Matthias Urhahn 0cdac10fd4 Update README.md 2023-01-14 16:45:11 +01:00
darken aaab548bfd Release: 2.6.0-rc0 2023-01-14 16:44:16 +01:00
Matthias Urhahn 46611b5606 Add preliminary support for Beats Fit Pro (#75) 2023-01-14 16:35:04 +01:00
Matthias Urhahn fdbdb46577 Overhaul play/pause reaction code (#74) 2023-01-14 16:21:57 +01:00
darken e7fd81d0eb Release: 2.5.3-rc1 2023-01-12 12:51:46 +01:00
darken ce50bae6a0 Merge branch 'main' of github.com:d4rken-org/capod into main 2023-01-12 12:51:03 +01:00
darken 8331cf3866 Release: 2.5.3-rc0 2023-01-12 12:50:53 +01:00
Matthias Urhahn 670262e53b Update README.md 2023-01-12 12:50:44 +01:00
Matthias Urhahn c584f942cf Support for fake AirPods 2. Generation (#73) 2023-01-12 12:32:44 +01:00
darken e83b6bb583 Update readme 2023-01-12 12:11:46 +01:00
darken cc5de668f0 Add additional test data for fake AirPods Pro2 2023-01-12 12:07:24 +01:00
58 changed files with 1010 additions and 415 deletions
+30 -12
View File
@@ -7,10 +7,14 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
build-and-test: build-all:
name: Build and test name: Assemble all variants
strategy:
fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Debug ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up JDK 11 - name: Set up JDK 11
@@ -19,16 +23,30 @@ jobs:
java-version: '11' java-version: '11'
distribution: 'adopt' distribution: 'adopt'
cache: gradle cache: gradle
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Build FOSS variant - name: Build (Flavor ${{ matrix.flavor }}, Variant ${{ matrix.variant }})
run: ./gradlew assembleFossDebug run: ./gradlew assemble${{ matrix.flavor }}${{ matrix.variant }}
- name: Test FOSS variant
run: ./gradlew testFossDebugUnitTest
- name: Build Google Play variant test-all:
run: ./gradlew assembleGplayDebug name: Run all tests
- name: Test Google Play variant strategy:
run: ./gradlew testGplayDebugUnitTest fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Release ]
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: Run tests (Flavor ${{ matrix.flavor }}, Variant ${{ matrix.variant }})
run: ./gradlew test${{ matrix.flavor }}${{ matrix.variant }}
+12 -6
View File
@@ -34,15 +34,21 @@ Currently supported models:
* Beats Studio 3 * Beats Studio 3
* Beats X * Beats X
* Beats Flex * Beats Flex
* Beats Fit Pro
* Fake AirPods 1. Generation 🎭
* Fake AirPods 2. Generation 🎭
* Fake AirPods 3. Generation 🎭
* Fake AirPods Pro1 🎭
* Fake AirPods Pro2 🎭
## Download ## Download
| Source | Status | | Source | Status |
|-----------------------|--------| |------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplayshields.herokuapp.com%2Fplay%3Fi%3Deu.darken.capod%26l%3DAndroid%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) | | [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | ? | | [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) | | [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) | | [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
## Support the project ## Support the project
+1 -1
View File
@@ -1 +1 @@
2.5.2-rc0 20502000 2.7.0-rc0 20700000
+32 -1
View File
@@ -1,4 +1,35 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.darken.capod.common"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.darken.capod.common">
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<application>
<receiver
android:name=".bluetooth.BleScanResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS" />
</intent-filter>
</receiver>
</application>
</manifest> </manifest>
@@ -0,0 +1,33 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BleScanResultForwarder @Inject constructor() {
private val forwarder = MutableSharedFlow<Collection<ScanResult>>(
replay = 0,
extraBufferCapacity = 128,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val results: Flow<Collection<ScanResult>> = forwarder
fun forward(scanResults: Collection<ScanResult>) {
log(TAG, VERBOSE) { "forward($scanResults)" }
val success = forwarder.tryEmit(scanResults)
if (!success) log(TAG, WARN) { "Failed to forward (overflow?) $scanResults" }
}
companion object {
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder")
}
}
@@ -0,0 +1,64 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class BleScanResultReceiver : BroadcastReceiver() {
@Inject @AppScope lateinit var appScope: CoroutineScope
@Inject lateinit var scanResultForwarder: BleScanResultForwarder
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "onReceive($context, $intent)" }
if (intent.action != ACTION) {
log(TAG, WARN) { "Unknown action: ${intent.action}" }
return
}
if (intent.extras == null) {
log(TAG) { "Extras are null!" }
return
}
val errorCode = intent.getIntExtra(BluetoothLeScanner.EXTRA_ERROR_CODE, 0)
log(TAG, VERBOSE) { "errorCode=$errorCode" }
if (errorCode != 0) {
log(TAG, WARN) { "ScanCallback error code: $errorCode" }
return
}
val callbackType = intent.getIntExtra(BluetoothLeScanner.EXTRA_CALLBACK_TYPE, -1)
log(TAG, VERBOSE) { "callbackType=$callbackType" }
val scanResults = intent.getParcelableArrayListExtra<ScanResult>(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT)
log(TAG, VERBOSE) { "scanResults=$scanResults" }
if (scanResults == null) {
log(TAG) { "Scan results were empty!" }
return
}
val pending = goAsync()
appScope.launch {
scanResultForwarder.forward(scanResults)
pending.finish()
}
}
companion object {
private val TAG = logTag("Bluetooth", "BleScanner", "Forwarder", "Receiver")
const val ACTION = "eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS"
}
}
@@ -1,20 +1,21 @@
package eu.darken.capod.common.bluetooth package eu.darken.capod.common.bluetooth
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.* import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
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.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -25,28 +26,48 @@ class BleScanner @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData, private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
) { ) {
@SuppressLint("MissingPermission") fun scan( @SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>, filters: Set<ScanFilter>,
scannerMode: ScannerMode, scannerMode: ScannerMode = ScannerMode.BALANCED,
compatMode: Boolean, disableOffloadFiltering: Boolean = true,
): Flow<List<BleScanResult>> = callbackFlow { disableOffloadBatching: Boolean = true,
log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" } disableDirectScanCallback: Boolean = true,
if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" } ): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable") val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable")
val supportsOffloadFiltering = adapter.isOffloadedFilteringSupported.also { val useOffloadedFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && !compatMode } && !disableOffloadFiltering
if (disableOffloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" }
val supportsOffloadBatching = adapter.isOffloadedScanBatchingSupported.also { val useOffloadedBatching = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && !compatMode } && !disableOffloadBatching
if (disableOffloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" }
if (disableDirectScanCallback) log(TAG, WARN) { "Direct scan callback is disabled!" }
val scanner = bluetoothManager.scanner ?: throw IllegalStateException("BLE scanner unavailable") val scanner = bluetoothManager.scanner ?: throw IllegalStateException("BLE scanner unavailable")
val filterResults: (Collection<ScanResult>) -> Collection<BleScanResult> = { results ->
results
.filter { result ->
val passed = when {
useOffloadedFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
}
val callback = object : ScanCallback() { val callback = object : ScanCallback() {
var lastScanAt = System.currentTimeMillis() var lastScanAt = System.currentTimeMillis()
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
@@ -55,17 +76,8 @@ class BleScanner @Inject constructor(
lastScanAt = System.currentTimeMillis() lastScanAt = System.currentTimeMillis()
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)" "onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
} }
val toSend = if (
supportsOffloadFiltering trySend(filterResults(setOf(result)))
|| filters.isEmpty()
|| filters.any { it.matchesSafe(result) }
) {
listOf(BleScanResult.fromScanResult(result))
} else {
log(TAG, VERBOSE) { "Manual filtering: No match for $result" }
emptyList()
}
trySend(toSend)
} }
override fun onBatchScanResults(results: MutableList<ScanResult>) { override fun onBatchScanResults(results: MutableList<ScanResult>) {
@@ -75,18 +87,7 @@ class BleScanner @Inject constructor(
"onBatchScanResults(delay=${delay}ms, results=$results)" "onBatchScanResults(delay=${delay}ms, results=$results)"
} }
val toSend = results trySend(filterResults(results))
.filter { result ->
val passed = when {
supportsOffloadFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
trySend(toSend)
} }
override fun onScanFailed(errorCode: Int) { override fun onScanFailed(errorCode: Int) {
@@ -94,58 +95,113 @@ class BleScanner @Inject constructor(
} }
} }
val forwarderConsumer = if (disableDirectScanCallback) {
val settings = ScanSettings.Builder().apply { scanResultForwarder.results
setScanMode( .onEach { results -> trySend(filterResults(results)) }
when (scannerMode) { .launchIn(this)
ScannerMode.LOW_POWER -> ScanSettings.SCAN_MODE_LOW_POWER } else {
ScannerMode.BALANCED -> ScanSettings.SCAN_MODE_BALANCED null
ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY
}
)
if (supportsOffloadBatching) {
setReportDelay(
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
)
}
}.build()
log(TAG, VERBOSE) { "Settings created for offloaded filtering: $settings" }
val flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
log(TAG, VERBOSE) { "Flushing scan results." }
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
} }
scanner.startScan( val flushJob = if (!disableDirectScanCallback) {
if (supportsOffloadFiltering) filters.toList() else listOf(ScanFilter.Builder().build()), launch {
settings, log(TAG) { "Flush job launched" }
callback while (isActive) {
) log(TAG, VERBOSE) { "Flushing scan results." }
log(TAG) { "BleScanner started (filters=$filters, settings=$settings)" } // Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} else {
null
}
val filterList = when {
useOffloadedFiltering -> filters.toList()
else -> emptyList()
}
val scanSettings = ScanSettings.Builder().apply {
setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
when (scannerMode) {
ScannerMode.LOW_POWER -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
}
ScannerMode.BALANCED -> {
setScanMode(ScanSettings.SCAN_MODE_BALANCED)
setMatchMode(ScanSettings.MATCH_MODE_STICKY)
setNumOfMatches(ScanSettings.MATCH_NUM_FEW_ADVERTISEMENT)
}
ScannerMode.LOW_LATENCY -> {
setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
}
}
val delay = if (useOffloadedBatching) {
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
} else {
0L // Anything > 0 enables batching
}
setReportDelay(delay)
}.build()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) { "Intent callback: startScan(filters=$filters, settings=$scanSettings, callbackIntent=$callbackIntent)" }
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) { "Direct callback: startScan(filters=$filters, settings=$scanSettings, callback=$callback)" }
scanner.startScan(filterList, scanSettings, callback)
}
awaitClose { awaitClose {
flushJob.cancel() forwarderConsumer?.cancel()
scanner.stopScan(callback) flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
log(TAG) { "BleScanner stopped" } log(TAG) { "BleScanner stopped" }
} }
} }
.map { fakeBleData.maybeAddfakeData(it) } .map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply {
action = BleScanResultReceiver.ACTION
}
}
private fun createStartIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
CALLBACK_INTENT_REQUESTCODE,
receiverIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
private fun createStopIntent(): PendingIntent = PendingIntent.getBroadcast(
context,
270,
receiverIntent,
PendingIntentCompat.FLAG_IMMUTABLE
)
companion object { companion object {
private const val CALLBACK_INTENT_REQUESTCODE = 270
private val TAG = logTag("Bluetooth", "BleScanner") private val TAG = logTag("Bluetooth", "BleScanner")
} }
} }
@@ -12,7 +12,7 @@ class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings, private val debugSettings: DebugSettings,
) { ) {
fun maybeAddfakeData(originals: List<BleScanResult>): List<BleScanResult> { fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals if (!debugSettings.showFakeData.value) return originals
return originals + getFakeData() return originals + getFakeData()
} }
@@ -9,4 +9,9 @@ object PendingIntentCompat {
} else { } else {
0 0
} }
val FLAG_MUTABLE: Int = if (hasApiLevel(31)) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
} }
@@ -23,51 +23,32 @@ class GeneralSettings @Inject constructor(
override val preferences: SharedPreferences = context.getSharedPreferences("settings_general", Context.MODE_PRIVATE) override val preferences: SharedPreferences = context.getSharedPreferences("settings_general", Context.MODE_PRIVATE)
val monitorMode = preferences.createFlowPreference( val monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi)
"core.monitor.mode", val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi)
MonitorMode.AUTOMATIC,
moshi
)
val scannerMode = preferences.createFlowPreference( val showAll = preferences.createFlowPreference("core.showall.enabled", false)
"core.scanner.mode",
ScannerMode.LOW_LATENCY,
moshi
)
val compatibilityMode = preferences.createFlowPreference( val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.25f)
"core.compatibility.enabled",
val mainDeviceAddress = preferences.createFlowPreference<String?>("core.maindevice.address", null)
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
val isOffloadedFilteringDisabled = preferences.createFlowPreference(
"core.compat.offloaded.filtering.disabled",
false false
) )
val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false)
val showAll = preferences.createFlowPreference( val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false)
"core.showall.enabled",
false
)
val minimumSignalQuality = preferences.createFlowPreference(
"core.signal.minimum",
0.25f
)
val mainDeviceAddress = preferences.createFlowPreference<String?>(
"core.maindevice.address",
null
)
val mainDeviceModel = preferences.createFlowPreference<PodDevice.Model>(
"core.maindevice.model",
PodDevice.Model.UNKNOWN,
moshi
)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode, monitorMode,
scannerMode, scannerMode,
compatibilityMode,
showAll, showAll,
minimumSignalQuality, minimumSignalQuality,
mainDeviceAddress, mainDeviceAddress,
isOffloadedFilteringDisabled,
isOffloadedBatchingDisabled,
useIndirectScanResultCallback,
debugSettings.isAutoReportingEnabled, debugSettings.isAutoReportingEnabled,
) )
} }
@@ -4,6 +4,7 @@ import android.bluetooth.le.ScanFilter
import eu.darken.capod.common.bluetooth.BleScanResult 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.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.DebugSettings import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -83,26 +84,50 @@ class PodMonitor @Inject constructor(
.setupCommonEventHandlers(TAG) { "mainDevice" } .setupCommonEventHandlers(TAG) { "mainDevice" }
.replayingShare(appScope) .replayingShare(appScope)
private data class ScannerOptions(
val scannerMode: ScannerMode,
val showUnfiltered: Boolean,
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean,
val disableDirectCallback: Boolean,
)
private fun createBleScanner() = combine( private fun createBleScanner() = combine(
generalSettings.scannerMode.flow, generalSettings.scannerMode.flow,
generalSettings.compatibilityMode.flow, debugSettings.showUnfiltered.flow,
debugSettings.showUnfiltered.flow generalSettings.isOffloadedBatchingDisabled.flow,
) { scannerMode, compatMode, unfiltered -> generalSettings.isOffloadedFilteringDisabled.flow,
Triple(scannerMode, compatMode, unfiltered) generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
)
} }
.flatMapLatest { (mode, compat, unfiltered) -> .flatMapLatest { options ->
val filters = when { val filters = when {
unfiltered -> { options.showUnfiltered -> {
log(TAG, WARN) { "Using unfiltered scan mode" } log(TAG, WARN) { "Using unfiltered scan mode" }
setOf(getUnfilteredFilter()) setOf(ScanFilter.Builder().build())
} }
else -> ProximityPairing.getBleScanFilter() else -> ProximityPairing.getBleScanFilter()
} }
bleScanner.scan( bleScanner.scan(
filters = filters, filters = filters,
scannerMode = mode, scannerMode = options.scannerMode,
compatMode = compat, disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
).map { preFilterAndMap(it) } ).map { preFilterAndMap(it) }
} }
@@ -134,7 +159,7 @@ class PodMonitor @Inject constructor(
return pods return pods
} }
private suspend fun preFilterAndMap(rawResults: List<BleScanResult>): List<PodFactory.Result> = rawResults private suspend fun preFilterAndMap(rawResults: Collection<BleScanResult>): List<PodFactory.Result> = rawResults
.groupBy { it.address } .groupBy { it.address }
.values .values
.map { sameAdrDevs -> .map { sameAdrDevs ->
@@ -187,10 +212,6 @@ class PodMonitor @Inject constructor(
.also { log(TAG) { "Cached mainDevice is $it" } } .also { log(TAG) { "Cached mainDevice is $it" } }
} }
private fun getUnfilteredFilter(): ScanFilter {
return ScanFilter.Builder().build()
}
companion object { companion object {
private val TAG = logTag("Monitor", "PodMonitor") private val TAG = logTag("Monitor", "PodMonitor")
} }
@@ -104,10 +104,17 @@ interface PodDevice {
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO( @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro" "Power Beats Pro"
), ),
@Json(name = "beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro"
),
@Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1( @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD", "AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen1_both, R.drawable.devic_airpods_gen1_both,
), ),
@Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3( @Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD", "AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both, R.drawable.devic_airpods_gen2_both,
@@ -7,10 +7,7 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.* import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.* import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1 import eu.darken.capod.pods.core.apple.misc.*
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@Module @Module
@@ -30,8 +27,10 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
@@ -79,13 +79,13 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
.mapNotNull { it.batteryCasePercent } .mapNotNull { it.batteryCasePercent }
.lastOrNull() .lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualAirPods): DualAirPods.LidState? { fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualAirPods.LidState.OPEN, DualAirPods.LidState.CLOSED) val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null if (definitive.contains(basic.caseLidState)) return null
return history return history
.filterIsInstance<AirPodsPro>() // TODO why is this AirPodsPro specific here? .filterIsInstance<AirPodsPro>() // TODO why is this AirPodsPro specific here?
.lastOrNull { it.caseLidState != DualAirPods.LidState.NOT_IN_CASE } .lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState ?.caseLidState
} }
@@ -1,8 +1,5 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import android.content.Context
import androidx.annotation.StringRes
import eu.darken.capod.common.R
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.lowerNibble
@@ -10,8 +7,8 @@ import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice.Pod import eu.darken.capod.pods.core.DualPodDevice.Pod
interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase, interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasStateDetection, HasDualMicrophone, HasAppleColor { HasDualMicrophone, HasAppleColor {
val primaryPod: Pod val primaryPod: Pod
get() = when (rawStatus.isBitSet(5)) { get() = when (rawStatus.isBitSet(5)) {
@@ -142,21 +139,4 @@ interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEar
UNKNOWN(0xFF..0xFF); UNKNOWN(0xFF..0xFF);
} }
override val state: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
IDLE(0x04, R.string.pods_connection_state_idle_label),
MUSIC(0x05, R.string.pods_connection_state_music_label),
CALL(0x06, R.string.pods_connection_state_call_label),
RINGING(0x07, R.string.pods_connection_state_ringing_label),
HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label),
UNKNOWN(null, R.string.pods_connection_state_unknown_label);
override fun getLabel(context: Context): String = context.getString(labelRes)
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
} }
@@ -4,9 +4,9 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualAirPods>(tag) { abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
fun DualAirPods.getCaseMatchMarkings() = SplitPodsMarkings( fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent, leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent, rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone, microPhoneLeft = isLeftPodMicrophone,
@@ -31,17 +31,17 @@ abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<
val model: PodDevice.Model, val model: PodDevice.Model,
) )
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualAirPods): Collection<KnownDevice> { private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings() val target = device.getCaseMatchMarkings()
return filter { known -> return filter { known ->
known.history known.history
.filterIsInstance<DualAirPods>() .filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target } .any { it.getCaseMatchMarkings() == target }
} }
} }
override fun searchHistory(current: DualAirPods): KnownDevice? { override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current) val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current) val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag 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.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory 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
@@ -20,15 +20,15 @@ data class AirPodsGen1 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null private val cachedCaseState: DualApplePods.LidState? = null
) : DualAirPods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag 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.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory 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
@@ -20,19 +20,19 @@ data class AirPodsGen2 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null private val cachedCaseState: DualApplePods.LidState? = null
) : DualAirPods { ) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag 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.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory 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
@@ -20,18 +20,19 @@ data class AirPodsGen3 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null private val cachedCaseState: DualApplePods.LidState? = null
) : DualAirPods { ) : DualApplePods, HasStateDetectionAirPods {
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
override val caseLidState: DualAirPods.LidState override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -21,12 +21,12 @@ data class AirPodsMax(
override val proximityMessage: ProximityPairing.Message, override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection { ) : SingleApplePods, HasEarDetection, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super<SingleApplePods>.rssi
val isHeadphonesBeingWorn: Boolean val isHeadphonesBeingWorn: Boolean
get() = rawStatus.isBitSet(1) get() = rawStatus.isBitSet(1)
@@ -6,8 +6,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag 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.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory 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
@@ -24,7 +24,7 @@ data class AirPodsPro(
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null private val cachedCaseState: LidState? = null
) : DualAirPods { ) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@@ -51,7 +51,7 @@ data class AirPodsPro(
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -6,8 +6,8 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag 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.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory 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
@@ -24,7 +24,7 @@ data class AirPodsPro2(
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null private val cachedCaseState: LidState? = null
) : DualAirPods { ) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2 override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2
@@ -51,7 +51,7 @@ data class AirPodsPro2(
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super.rssi get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) { class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -0,0 +1,27 @@
package eu.darken.capod.pods.core.apple.airpods
import android.content.Context
import androidx.annotation.StringRes
import eu.darken.capod.common.R
import eu.darken.capod.pods.core.HasStateDetection
import eu.darken.capod.pods.core.apple.ApplePods
interface HasStateDetectionAirPods : HasStateDetection, ApplePods {
override val state: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
IDLE(0x04, R.string.pods_connection_state_idle_label),
MUSIC(0x05, R.string.pods_connection_state_music_label),
CALL(0x06, R.string.pods_connection_state_call_label),
RINGING(0x07, R.string.pods_connection_state_ringing_label),
HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label),
UNKNOWN(null, R.string.pods_connection_state_unknown_label);
override fun getLabel(context: Context): String = context.getString(labelRes)
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
}
@@ -0,0 +1,70 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsFitPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FIT_PRO
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
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(message: ProximityPairing.Message): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsFitPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
private val DEVICE_CODE = 0x1220.toUShort()
private val TAG = logTag("PodDevice", "Beats", "Fit", "Pro")
}
}
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag 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.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory 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
@@ -20,15 +20,15 @@ data class PowerBeatsPro(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null private val cachedCaseState: DualApplePods.LidState? = null
) : DualAirPods { ) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
override val batteryCasePercent: Float? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState get() = cachedCaseState ?: super.caseLidState
override val rssi: Int override val rssi: Int
@@ -0,0 +1,150 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
/**
* Similar data structure but a lot of placeholder values or hardcoded values
*/
data class FakeAirPodsGen2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN2
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen2>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x0F20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen2")
}
}
@@ -11,7 +11,7 @@ import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
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
@@ -30,7 +30,7 @@ data class FakeAirPodsPro2 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE, override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null, private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null, private val cachedCaseState: DualApplePods.LidState? = null,
) : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase { ) : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO2 override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO2
@@ -10,7 +10,7 @@ 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.HasEarDetection import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import javax.inject.Inject import javax.inject.Inject
@@ -69,7 +69,7 @@ class AutoConnect @Inject constructor(
val conditionFulfilled = when (condition) { val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (mainDevice) { AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualAirPods -> mainDevice.caseLidState == DualAirPods.LidState.OPEN is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true else -> true
} }
AutoConnectCondition.IN_EAR -> when (mainDevice) { AutoConnectCondition.IN_EAR -> when (mainDevice) {
@@ -3,6 +3,7 @@ package eu.darken.capod.reaction.core.playpause
import eu.darken.capod.common.MediaControl import eu.darken.capod.common.MediaControl
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.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
@@ -40,46 +41,51 @@ class PlayPause @Inject constructor(
} }
.distinctUntilChanged() .distinctUntilChanged()
.withPrevious() .withPrevious()
.filter { (previous, current) ->
if (previous == null || current == null) return@filter false
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
val match = previous.identifier == current.identifier
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
match
}
.onEach { (previous, current) -> .onEach { (previous, current) ->
if (previous is HasEarDetection && current is HasEarDetection) { log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
if (previous.identifier != current.identifier) {
return@onEach
}
if (previous is HasEarDetectionDual && current is HasEarDetectionDual && reactionSettings.onePodMode.value) { val previousWorn: Boolean?
log(TAG) { "Ear status changed for dual pod device in single pod mode." } val currentWorn: Boolean?
val previousWorn = previous.isEitherPodInEar
val currentWorn = current.isEitherPodInEar when {
if (!previousWorn && currentWorn && !mediaControl.isPlaying) { reactionSettings.onePodMode.value && previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
if (reactionSettings.autoPlay.value) { previousWorn = previous.isEitherPodInEar
mediaControl.sendPlay() currentWorn = current.isEitherPodInEar
} else { log(TAG, VERBOSE) { "previous: left=${previous.isLeftPodInEar}, right=${previous.isRightPodInEar}" }
log(TAG) { "autoPlay is disabled" } log(TAG, VERBOSE) { "current: left${current.isLeftPodInEar}, right=${current.isRightPodInEar}" }
} }
} else if (!currentWorn && previousWorn && mediaControl.isPlaying) { previous is HasEarDetection && current is HasEarDetection -> {
if (reactionSettings.autoPause.value) { previousWorn = previous.isBeingWorn
mediaControl.sendPause() currentWorn = current.isBeingWorn
} else { log(TAG, VERBOSE) { "prev.isBeingWorn=${previousWorn}, cur.isBeingWorn=${currentWorn}" }
log(TAG) { "autoPause is disabled" } }
} else -> {
} log(TAG, VERBOSE) { "Current devices don't support ear detection." }
} else if (previous.isBeingWorn != current.isBeingWorn) { previousWorn = null
log(TAG) { "Ear status changed for monitored device." } currentWorn = null
if (current.isBeingWorn && !mediaControl.isPlaying) { }
if (reactionSettings.autoPlay.value) { }
mediaControl.sendPlay()
} else { if (previousWorn == false && currentWorn == true && !mediaControl.isPlaying) {
log(TAG) { "autoPlay is disabled" } if (reactionSettings.autoPlay.value) {
} log(TAG) { "autoPlay is triggered, sendPlay()" }
} else if (!current.isBeingWorn && mediaControl.isPlaying) { mediaControl.sendPlay()
if (reactionSettings.autoPause.value) { } else {
mediaControl.sendPause() log(TAG, VERBOSE) { "autoPlay is disabled" }
} else { }
log(TAG) { "autoPause is disabled" } } else if (previousWorn == true && currentWorn == false && mediaControl.isPlaying) {
} if (reactionSettings.autoPause.value) {
} log(TAG) { "autoPause is triggered, sendPause()" }
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
} }
} }
} }
@@ -7,7 +7,7 @@ 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.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import java.time.Duration import java.time.Duration
@@ -34,7 +34,7 @@ class PopUpReaction @Inject constructor(
.withPrevious() .withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" } .setupCommonEventHandlers(TAG) { "monitor" }
.mapNotNull { (previous, current) -> .mapNotNull { (previous, current) ->
if (previous !is DualAirPods? || current !is DualAirPods) { if (previous !is DualApplePods? || current !is DualApplePods) {
return@mapNotNull null return@mapNotNull null
} }
log(TAG, VERBOSE) { log(TAG, VERBOSE) {
@@ -57,8 +57,8 @@ class PopUpReaction @Inject constructor(
tryPopWindow(current) tryPopWindow(current)
} }
private suspend fun tryPopWindow(current: DualAirPods): Event? = when { private suspend fun tryPopWindow(current: DualApplePods): Event? = when {
current.caseLidState == DualAirPods.LidState.OPEN -> { current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" } log(TAG, INFO) { "Show popup" }
val now = Instant.now() val now = Instant.now()
@@ -74,9 +74,9 @@ class PopUpReaction @Inject constructor(
null null
} }
} }
current.caseLidState != DualAirPods.LidState.OPEN -> { current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) { when (current.caseLidState) {
DualAirPods.LidState.CLOSED -> { DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." } log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
coolDowns.remove(current.identifier) coolDowns.remove(current.identifier)
} }
@@ -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,4H18V5H21V7H18V9H21V11H18V13H21V15H18V17H21V19H18V20H6V19H3V17H6V15H3V13H6V11H3V9H6V7H3V5H6V4M11,15V18H12V15H11M13,15V18H14V15H13M15,15V18H16V15H15Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M22.77 19.32L21.7 18.5C21.72 18.33 21.74 18.17 21.74 18S21.73 17.67 21.7 17.5L22.76 16.68C22.85 16.6 22.88 16.47 22.82 16.36L21.82 14.63C21.76 14.5 21.63 14.5 21.5 14.5L20.27 15C20 14.82 19.73 14.65 19.42 14.53L19.23 13.21C19.22 13.09 19.11 13 19 13H17C16.87 13 16.76 13.09 16.74 13.21L16.55 14.53C16.25 14.66 15.96 14.82 15.7 15L14.46 14.5C14.35 14.5 14.22 14.5 14.15 14.63L13.15 16.36C13.09 16.47 13.11 16.6 13.21 16.68L14.27 17.5C14.25 17.67 14.24 17.83 14.24 18S14.25 18.33 14.27 18.5L13.21 19.32C13.12 19.4 13.09 19.53 13.15 19.64L14.15 21.37C14.21 21.5 14.34 21.5 14.46 21.5L15.7 21C15.96 21.18 16.24 21.35 16.55 21.47L16.74 22.79C16.76 22.91 16.86 23 17 23H19C19.11 23 19.22 22.91 19.24 22.79L19.43 21.47C19.73 21.34 20 21.18 20.27 21L21.5 21.5C21.63 21.5 21.76 21.5 21.83 21.37L22.83 19.64C22.89 19.53 22.86 19.4 22.77 19.32M18 19.5C17.16 19.5 16.5 18.83 16.5 18S17.17 16.5 18 16.5 19.5 17.17 19.5 18 18.83 19.5 18 19.5M17.62 3.22C17.43 3.08 17.22 3 17 3H3C2.78 3 2.57 3.08 2.38 3.22C1.95 3.56 1.87 4.19 2.21 4.62L7 10.75V15.87C6.96 16.16 7.06 16.47 7.29 16.7L11.3 20.71C11.4 20.81 11.5 20.88 11.65 20.93C11.22 20 11 19 11 18C11 16.17 11.72 14.41 13 13.1V10.75L17.79 4.62C18.13 4.19 18.05 3.56 17.62 3.22M11 10.05V17.58L9 15.58V10.06L5.04 5H14.96L11 10.05Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M5 5V19H7V21H3V3H7V5H5M20 7H7V9H20V7M20 11H7V13H20V11M20 15H7V17H20V15Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6.91 5.5L9.21 7.79L7.79 9.21L5.5 6.91L3.21 9.21L1.79 7.79L4.09 5.5L1.79 3.21L3.21 1.79L5.5 4.09L7.79 1.79L9.21 3.21M22.21 16.21L20.79 14.79L18.5 17.09L16.21 14.79L14.79 16.21L17.09 18.5L14.79 20.79L16.21 22.21L18.5 19.91L20.79 22.21L22.21 20.79L19.91 18.5M20.4 6.83L17.18 11L15.6 9.73L16.77 8.23A9.08 9.08 0 0 0 10.11 13.85A4.5 4.5 0 1 1 7.5 13A4 4 0 0 1 8.28 13.08A11.27 11.27 0 0 1 16.43 6.26L15 5.18L16.27 3.6M10 17.5A2.5 2.5 0 1 0 7.5 20A2.5 2.5 0 0 0 10 17.5Z" />
</vector>
@@ -6,6 +6,7 @@
<string name="overview_nomaindevice_label">Відсутній первинний пристрій</string> <string name="overview_nomaindevice_label">Відсутній первинний пристрій</string>
<string name="overview_bluetooth_disabled_label">Bluetooth вимкнено</string> <string name="overview_bluetooth_disabled_label">Bluetooth вимкнено</string>
<string name="overview_bluetooth_disabled_description">Bluetooth вимкнено, увімкніть його ;)</string> <string name="overview_bluetooth_disabled_description">Bluetooth вимкнено, увімкніть його ;)</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_access_fine_location_label">Доступ до точного місцезнаходження</string> <string name="permission_access_fine_location_label">Доступ до точного місцезнаходження</string>
<string name="permission_background_location_label">Доступ до місцезнаходження у тлі</string> <string name="permission_background_location_label">Доступ до місцезнаходження у тлі</string>
<string name="permission_ignore_battery_optimizations_label">Вимкнути оптимізацію батареї</string> <string name="permission_ignore_battery_optimizations_label">Вимкнути оптимізацію батареї</string>
@@ -7,21 +7,21 @@
<string name="general_error_label">錯誤</string> <string name="general_error_label">錯誤</string>
<string name="general_grant_permission_action">授予權限</string> <string name="general_grant_permission_action">授予權限</string>
<string name="overview_nomaindevice_label">無主要裝置</string> <string name="overview_nomaindevice_label">無主要裝置</string>
<string name="overview_nomaindevice_description">所有測到的裝置似乎並不是你的。開啟並連線你的裝置,也可以調整設定。</string> <string name="overview_nomaindevice_description">所有測到的裝置似乎並不是你的。開啟並連線你的裝置,也可以調整設定。</string>
<string name="overview_bluetooth_disabled_label">藍牙已停用</string> <string name="overview_bluetooth_disabled_label">藍牙已停用</string>
<string name="overview_bluetooth_disabled_description">藍牙已停用,啟用它 ;)</string> <string name="overview_bluetooth_disabled_description">藍牙已停用,啟用它 ;)</string>
<string name="permission_bluetooth_connect_label">藍牙連線</string> <string name="permission_bluetooth_connect_label">藍牙連線</string>
<string name="permission_bluetooth_connect_description">應用程式需要「藍牙連線」權限以與已配對的裝置交互或配對新裝置。</string> <string name="permission_bluetooth_connect_description">這個應用程式需要「藍牙連線」權限以與已配對的裝置交互或配對新裝置。</string>
<string name="permission_bluetooth_scan_label">藍牙掃描</string> <string name="permission_bluetooth_scan_label">藍牙掃描</string>
<string name="permission_bluetooth_scan_description">「藍牙掃描」權限允許應用程式發現附近的裝置並接收藍牙資料,你的 AirPods 需要如此。</string> <string name="permission_bluetooth_scan_description">「藍牙掃描」權限允許這個應用程式發現附近的裝置並接收藍牙資料,你的 AirPods 需要如此。</string>
<string name="permission_bluetooth_label">藍牙</string> <string name="permission_bluetooth_label">藍牙</string>
<string name="permission_bluetooth_description">應用程式需要「藍牙」權限與已配對裝置連線。</string> <string name="permission_bluetooth_description">這個應用程式需要「藍牙」權限與已配對裝置連線。</string>
<string name="permission_access_fine_location_label">存取精確位置</string> <string name="permission_access_fine_location_label">存取精確位置</string>
<string name="permission_access_fine_location_description">CAPod 使用「精確位置」權限以接收低功耗藍牙資料。你的耳機使用低功耗藍牙技術以廣播其狀態。這個應用程式不會使用藍牙資料來確定你的位置。</string> <string name="permission_access_fine_location_description">CAPod 使用「精確位置」權限以接收低功耗藍牙資料。你的耳機使用低功耗藍牙技術以廣播其狀態。這個應用程式不會使用藍牙資料來確定你的位置。</string>
<string name="permission_background_location_label">背景位置存取</string> <string name="permission_background_location_label">背景位置存取</string>
<string name="permission_background_location_description">CAPods 在應用程式關閉時使用「背景位置存取」來啟用諸如「顯示彈出式視窗」和「自動連線」等功能。背景位置存取允許應用程式在背景接收低功耗藍牙資料。這個應用程式不會使用藍牙資料來確定你的位置。</string> <string name="permission_background_location_description">CAPods 在應用程式關閉時使用「背景位置存取」來啟用諸如「顯示彈出式視窗」和「自動連線」等功能。背景位置存取允許這個應用程式在背景接收低功耗藍牙資料。這個應用程式不會使用藍牙資料來確定你的位置。</string>
<string name="permission_ignore_battery_optimizations_label">停用電池效能最佳化</string> <string name="permission_ignore_battery_optimizations_label">停用電池效能最佳化</string>
<string name="permission_ignore_battery_optimizations_description">電池效能最佳化使應用程式在背景時無法可靠地接收藍牙資料。</string> <string name="permission_ignore_battery_optimizations_description">電池效能最佳化使這個應用程式在背景時無法可靠地接收藍牙資料。</string>
<string name="permission_required_title">可能要求下列權限:</string> <string name="permission_required_title">可能要求下列權限:</string>
<string name="permission_system_alert_window_label">系統警報視窗</string> <string name="permission_system_alert_window_label">系統警報視窗</string>
<string name="permission_system_alert_window_description">允許 CAPod 在其他應用程式上繪圖,使「顯示彈出式視窗」功能成為可能。</string> <string name="permission_system_alert_window_description">允許 CAPod 在其他應用程式上繪圖,使「顯示彈出式視窗」功能成為可能。</string>
@@ -14,14 +14,14 @@ class AirPodsFactoryTest : BaseAirPodsTest() {
@Test @Test
fun `create AirPodsGen1`() = runTest { fun `create AirPodsGen1`() = runTest {
create<DualAirPods>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") { create<DualApplePods>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
this shouldBe instanceOf<AirPodsGen1>() this shouldBe instanceOf<AirPodsGen1>()
} }
} }
@Test @Test
fun `create AirPodsPro`() = runTest { fun `create AirPodsPro`() = runTest {
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") { create<DualApplePods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
this shouldBe instanceOf<AirPodsPro>() this shouldBe instanceOf<AirPodsPro>()
} }
} }
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -8,7 +9,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test bit mapping`() = runTest { fun `test bit mapping`() = runTest {
create<DualAirPods>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0e20.toUShort() rawDeviceModel shouldBe 0x0e20.toUShort()
@@ -24,13 +25,13 @@ class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test AirPodDevice - active microphone`() = runTest { fun `test AirPodDevice - active microphone`() = runTest {
create<DualAirPods>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101011 // 00101011
// --^----- // --^-----
isLeftPodMicrophone shouldBe true isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false isRightPodMicrophone shouldBe false
} }
create<DualAirPods>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001011 // 00001011
// --^----- // --^-----
isLeftPodMicrophone shouldBe false isLeftPodMicrophone shouldBe false
@@ -41,24 +42,24 @@ class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test AirPodDevice - left pod ear status`() = runTest { fun `test AirPodDevice - left pod ear status`() = runTest {
// Left Pod primary // Left Pod primary
create<DualAirPods>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100010 // 00100010
// 765432¹0 // 765432¹0
isLeftPodInEar shouldBe true isLeftPodInEar shouldBe true
} }
create<DualAirPods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100000 // 00100000
// 765432¹0 // 765432¹0
isLeftPodInEar shouldBe false isLeftPodInEar shouldBe false
} }
// Right Pod is primary // Right Pod is primary
create<DualAirPods>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001001 // 00001001
// 7654³210 // 7654³210
isLeftPodInEar shouldBe true isLeftPodInEar shouldBe true
} }
create<DualAirPods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001 // 00000001
// 7654³210 // 7654³210
isLeftPodInEar shouldBe false isLeftPodInEar shouldBe false
@@ -68,24 +69,24 @@ class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test AirPodDevice - right pod ear status`() = runTest { fun `test AirPodDevice - right pod ear status`() = runTest {
// Left Pod primary // Left Pod primary
create<DualAirPods>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101001 // 00101001
// 7654³210 // 7654³210
isRightPodInEar shouldBe true isRightPodInEar shouldBe true
} }
create<DualAirPods>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100001 // 00100001
// 7654³210 // 7654³210
isRightPodInEar shouldBe false isRightPodInEar shouldBe false
} }
// Right Pod is primary // Right Pod is primary
create<DualAirPods>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000011 // 00000011
// 765432¹0 // 765432¹0
isRightPodInEar shouldBe true isRightPodInEar shouldBe true
} }
create<DualAirPods>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<DualApplePods>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001 // 00000001
// 765432¹0 // 765432¹0
isRightPodInEar shouldBe false isRightPodInEar shouldBe false
@@ -95,13 +96,13 @@ class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test AirPodDevice - battery status`() = runTest { fun `test AirPodDevice - battery status`() = runTest {
// Right Pod is primary // Right Pod is primary
create<DualAirPods>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") { create<DualApplePods>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// 88 10001000 // 88 10001000
batteryLeftPodPercent shouldBe 0.9f batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f batteryRightPodPercent shouldBe 0.8f
} }
// Left Pod primary // Left Pod primary
create<DualAirPods>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") { create<DualApplePods>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// F8 11111000 // F8 11111000
batteryLeftPodPercent shouldBe 0.9f batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f batteryRightPodPercent shouldBe 0.8f
@@ -114,13 +115,13 @@ class DualApplePodsTest : BaseAirPodsTest() {
* Right pod is charging * Right pod is charging
*/ */
// This is the left // This is the left
create<DualAirPods>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") { create<DualApplePods>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") {
// 1001 0100 // 1001 0100
isLeftPodCharging shouldBe false isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true isRightPodCharging shouldBe true
} }
// This is the right // This is the right
create<DualAirPods>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") { create<DualApplePods>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") {
// 1010 0100 // 1010 0100
isLeftPodCharging shouldBe false isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true isRightPodCharging shouldBe true
@@ -130,26 +131,26 @@ class DualApplePodsTest : BaseAirPodsTest() {
* Left pod is charging * Left pod is charging
*/ */
// This is the left // This is the left
create<DualAirPods>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") { create<DualApplePods>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") {
// 1001 0100 // 1001 0100
isLeftPodCharging shouldBe true isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false isRightPodCharging shouldBe false
} }
// This is the right // This is the right
create<DualAirPods>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") { create<DualApplePods>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") {
// 1010 0100 // 1010 0100
isLeftPodCharging shouldBe true isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false isRightPodCharging shouldBe false
} }
// Both charging // Both charging
create<DualAirPods>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") { create<DualApplePods>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") {
// 1011 0100 // 1011 0100
isLeftPodCharging shouldBe true isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true isRightPodCharging shouldBe true
} }
// Both not charging // Both not charging
create<DualAirPods>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") { create<DualApplePods>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") {
// 1000 1111 // 1000 1111
isLeftPodCharging shouldBe false isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false isRightPodCharging shouldBe false
@@ -158,11 +159,11 @@ class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test AirPodDevice - case charging`() = runTest { fun `test AirPodDevice - case charging`() = runTest {
create<DualAirPods>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") { create<DualApplePods>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
// 0011 0011 // 0011 0011
isCaseCharging shouldBe false isCaseCharging shouldBe false
} }
create<DualAirPods>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") { create<DualApplePods>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") {
// 0101 0011 // 0101 0011
isCaseCharging shouldBe true isCaseCharging shouldBe true
} }
@@ -171,75 +172,75 @@ class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test AirPodDevice - case lid test`() = runTest { fun `test AirPodDevice - case lid test`() = runTest {
// Lid open // Lid open
create<DualAirPods>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") { create<DualApplePods>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") {
// 31 0011 0001 // 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN caseLidState shouldBe DualApplePods.LidState.OPEN
} }
// Lid open, left pod in case // Lid open, left pod in case
create<DualAirPods>("07 19 01 0E 20 51 9A 93 >31< 00 00 95 D0 A5 D7 E3 F4 F1 38 38 99 61 3B 57 95 37 B7") { create<DualApplePods>("07 19 01 0E 20 51 9A 93 >31< 00 00 95 D0 A5 D7 E3 F4 F1 38 38 99 61 3B 57 95 37 B7") {
// 31 0011 0001 // 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN caseLidState shouldBe DualApplePods.LidState.OPEN
} }
// Lid open, left pod in case // Lid open, left pod in case
create<DualAirPods>("07 19 01 0E 20 71 A9 92 31 00 00 DB 48 7F 32 8C CE 80 6F D9 27 98 D6 76 45 9D 62") { create<DualApplePods>("07 19 01 0E 20 71 A9 92 31 00 00 DB 48 7F 32 8C CE 80 6F D9 27 98 D6 76 45 9D 62") {
// 31 0011 0001 // 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN caseLidState shouldBe DualApplePods.LidState.OPEN
} }
// Lid just closed // Lid just closed
create<DualAirPods>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") { create<DualApplePods>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
// 39 0011 1001 // 39 0011 1001
caseLidState shouldBe DualAirPods.LidState.CLOSED caseLidState shouldBe DualApplePods.LidState.CLOSED
} }
// Lid closed // Lid closed
create<DualAirPods>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") { create<DualApplePods>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
// 38 0011 1000 // 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED caseLidState shouldBe DualApplePods.LidState.CLOSED
} }
// Lid closed, right pod in case // Lid closed, right pod in case
create<DualAirPods>("07 19 01 0E 20 51 9A 93 >38< 00 00 3A D8 85 76 B0 91 48 31 DA FF 6C 4A 2B C2 67 F4") { create<DualApplePods>("07 19 01 0E 20 51 9A 93 >38< 00 00 3A D8 85 76 B0 91 48 31 DA FF 6C 4A 2B C2 67 F4") {
// 38 0011 1000 // 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED caseLidState shouldBe DualApplePods.LidState.CLOSED
} }
// Lid closed, left pod in case // Lid closed, left pod in case
create<DualAirPods>("07 19 01 0E 20 71 A9 92 38 00 00 44 91 C4 8B 85 98 DD 55 4E 6A CA BC B5 CA 8D 37") { create<DualApplePods>("07 19 01 0E 20 71 A9 92 38 00 00 44 91 C4 8B 85 98 DD 55 4E 6A CA BC B5 CA 8D 37") {
// 38 0011 1000 // 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED caseLidState shouldBe DualApplePods.LidState.CLOSED
} }
} }
@Test @Test
fun `test AirPodDevice - connection state`() = runTest { fun `test AirPodDevice - connection state`() = runTest {
// Disconnected // Disconnected
create<DualAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") { create<HasStateDetectionAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") {
// 31 0011 0001 // 31 0011 0001
state shouldBe DualAirPods.ConnectionState.DISCONNECTED state shouldBe HasStateDetectionAirPods.ConnectionState.DISCONNECTED
} }
// Connected idle // Connected idle
create<DualAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") { create<HasStateDetectionAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") {
// 39 0011 1001 // 39 0011 1001
state shouldBe DualAirPods.ConnectionState.IDLE state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
} }
// Connected and playing music // Connected and playing music
create<DualAirPods>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") { create<HasStateDetectionAirPods>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") {
// 38 0011 1000 // 38 0011 1000
state shouldBe DualAirPods.ConnectionState.MUSIC state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
} }
// Connected and call active // Connected and call active
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") { create<HasStateDetectionAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") {
// 38 0011 1000 // 38 0011 1000
state shouldBe DualAirPods.ConnectionState.CALL state shouldBe HasStateDetectionAirPods.ConnectionState.CALL
} }
// Connected and call active // Connected and call active
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") { create<HasStateDetectionAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") {
// 38 0011 1000 // 38 0011 1000
state shouldBe DualAirPods.ConnectionState.RINGING state shouldBe HasStateDetectionAirPods.ConnectionState.RINGING
} }
// Switching? // Switching?
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") { create<HasStateDetectionAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
// 38 0011 1000 // 38 0011 1000
state shouldBe DualAirPods.ConnectionState.HANGING_UP state shouldBe HasStateDetectionAirPods.ConnectionState.HANGING_UP
} }
} }
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -35,9 +35,7 @@ class AirPodsGen1Test : BaseAirPodsTest() {
isRightPodInEar shouldBe false isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.6f batteryCasePercent shouldBe 0.6f
caseLidState shouldBe DualAirPods.LidState.OPEN caseLidState shouldBe DualApplePods.LidState.OPEN
state shouldBe DualAirPods.ConnectionState.DISCONNECTED
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -34,9 +34,9 @@ class AirPodsGen2Test : BaseAirPodsTest() {
isRightPodInEar shouldBe true isRightPodInEar shouldBe true
batteryCasePercent shouldBe null batteryCasePercent shouldBe null
caseLidState shouldBe DualAirPods.LidState.NOT_IN_CASE caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
state shouldBe DualAirPods.ConnectionState.MUSIC state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -34,9 +34,9 @@ class AirPodsGen3Test : BaseAirPodsTest() {
isRightPodInEar shouldBe false isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.9f batteryCasePercent shouldBe 0.9f
caseLidState shouldBe DualAirPods.LidState.OPEN caseLidState shouldBe DualApplePods.LidState.OPEN
state shouldBe DualAirPods.ConnectionState.IDLE state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
@@ -58,9 +58,9 @@ class AirPodsGen3Test : BaseAirPodsTest() {
isRightPodInEar shouldBe true isRightPodInEar shouldBe true
batteryCasePercent shouldBe null batteryCasePercent shouldBe null
caseLidState shouldBe DualAirPods.LidState.NOT_IN_CASE caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
state shouldBe DualAirPods.ConnectionState.UNKNOWN state shouldBe HasStateDetectionAirPods.ConnectionState.UNKNOWN
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
} }
@@ -0,0 +1,43 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsFitProTest : BaseAirPodsTest() {
/**
* From https://github.com/d4rken-org/capod/issues/33#issuecomment-1256235651
*/
@Test
fun `test basics`() = runTest {
create<BeatsFitPro>("07 19 01 12 20 20 FA 8F 01 11 24 9B 9B 23 52 60 5A 8C 32 1C A5 C2 81 51 82 AF C8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1220.toUShort()
rawStatus shouldBe 0x20.toUByte()
rawPodsBattery shouldBe 0xFA.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x11.toUByte()
rawSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe null
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.UNKNOWN.name
model shouldBe PodDevice.Model.BEATS_FIT_PRO
}
}
}
@@ -10,9 +10,9 @@ class FakeAirPodsGen1Test : BaseAirPodsTest() {
@Test @Test
fun `charging in box`() = runTest { fun `charging in box`() = runTest {
create<FakeAirPodsGen1>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") { create<FakeAirPodsGen2>("07 13 01 0F 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort() rawDeviceModel shouldBe 0x0F20.toUShort()
rawStatus shouldBe 0x71.toUByte() rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte() rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort() rawFlags shouldBe 0x3.toUShort()
@@ -34,7 +34,7 @@ class FakeAirPodsGen1Test : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.7f batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN1 model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN2
} }
} }
} }
@@ -0,0 +1,40 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runTest {
create<FakeAirPodsGen1>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x32.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN1
}
}
}
@@ -31,4 +31,31 @@ class FakeAirPodsPro2Test : BaseAirPodsTest() {
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO2 model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO2
} }
} }
/**
* https://discord.com/channels/548521543039189022/927235844127993866/1063027552765087754
*/
@Test
fun `user supplied`() = runTest {
create<FakeAirPodsPro2>("07 13 01 14 20 75 AA 72 39 00 00 6F E4 E4 93 30 00 30 30 30 30") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x7.toUShort()
rawCaseBattery shouldBe 0x2.toUShort()
rawCaseLidState shouldBe 0x39.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x0.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe true
batteryCasePercent shouldBe 0.2f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO2
}
}
} }
-20
View File
@@ -4,26 +4,6 @@
package="eu.darken.capod"> package="eu.darken.capod">
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-feature android:name="android.hardware.type.watch" /> <uses-feature android:name="android.hardware.type.watch" />
@@ -8,7 +8,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.OverviewPodsDualItemBinding import eu.darken.capod.databinding.OverviewPodsDualItemBinding
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import java.time.Instant import java.time.Instant
class DualPodsCardVH(parent: ViewGroup) : class DualPodsCardVH(parent: ViewGroup) :
@@ -32,7 +32,7 @@ class DualPodsCardVH(parent: ViewGroup) :
if (item.isMainPod) setTypeface(null, Typeface.BOLD) if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(null, Typeface.NORMAL) else setTypeface(null, Typeface.NORMAL)
if (device is DualAirPods && item.showDebug) { if (device is DualApplePods && item.showDebug) {
append(" [${device.primaryPod.name}]") append(" [${device.primaryPod.name}]")
} }
} }
-21
View File
@@ -5,29 +5,8 @@
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-feature <uses-feature
@@ -8,8 +8,8 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsDualItemBinding import eu.darken.capod.databinding.OverviewPodsDualItemBinding
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -34,7 +34,7 @@ class DualPodsCardVH(parent: ViewGroup) :
if (item.isMainPod) setTypeface(null, Typeface.BOLD) if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(null, Typeface.NORMAL) else setTypeface(null, Typeface.NORMAL)
if (device is DualAirPods && item.showDebug) { if (device is DualApplePods && item.showDebug) {
append(" [${device.primaryPod.name}]") append(" [${device.primaryPod.name}]")
} }
} }
@@ -129,7 +129,7 @@ class DualPodsCardVH(parent: ViewGroup) :
// Case lid state // Case lid state
device.apply { device.apply {
if (this is DualAirPods) { if (this is DualApplePods) {
podCaseLidLabel.text = when (caseLidState) { podCaseLidLabel.text = when (caseLidState) {
LidState.OPEN -> context.getString(R.string.pods_case_status_open_label) LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label) LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
@@ -25,9 +25,9 @@ class BluetoothEventReceiver : BroadcastReceiver() {
@Inject @AppScope lateinit var appScope: CoroutineScope @Inject @AppScope lateinit var appScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
log { "onReceive($context, $intent)" } log(TAG) { "onReceive($context, $intent)" }
if (!EXPECTED_ACTIONS.contains(intent.action)) { if (!EXPECTED_ACTIONS.contains(intent.action)) {
log(WARN) { "Unknown action: $intent.action" } log(TAG, WARN) { "Unknown action: ${intent.action}" }
return return
} }
@@ -41,7 +41,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) } val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
if (supportedFeatures.isEmpty()) { if (supportedFeatures.isEmpty()) {
log { "Device has no features we support." } log(TAG) { "Device has no features we support." }
return return
} else { } else {
log { "Device has the following we features we support $supportedFeatures" } log { "Device has the following we features we support $supportedFeatures" }
@@ -49,7 +49,7 @@ class BluetoothEventReceiver : BroadcastReceiver() {
val pending = goAsync() val pending = goAsync()
appScope.launch { appScope.launch {
log { "Starting monitor" } log(TAG) { "Starting monitor" }
monitorControl.startMonitor(bluetoothDevice, forceStart = false) monitorControl.startMonitor(bluetoothDevice, forceStart = false)
pending.finish() pending.finish()
} }
@@ -6,7 +6,7 @@ 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
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods 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 javax.inject.Inject import javax.inject.Inject
@@ -16,12 +16,12 @@ class MonitorNotificationViewFactory @Inject constructor(
) { ) {
fun createContentView(device: PodDevice): RemoteViews = when (device) { fun createContentView(device: PodDevice): RemoteViews = when (device) {
is DualAirPods -> createDualApplePods(device) is DualApplePods -> createDualApplePods(device)
is SingleApplePods -> createSingleApplePods(device) is SingleApplePods -> createSingleApplePods(device)
else -> createUnknownDevice(device) else -> createUnknownDevice(device)
} }
private fun createDualApplePods(device: DualAirPods): RemoteViews = RemoteViews( private fun createDualApplePods(device: DualApplePods): RemoteViews = RemoteViews(
context.packageName, context.packageName,
R.layout.monitor_notification_dual_pods_small R.layout.monitor_notification_dual_pods_small
).apply { ).apply {
@@ -12,7 +12,7 @@ import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
import eu.darken.capod.pods.core.* import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods 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 javax.inject.Inject import javax.inject.Inject
@@ -26,13 +26,13 @@ class PopUpPodViewFactory @Inject constructor(
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) { fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) {
is DualAirPods -> createDualApplePods(parent, device) is DualApplePods -> createDualApplePods(parent, device)
// Unused, has no case to trigger reaction? // Unused, has no case to trigger reaction?
is SingleApplePods -> createSingleApplePods(parent, device) is SingleApplePods -> createSingleApplePods(parent, device)
else -> throw IllegalArgumentException("Unexpected device: $device") else -> throw IllegalArgumentException("Unexpected device: $device")
} }
private fun createDualApplePods(parent: ViewGroup, device: DualAirPods): View = private fun createDualApplePods(parent: ViewGroup, device: DualApplePods): View =
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply { PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply { device.apply {
podIcon.setImageResource(iconRes) podIcon.setImageResource(iconRes)
+20 -20
View File
@@ -4,29 +4,29 @@
<string name="general_done_action">Fertig</string> <string name="general_done_action">Fertig</string>
<string name="general_copy_action">Kopieren</string> <string name="general_copy_action">Kopieren</string>
<string name="general_thank_you_label">Danke</string> <string name="general_thank_you_label">Danke</string>
<string name="general_upgrade_action">Aktualisierung</string> <string name="general_upgrade_action">Upgrade</string>
<string name="general_check_action">Überprüfen</string> <string name="general_check_action">Überprüfen</string>
<string name="general_close_action">Schließen</string> <string name="general_close_action">Schließen</string>
<string name="upgrade_capod_label">Verbesser CAPod</string> <string name="upgrade_capod_label">Verbesser CAPod</string>
<string name="upgrade_capod_description">Erhalten Sie zusätzliche Funktionen und unterstützen Sie den Entwickler.</string> <string name="upgrade_capod_description">Erhalten Sie zusätzliche Funktionen und unterstützen Sie den Entwickler.</string>
<string name="settings_monitor_mode_label">Überwachungsmodus</string> <string name="settings_monitor_mode_label">Überwachungsmodus</string>
<string name="settings_monitor_mode_description">Unter welchen Umständen überwacht diese App Bluetooth-Daten.</string> <string name="settings_monitor_mode_description">Unter welchen Umständen überwacht diese App Bluetooth-Daten.</string>
<string name="settings_scanner_mode_label">Scannermodus</string> <string name="settings_scanner_mode_label">Scan-Modus</string>
<string name="settings_scanner_mode_description">Soll der Bluetooth Low Energy Datenscanner Leistung priorisieren oder Energie sparen?</string> <string name="settings_scanner_mode_description">Soll die Bluetooth Low Energy Überwachung Leistung priorisieren oder Energie sparen?</string>
<string name="settings_autopause_label">Automatische Pause</string> <string name="settings_autopause_label">Automatische Pause</string>
<string name="settings_autopause_description">Halten Sie den Ton an, wenn Sie das Gerät von Ihrem Ohr entfernen.</string> <string name="settings_autopause_description">Musik pausieren, wenn der Kopfhörer vom Ohr entfernt wird.</string>
<string name="settings_showall_label">Alle Geräte anzeigen</string> <string name="settings_showall_label">Alle Geräte anzeigen</string>
<string name="settings_showall_description">Zeigen Sie die Geräte anderer Personen in Ihrer Nähe an.</string> <string name="settings_showall_description">Alle in der Nähe befindlichen Geräte anzeigen (auch die von anderen Personen).</string>
<string name="settings_autopplay_label">Automatisches Abspielen</string> <string name="settings_autopplay_label">Automatisches abspielen</string>
<string name="settings_autoplay_description">Starten Sie die Audiowiedergabe, wenn das Gerät getragen wird.</string> <string name="settings_autoplay_description">Audiowiedergabe starten, wenn das Gerät getragen wird.</string>
<string name="settings_fake_data_label">Gefälschte Daten</string> <string name="settings_fake_data_label">Gefälschte Daten</string>
<string name="settings_fake_data_description">Falsche Daten anzeigen, also nicht existierende Geräte simulieren.</string> <string name="settings_fake_data_description">Gefälschte Test-Daten anzeigen, d.h. nicht existierende Geräte simulieren.</string>
<string name="settings_debug_label">Debug-Einstellungen</string> <string name="settings_debug_label">Debug-Einstellungen</string>
<string name="settings_debug_description">Zusätzliche Einstellungen zur Behebung von Problemen mit der App.</string> <string name="settings_debug_description">Zusätzliche Einstellungen zur Behebung von Problemen mit der App.</string>
<string name="settings_signal_minimum_label">Minimale Signalqualität</string> <string name="settings_signal_minimum_label">Minimale Signalqualität</string>
<string name="settings_signal_minimum_description">Die minimale Signalqualität, die ein Gerät haben muss, muss als Ihre angesehen werden.</string> <string name="settings_signal_minimum_description">Die minimale Signalqualität, die ein Gerät haben muss, damit als Ihres erkannt wird.</string>
<string name="settings_autoconnect_label">Automatisch verbinden</string> <string name="settings_autoconnect_label">Automatisch verbinden</string>
<string name="settings_autoconnect_description">Wenn Android nicht automatisch eine Verbindung herstellt, können wir es auch fragen. Dadurch wird die Monitormodus-Einstellung auf „Immer“ gesetzt.</string> <string name="settings_autoconnect_description">Wenn Android keine automatische Verbindung herstellt, können wir es auch auslösen. Durch diese Option wird die Monitormodus-Einstellung auf „Immer“ gesetzt.</string>
<string name="settings_autoconnect_condition_label">Bedingung für automatische Verbindung</string> <string name="settings_autoconnect_condition_label">Bedingung für automatische Verbindung</string>
<string name="settings_autoconnect_condition_description">Wann sollten wir versuchen, eine Verbindung zu Ihrem Gerät herzustellen?</string> <string name="settings_autoconnect_condition_description">Wann sollten wir versuchen, eine Verbindung zu Ihrem Gerät herzustellen?</string>
<string name="settings_reaction_label">Reaktionen</string> <string name="settings_reaction_label">Reaktionen</string>
@@ -36,21 +36,21 @@
<string name="settings_maindevice_address_description">Die Adresse Ihres gekoppelten Geräts. Die App verwendet dies, um festzustellen, wann sie mit Ihrem Telefon verbunden ist.</string> <string name="settings_maindevice_address_description">Die Adresse Ihres gekoppelten Geräts. Die App verwendet dies, um festzustellen, wann sie mit Ihrem Telefon verbunden ist.</string>
<string name="settings_maindevice_address_none">Keine</string> <string name="settings_maindevice_address_none">Keine</string>
<string name="settings_maindevice_model_label">Ihr Gerätemodell</string> <string name="settings_maindevice_model_label">Ihr Gerätemodell</string>
<string name="settings_maindevice_model_description">Das Modell Ihres Hauptgeräts. Dies hilft der App, Ihr Gerät zu erkennen, wenn es nicht mit Ihrem Telefon verbunden ist.</string> <string name="settings_maindevice_model_description">Das Modell Ihres Hauptgeräts. Dies hilft der App, Ihr Gerät zu erkennen, wenn es nicht mit Ihrem Telefon verbunden ist.</string>
<string name="settings_popup_caseopen_label">Popup zeigen</string> <string name="settings_popup_caseopen_label">Popup zeigen</string>
<string name="settings_popup_caseopen_description">Ein Popup anzeigen, wenn das Gerätegehäuse geöffnet wird (experimentell).</string> <string name="settings_popup_caseopen_description">Ein Popup anzeigen, wenn das Gerätegehäuse geöffnet wird (experimentell).</string>
<string name="settings_onepod_mode_label">Ein-Pod-Modus</string> <string name="settings_onepod_mode_label">Ein-Pod-Modus</string>
<string name="settings_onepod_mode_description">Das Tragen beider Pods ist nicht erforderlich, das Tragen eines einzigen Pods reicht aus, um Reaktionen auszulösen.</string> <string name="settings_onepod_mode_description">Das Tragen beider Pods ist nicht erforderlich. Das Tragen eines einzigen Pods reicht aus, um Reaktionen auszulösen.</string>
<string name="notification_channel_device_status_label">Geräte Status</string> <string name="notification_channel_device_status_label">Geräte Status</string>
<string name="debug_debuglog_size_label">Größe</string> <string name="debug_debuglog_size_label">Größe</string>
<string name="debug_debuglog_size_compressed_label">Komprimierte Größe</string> <string name="debug_debuglog_size_compressed_label">Komprimierte Größe</string>
<string name="debug_notification_channel_label">Debug Benachrichtigungen</string> <string name="debug_notification_channel_label">Debug Benachrichtigungen</string>
<string name="debug_debuglog_file_label">Aufgenommene Log-Datei</string> <string name="debug_debuglog_file_label">Aufgenommene Log-Datei</string>
<string name="debug_debuglog_record_action">Debug Log aufnehmen</string> <string name="debug_debuglog_record_action">Debug Log aufnehmen</string>
<string name="settings_support_installid_label">ID installieren</string> <string name="settings_support_installid_label">Installations-ID</string>
<string name="settings_support_installid_desc">Automatische Fehlermeldungen sind anonym. Teilen Sie Ihre Installations- ID mit, wenn der Entwickler Ihre Fehlerberichte finden muss.</string> <string name="settings_support_installid_desc">Automatische Fehlermeldungen sind anonym. Teilen Sie Ihre Installations- ID dem Entwickler mit, wenn er Ihre Fehlerberichte finden muss.</string>
<string name="settings_support_label">Unterstützung</string> <string name="settings_support_label">Unterstützung</string>
<string name="settings_support_description">Wenn Sie Hilfe brauchen.</string> <string name="settings_support_description">Wenn du Hilfe brauchst.</string>
<string name="issue_tracker_label">Issue-Tracker</string> <string name="issue_tracker_label">Issue-Tracker</string>
<string name="issue_tracker_description">Ein öffentlicher Issue-Tracker für Fehlerberichte und Funktionsanfragen (nur auf Englisch).</string> <string name="issue_tracker_description">Ein öffentlicher Issue-Tracker für Fehlerberichte und Funktionsanfragen (nur auf Englisch).</string>
<string name="discord_label">Discord</string> <string name="discord_label">Discord</string>
@@ -65,15 +65,15 @@
<string name="settings_general_description">Allgemeine Optimierungen, die die gesamte App betreffen.</string> <string name="settings_general_description">Allgemeine Optimierungen, die die gesamte App betreffen.</string>
<string name="settings_acknowledgements_label">Danksagungen</string> <string name="settings_acknowledgements_label">Danksagungen</string>
<string name="settings_debug_autoreports_label">Automatische Fehlerberichte</string> <string name="settings_debug_autoreports_label">Automatische Fehlerberichte</string>
<string name="settings_debug_autoreports_description">Meldet Probleme automatisch, Details zu einem App-Absturz, damit ich herausfinden kann, wie ich ihn beheben kann.</string> <string name="settings_debug_autoreports_description">Meldet Probleme automatisch, z.B. Details zu einem App-Absturz, damit ich herausfinden kann, wie ich ihn beheben kann.</string>
<string name="settings_debug_mode_label">Debug-Modus</string> <string name="settings_debug_mode_label">Debug-Modus</string>
<string name="settings_debug_mode_description">Zeigen Sie zusätzliche Informationen an, um Probleme zu beheben.</string> <string name="settings_debug_mode_description">Zeige zusätzliche Informationen an, um Probleme zu beheben.</string>
<string name="settings_blescanner_unfiltered_label">Ungefilterte BLE-Daten</string> <string name="settings_blescanner_unfiltered_label">Ungefilterte BLE-Daten</string>
<string name="settings_blescanner_unfiltered_description">Entfernen Sie alle Filter aus dem BLE-Scanner, um alle übertragenen BLE-Daten anzuzeigen. Nützlich, um Unterstützung für neue Kopfhörertypen hinzuzufügen.</string> <string name="settings_blescanner_unfiltered_description">Entfernt alle Filter aus dem BLE-Scanner, um alle übertragenen BLE-Daten anzuzeigen. Nützlich, um Unterstützung für neue Kopfhörertypen hinzuzufügen.</string>
<string name="settings_compatibility_mode_label">Kompatibilitätsmodus</string> <string name="settings_compatibility_mode_label">Kompatibilitätsmodus</string>
<string name="settings_compatibility_mode_description">Deaktivieren Sie Optimierungen, um die Kompatibilität zu verbessern. Versuchen Sie dies, wenn Sie keine Daten sehen.</string> <string name="settings_compatibility_mode_description">Deaktivieren Optimierungen, um die Kompatibilität zu verbessern. Probier diese Option, wenn gar keine Daten angezeigt werden.</string>
<string name="help_translate_label">Übersetzung</string> <string name="help_translate_label">Übersetzung</string>
<string name="help_translate_description">Helfen Sie mit, diese App in Ihre Lieblingssprache zu übersetzen.</string> <string name="help_translate_description">Hilf mit diese App in deine Lieblingssprache zu übersetzen.</string>
<string name="translators_thanks_title">Übersetzer</string> <string name="translators_thanks_title">Übersetzer</string>
<string name="translators_thanks_description">Gamechanger181</string> <string name="translators_thanks_description">Gamechanger181</string>
</resources> </resources>
+3 -3
View File
@@ -23,8 +23,8 @@
<string name="settings_fake_data_description">顯示假資料,模擬不存在的裝置。</string> <string name="settings_fake_data_description">顯示假資料,模擬不存在的裝置。</string>
<string name="settings_debug_label">偵錯設定</string> <string name="settings_debug_label">偵錯設定</string>
<string name="settings_debug_description">一個能協助這個應用程式故障檢修的附加設定。</string> <string name="settings_debug_description">一個能協助這個應用程式故障檢修的附加設定。</string>
<string name="settings_signal_minimum_label">最低訊號質</string> <string name="settings_signal_minimum_label">最低訊號</string>
<string name="settings_signal_minimum_description">可以被認為是你的裝置的最低訊號質</string> <string name="settings_signal_minimum_description">可以被認為是你的裝置的最低訊號質。</string>
<string name="settings_autoconnect_label">自動連線</string> <string name="settings_autoconnect_label">自動連線</string>
<string name="settings_autoconnect_description">如果 Android 不會自動連線,我們也可以要求它。這將把監視模式設定為「一律」。</string> <string name="settings_autoconnect_description">如果 Android 不會自動連線,我們也可以要求它。這將把監視模式設定為「一律」。</string>
<string name="settings_autoconnect_condition_label">自動連線條件</string> <string name="settings_autoconnect_condition_label">自動連線條件</string>
@@ -33,7 +33,7 @@
<string name="settings_reaction_description">對事件和行為作出反應。</string> <string name="settings_reaction_description">對事件和行為作出反應。</string>
<string name="settings_category_yourdevice_label">你的裝置</string> <string name="settings_category_yourdevice_label">你的裝置</string>
<string name="settings_maindevice_address_label">你的裝置位址</string> <string name="settings_maindevice_address_label">你的裝置位址</string>
<string name="settings_maindevice_address_description">你的已配對裝置的位址這個應用程式用位址來確定何時連線到你的手機。</string> <string name="settings_maindevice_address_description">你的已配對裝置的位址這個應用程式用這個位址來確定何時連線到你的手機。</string>
<string name="settings_maindevice_address_none"></string> <string name="settings_maindevice_address_none"></string>
<string name="settings_maindevice_model_label">你的裝置型號</string> <string name="settings_maindevice_model_label">你的裝置型號</string>
<string name="settings_maindevice_model_description">你的主要裝置型號。這能協助這個應用程式在未連線到你的手機時辨識你的裝置。</string> <string name="settings_maindevice_model_description">你的主要裝置型號。這能協助這個應用程式在未連線到你的手機時辨識你的裝置。</string>
+11
View File
@@ -33,6 +33,14 @@
<string name="settings_reaction_label">Reactions</string> <string name="settings_reaction_label">Reactions</string>
<string name="settings_reaction_description">React to events and behaviors.</string> <string name="settings_reaction_description">React to events and behaviors.</string>
<string name="settings_category_yourdevice_label">Your device</string> <string name="settings_category_yourdevice_label">Your device</string>
<string name="settings_category_compatibility_options_title">Compatibility options</string>
<string name="settings_category_compatibility_options_description">Don\'t touch if everything works ;)</string>
<string name="settings_compat_offloaded_filtering_disabled_title">Disable hardware filtering</string>
<string name="settings_compat_offloaded_filtering_disabled_summary">Don\'t delegate data filtering to the system, instead get all data and filter within the app.</string>
<string name="settings_compat_offloaded_batching_disabled_title">Disable hardware batching</string>
<string name="settings_compat_offloaded_batching_disabled_summary">Don\'t let the system group collected BLE data before forwarding it to us.</string>
<string name="settings_maindevice_address_label">Your device address</string> <string name="settings_maindevice_address_label">Your device address</string>
<string name="settings_maindevice_address_description">The address of your paired device. The app uses this to determine when it is connected to your phone.</string> <string name="settings_maindevice_address_description">The address of your paired device. The app uses this to determine when it is connected to your phone.</string>
<string name="settings_maindevice_address_none">None</string> <string name="settings_maindevice_address_none">None</string>
@@ -43,6 +51,7 @@
<string name="settings_onepod_mode_label">One pod mode</string> <string name="settings_onepod_mode_label">One pod mode</string>
<string name="settings_onepod_mode_description">Wearing both pods is not required, wearing a single pod is sufficient to trigger reactions.</string> <string name="settings_onepod_mode_description">Wearing both pods is not required, wearing a single pod is sufficient to trigger reactions.</string>
<string name="notification_channel_device_status_label">Device status</string> <string name="notification_channel_device_status_label">Device status</string>
<string name="debug_debuglog_size_label">Size</string> <string name="debug_debuglog_size_label">Size</string>
@@ -91,4 +100,6 @@
<string name="translators_thanks_description">darken</string> <string name="translators_thanks_description">darken</string>
<string name="widget_description">A widget showing the last known device status.</string> <string name="widget_description">A widget showing the last known device status.</string>
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
</resources> </resources>
+26 -6
View File
@@ -14,12 +14,6 @@
android:summary="@string/settings_scanner_mode_description" android:summary="@string/settings_scanner_mode_description"
android:title="@string/settings_scanner_mode_label" /> android:title="@string/settings_scanner_mode_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_ghost_24"
android:key="core.compatibility.enabled"
android:summary="@string/settings_compatibility_mode_description"
android:title="@string/settings_compatibility_mode_label" />
<CheckBoxPreference <CheckBoxPreference
android:icon="@drawable/ic_baseline_devices_other_24" android:icon="@drawable/ic_baseline_devices_other_24"
android:key="core.showall.enabled" android:key="core.showall.enabled"
@@ -49,6 +43,32 @@
android:title="@string/settings_maindevice_model_label" /> android:title="@string/settings_maindevice_model_label" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory
android:singleLineTitle="false"
android:summary="@string/settings_category_compatibility_options_description"
android:title="@string/settings_category_compatibility_options_title"
app:icon="@drawable/ic_chip_24">
<CheckBoxPreference
android:icon="@drawable/ic_filter_cog_outline_24"
android:key="core.compat.offloaded.filtering.disabled"
android:summary="@string/settings_compat_offloaded_filtering_disabled_summary"
android:title="@string/settings_compat_offloaded_filtering_disabled_title" />
<CheckBoxPreference
android:icon="@drawable/ic_format_list_group_24"
android:key="core.compat.offloaded.batching.disabled"
android:summary="@string/settings_compat_offloaded_batching_disabled_summary"
android:title="@string/settings_compat_offloaded_batching_disabled_title" />
<CheckBoxPreference
android:icon="@drawable/ic_strategy_24"
android:key="core.compat.indirectcallback.enabled"
android:summary="@string/settings_compat_indirectcallback_summary"
android:title="@string/settings_compat_indirectcallback_title" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_other_label"> <PreferenceCategory android:title="@string/settings_category_other_label">
<Preference <Preference
android:fragment="eu.darken.capod.main.ui.settings.general.debug.DebugSettingsFragment" android:fragment="eu.darken.capod.main.ui.settings.general.debug.DebugSettingsFragment"
@@ -6,7 +6,7 @@ CAPod 是一個能提供 AirPods 相關功能的應用程式。
* 顯示耳機和充電盒充電狀態。 * 顯示耳機和充電盒充電狀態。
* 顯示有關連線、麥克風、充電盒的附加資訊。 * 顯示有關連線、麥克風、充電盒的附加資訊。
* 可以接收並顯示附近的所有裝置。 * 可以接收並顯示附近的所有裝置。
* 耳朵測,自動播放/暫停。 * 耳朵測,自動播放/暫停。
* 自動連線手機和 AirPods。 * 自動連線手機和 AirPods。
* 開啟充電盒時顯示彈出式視窗。 * 開啟充電盒時顯示彈出式視窗。
+2 -2
View File
@@ -1,6 +1,6 @@
### Updated by release.sh ### ### Updated by release.sh ###
project.versioning.major=2 project.versioning.major=2
project.versioning.minor=5 project.versioning.minor=7
project.versioning.patch=2 project.versioning.patch=0
project.versioning.build=0 project.versioning.build=0
############################# #############################