Compare commits

...
74 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
darken 653c7b8c4b Release: 2.5.2-rc0 2023-01-06 16:45:28 +01:00
darken 4e38bcd4cc Fix left pod icon tint. 2023-01-06 16:44:30 +01:00
Matthias Urhahn fcbeda4888 Improve feature support for fake AirPods Gen3 (#72)
Remove microphone detection and add ear detection.
2023-01-06 16:40:03 +01:00
Matthias Urhahn 4101fddc5b Improve feature support for fake AirPods Gen1 (#71)
Remove microphone detection and add ear detection and pod charge detection.
2023-01-06 16:39:46 +01:00
darken 86df7bea22 Improve feature support for fake AirPods Pro
Remove microphone detection and add pod charging and ear detection.
2023-01-06 16:02:38 +01:00
darken 4db8c0c286 Add support for fake AirPods Pro 2 2023-01-06 16:02:25 +01:00
darken 1d03335903 Improve dual pod widget layout, use same style as notification. 2023-01-02 18:26:33 +01:00
darken b1b541014f Release: 2.5.1-rc0 2022-12-24 11:57:34 +01:00
darken b40a7e435f Update translations 2022-12-24 11:56:46 +01:00
Matthias Urhahn 8305fb4c5f Airpod pro2 icon iteration (#64)
* Update airpods pro icons to SVG variant

* Fix AirPodsPro2 not using the new icons

* Revert "Update airpods pro icons to SVG variant"

This reverts commit d7ca13d439.

* Add padding to icons
2022-12-24 11:48:32 +01:00
darken 6c678be511 Release: 2.5.0-rc1 2022-12-23 09:54:19 +01:00
darken 2823663f09 Merge branch 'main' of github.com:d4rken-org/capod into main 2022-12-23 09:53:08 +01:00
darken 033c2264f8 Release: 2.5.0-rc0 2022-12-23 09:52:59 +01:00
darken 8eb6ce19f7 Add support for specific left/right/case icons.
Add AirPods Pro 2 Icons by @christoschronopoulos
2022-12-23 09:52:17 +01:00
darken 1cfc4e6f65 Merge branch 'improve_fake_airpod_support' into main 2022-12-23 08:27:00 +01:00
darken 454d76e305 Release: 2.4.0-rc0 2022-12-19 17:10:30 +01:00
darken f3609fc3a5 Update translations 2022-12-19 17:09:55 +01:00
darken 16da51d095 Refactor fake airpods code and improve tests 2022-12-19 17:09:55 +01:00
darken 51fb4c7a29 Update translations 2022-12-19 17:03:17 +01:00
darken 0b27cb55dc Refactor fake airpods code and improve tests 2022-12-19 17:03:17 +01:00
darken 9e836eef69 Add support for fake AirPods Gen3 2022-12-19 16:32:09 +01:00
darken 07be4b8258 Release: 2.3.1-rc0 2022-11-07 22:51:12 +01:00
darken 217087c6d4 Clean up tests 2022-11-07 22:50:52 +01:00
Matthias Urhahn 2315a18c86 Improve history caching for AirPods. (#55)
Don't throw away the last history item that contains case infos.
2022-11-07 22:28:13 +01:00
Matthias Urhahn f59e9d2dc5 Fix comparison that determines which list item is the primary device. (#54) 2022-11-07 19:42:31 +01:00
Matthias Urhahn 1da220085c Merge pull request #53 from d4rken-org/improve_automatic_monitor_mode
Improve automatic monitor mode
2022-11-07 19:36:30 +01:00
Matthias Urhahn 962b2a01f1 Merge branch 'main' into improve_automatic_monitor_mode 2022-11-07 19:35:49 +01:00
darken eeda14f424 When in MonitorMode.AUTOMATIC and there is no mainDeviceAddress set, then just keep running while there is any connected headset device. 2022-11-07 19:25:31 +01:00
darken 6269e20085 When selecting automatic monitor-mode, check if the user has selected a device address, if not pop up the selection dialog. 2022-11-07 19:08:52 +01:00
Matthias Urhahn 3d05296eb2 Merge pull request #51 from GitGitro/main
add monochrome icon
2022-11-07 18:36:48 +01:00
GitGitro e90ab95cd0 add monochrome icon, closes #50,closes #23 2022-11-07 15:31:31 +01:00
Matthias Urhahn 23ec2f025e Update README.md 2022-11-05 12:31:21 +01:00
darken 624898dd5e Release: 2.3.0-rc0 2022-11-05 12:20:06 +01:00
darken c6d6604399 Merge remote-tracking branch 'origin/main' into main 2022-11-05 12:19:19 +01:00
darken 7473704954 Update changelog 2022-11-05 12:19:14 +01:00
Matthias Urhahn 7a6b216bf6 Update translations (#49) 2022-11-05 12:11:54 +01:00
Matthias Urhahn 1ef09124af Improve notification theming (#47)
* Make notification themes more dynamic to adapt to day/night/system themes.

* Fix broken style references
2022-11-05 12:11:33 +01:00
darken 6340f73d61 Throttle pod device data for the UI, notifications or widgets.
We don't need faster updates than 1 per second.
(We keep reactions at maximum speed tho)
2022-11-05 12:11:09 +01:00
darken b9f4992a33 A CAPod widget, oh my! ¯\_(ツ)_/¯ 2022-11-05 11:53:09 +01:00
darken 07a1fee013 Fix proguard rule inclusion 2022-10-28 22:27:50 +02:00
darken 63f37e51ad Release: 2.2.1-rc1 2022-10-27 17:40:57 +02:00
darken 01ea08a5cb Update release script 2022-10-27 17:38:50 +02:00
darken bddb20c5bb More version plumping 2022-10-27 17:38:50 +02:00
darken f9a97297da Add a release script base.
Based on https://gist.github.com/jv-k/703e79306554c26a65a7cfdb9ca119c6

WIP
2022-10-27 17:38:50 +02:00
darken 5fc2b56635 Update translations 2022-10-27 08:09:49 +02:00
darken 172953ccb3 Fix monitor mode being set to ALWAYS when visiting the reactions settings screen.
Fixes #41
2022-10-27 07:59:21 +02:00
darken d55300f53d Fix bold highlighting of the main pod device not being reset. 2022-10-22 10:49:45 +03:00
darken c3d4500e3a Version bump (v2.2.1-rc0) 2022-09-24 15:50:25 +02:00
darken cf72e28727 Fix app crash if Bluetooth adapter is null and we can't retrieve the list of bonded devices in settings. 2022-09-24 15:49:56 +02:00
darken b488a1be16 Version bump (v2.2.0-rc0) 2022-09-24 12:00:21 +02:00
darken 805d0cb4b6 Update translations 2022-09-24 11:59:58 +02:00
darken fa045c2504 Increase worker scheduling flex interval for WearOS. 2022-09-24 11:59:09 +02:00
Matthias Urhahn 23fd373a9b AirPods Pro 2 (#32)
* Preliminary support for AirPods Pro 2, based on #31, assuming identifier is `11 20`

* Fix AirPods Pro 2 identifier (it's 0x1420)

* Add second AirPods Pro 2 test case from reddit user.

* Update Readme with AirPods Pro 2
2022-09-24 11:51:53 +02:00
191 changed files with 3555 additions and 714 deletions
+30 -12
View File
@@ -7,10 +7,14 @@ on:
branches: [ main ]
jobs:
build-and-test:
name: Build and test
build-all:
name: Assemble all variants
strategy:
fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Debug ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
@@ -19,16 +23,30 @@ jobs:
java-version: '11'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build FOSS variant
run: ./gradlew assembleFossDebug
- name: Test FOSS variant
run: ./gradlew testFossDebugUnitTest
- name: Build (Flavor ${{ matrix.flavor }}, Variant ${{ matrix.variant }})
run: ./gradlew assemble${{ matrix.flavor }}${{ matrix.variant }}
- name: Build Google Play variant
run: ./gradlew assembleGplayDebug
- name: Test Google Play variant
run: ./gradlew testGplayDebugUnitTest
test-all:
name: Run all tests
strategy:
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 }}
+17 -5
View File
@@ -16,6 +16,7 @@ A companion app that adds support for AirPod specific features to Android:
* Automatically connect phone & AirPods.
* Show popup when case is opened.
* Support for Wear OS
* Widgets
CAPod is ad-free. Some additional features require an in-app purchase.
@@ -24,7 +25,8 @@ Currently supported models:
* AirPods 1. Generation
* AirPods 2. Generation
* AirPods 3. Generation
* AirPods Pro
* AirPods Pro 1. Generation
* AirPods Pro 2. Generation
* AirPods Max
* Power Beats Pro
* Power Beats 3
@@ -32,12 +34,22 @@ Currently supported models:
* Beats Studio 3
* Beats X
* Beats Flex
* Beats Fit Pro
* Fake AirPods 1. Generation 🎭
* Fake AirPods 2. Generation 🎭
* Fake AirPods 3. Generation 🎭
* Fake AirPods Pro1 🎭
* Fake AirPods Pro2 🎭
## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [GitHub](https://github.com/d4rken-org/capod/releases/latest)
* [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.darken.capod)
| 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%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) | [![](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) |
| [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
@@ -53,7 +65,7 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
## Thanks to
* The [OpenPods](https://github.com/adolfintel/OpenPods) project,
+1
View File
@@ -0,0 +1 @@
2.7.0-rc0 20700000
+32 -1
View File
@@ -1,4 +1,35 @@
<?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>
@@ -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
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,27 +26,47 @@ class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
) {
@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode,
compatMode: Boolean,
): Flow<List<BleScanResult>> = callbackFlow {
log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" }
if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" }
scannerMode: ScannerMode = ScannerMode.BALANCED,
disableOffloadFiltering: Boolean = true,
disableOffloadBatching: Boolean = true,
disableDirectScanCallback: Boolean = true,
): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
val adapter = bluetoothManager.adapter
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" }
} && !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" }
} && !compatMode
} && !disableOffloadBatching
if (disableOffloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" }
val scanner = bluetoothManager.scanner
if (disableDirectScanCallback) log(TAG, WARN) { "Direct scan callback is disabled!" }
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() {
var lastScanAt = System.currentTimeMillis()
@@ -55,17 +76,8 @@ class BleScanner @Inject constructor(
lastScanAt = System.currentTimeMillis()
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
}
val toSend = if (
supportsOffloadFiltering
|| filters.isEmpty()
|| filters.any { it.matchesSafe(result) }
) {
listOf(BleScanResult.fromScanResult(result))
} else {
log(TAG, VERBOSE) { "Manual filtering: No match for $result" }
emptyList()
}
trySend(toSend)
trySend(filterResults(setOf(result)))
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
@@ -75,18 +87,7 @@ class BleScanner @Inject constructor(
"onBatchScanResults(delay=${delay}ms, results=$results)"
}
val toSend = results
.filter { result ->
val passed = when {
supportsOffloadFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
trySend(toSend)
trySend(filterResults(results))
}
override fun onScanFailed(errorCode: Int) {
@@ -94,58 +95,113 @@ class BleScanner @Inject constructor(
}
}
val settings = ScanSettings.Builder().apply {
setScanMode(
when (scannerMode) {
ScannerMode.LOW_POWER -> ScanSettings.SCAN_MODE_LOW_POWER
ScannerMode.BALANCED -> ScanSettings.SCAN_MODE_BALANCED
ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY
}
)
if (supportsOffloadBatching) {
setReportDelay(
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
)
}
}.build()
log(TAG, VERBOSE) { "Settings created for offloaded filtering: $settings" }
val flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
log(TAG, VERBOSE) { "Flushing scan results." }
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
val forwarderConsumer = if (disableDirectScanCallback) {
scanResultForwarder.results
.onEach { results -> trySend(filterResults(results)) }
.launchIn(this)
} else {
null
}
scanner.startScan(
if (supportsOffloadFiltering) filters.toList() else listOf(ScanFilter.Builder().build()),
settings,
callback
)
log(TAG) { "BleScanner started (filters=$filters, settings=$settings)" }
val flushJob = if (!disableDirectScanCallback) {
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
log(TAG, VERBOSE) { "Flushing scan results." }
// 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 {
flushJob.cancel()
scanner.stopScan(callback)
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
log(TAG) { "BleScanner stopped" }
}
}
.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 {
private const val CALLBACK_INTENT_REQUESTCODE = 270
private val TAG = logTag("Bluetooth", "BleScanner")
}
}
@@ -30,12 +30,11 @@ class BluetoothManager2 @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
) {
val adapter: BluetoothAdapter
val adapter: BluetoothAdapter?
get() = manager.adapter
val scanner: BluetoothLeScanner
get() = adapter.bluetoothLeScanner
?: throw IllegalStateException("Bluetooth is disabled or permissiong missing")
val scanner: BluetoothLeScanner?
get() = adapter?.bluetoothLeScanner
val isBluetoothEnabled: Flow<Boolean> = callbackFlow {
send(manager.adapter?.isEnabled ?: false)
@@ -158,7 +157,9 @@ class BluetoothManager2 @Inject constructor(
}
}
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
fun bondedDevices(): Flow<Set<BluetoothDevice>> = flow {
emit(adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable"))
}
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
@@ -12,7 +12,7 @@ class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
) {
fun maybeAddfakeData(originals: List<BleScanResult>): List<BleScanResult> {
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals
return originals + getFakeData()
}
@@ -7,6 +7,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.hasCause
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlin.time.Duration
@@ -71,4 +72,11 @@ fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String)
log(tag, ERROR) { "${identifier()} failed: ${it.asLog()}" }
throw it
}
}
fun <T> Flow<T>.throttleLatest(delayMillis: Long): Flow<T> = this
.conflate()
.transform {
emit(it)
delay(delayMillis)
}
@@ -9,4 +9,9 @@ object PendingIntentCompat {
} else {
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)
val monitorMode = preferences.createFlowPreference(
"core.monitor.mode",
MonitorMode.AUTOMATIC,
moshi
)
val monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi)
val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi)
val scannerMode = preferences.createFlowPreference(
"core.scanner.mode",
ScannerMode.LOW_LATENCY,
moshi
)
val showAll = preferences.createFlowPreference("core.showall.enabled", false)
val compatibilityMode = preferences.createFlowPreference(
"core.compatibility.enabled",
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.25f)
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
)
val showAll = preferences.createFlowPreference(
"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
)
val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
monitorMode,
scannerMode,
compatibilityMode,
showAll,
minimumSignalQuality,
mainDeviceAddress,
isOffloadedFilteringDisabled,
isOffloadedBatchingDisabled,
useIndirectScanResultCallback,
debugSettings.isAutoReportingEnabled,
)
}
@@ -4,6 +4,7 @@ import android.bluetooth.le.ScanFilter
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -12,6 +13,7 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
@@ -79,28 +81,53 @@ class PodMonitor @Inject constructor(
val mainDevice: Flow<PodDevice?> = devices
.map { determineMainDevice(it) }
.setupCommonEventHandlers(TAG) { "mainDevice" }
.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(
generalSettings.scannerMode.flow,
generalSettings.compatibilityMode.flow,
debugSettings.showUnfiltered.flow
) { scannerMode, compatMode, unfiltered ->
Triple(scannerMode, compatMode, unfiltered)
debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
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 {
unfiltered -> {
options.showUnfiltered -> {
log(TAG, WARN) { "Using unfiltered scan mode" }
setOf(getUnfilteredFilter())
setOf(ScanFilter.Builder().build())
}
else -> ProximityPairing.getBleScanFilter()
}
bleScanner.scan(
filters = filters,
scannerMode = mode,
compatMode = compat,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
).map { preFilterAndMap(it) }
}
@@ -132,7 +159,7 @@ class PodMonitor @Inject constructor(
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 }
.values
.map { sameAdrDevs ->
@@ -176,8 +203,13 @@ class PodMonitor @Inject constructor(
}
}
private fun getUnfilteredFilter(): ScanFilter {
return ScanFilter.Builder().build()
suspend fun latestMainDevice(): PodDevice? {
val currentMain = mainDevice.firstOrNull()
log(TAG) { "Live mainDevice is $currentMain" }
return currentMain ?: podDeviceCache.loadMainDevice()
?.let { podFactory.createPod(it)?.device }
.also { log(TAG) { "Cached mainDevice is $it" } }
}
companion object {
@@ -1,5 +1,8 @@
package eu.darken.capod.pods.core
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
interface DualPodDevice : PodDevice {
enum class Pod {
@@ -10,4 +13,12 @@ interface DualPodDevice : PodDevice {
val batteryLeftPodPercent: Float?
val batteryRightPodPercent: Float?
@get:DrawableRes
val leftPodIcon: Int
get() = R.drawable.devic_airpods_gen1_left
@get:DrawableRes
val rightPodIcon: Int
get() = R.drawable.devic_airpods_gen1_right
}
@@ -1,9 +1,15 @@
package eu.darken.capod.pods.core
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
interface HasCase {
val batteryCasePercent: Float?
val isCaseCharging: Boolean
@get:DrawableRes
val caseIcon: Int
get() = R.drawable.devic_airpods_gen1_case
}
@@ -60,27 +60,31 @@ interface PodDevice {
@JsonClass(generateAdapter = false)
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.ic_device_generic_earbuds,
@DrawableRes val iconRes: Int = R.drawable.devic_earbuds_generic_both,
) {
@Json(name = "airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.ic_device_airpods_gen1,
iconRes = R.drawable.devic_airpods_gen1_both,
),
@Json(name = "airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.ic_device_airpods_gen2,
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.ic_device_airpods_gen2,
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.ic_device_airpods_gen2
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.ic_device_generic_headphones
R.drawable.devic_headphones_generic
),
@Json(name = "beats.flex") BEATS_FLEX(
"Beats Flex"
@@ -100,11 +104,28 @@ interface PodDevice {
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro"
),
@Json(name = "fakes.tws.i99999") TWS_I99999(
"TWS i99999"
@Json(name = "beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro"
),
@Json(name = "fakes.varunr.airpodspro") VARUNR_AIRPODS_PRO(
"Fake AirPods Pro"
@Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
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(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.devic_airpods_pro2_both,
),
@Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.devic_airpods_pro2_both,
),
@Json(name = "unknown") UNKNOWN(
"Unknown"
@@ -7,8 +7,7 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.misc.Twsi99999
import eu.darken.capod.pods.core.apple.misc.VarunrAirPodsPro
import eu.darken.capod.pods.core.apple.misc.*
@InstallIn(SingletonComponent::class)
@Module
@@ -19,6 +18,7 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@@ -27,8 +27,11 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakesTwsi999999(factory: Twsi99999.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet
abstract fun fakesVarunrAirPodsPro(factory: VarunrAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@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 fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
}
@@ -2,6 +2,7 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
@@ -66,7 +67,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 10
const val MAX_HISTORY = 20
}
}
@@ -78,16 +79,32 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualAirPods): DualAirPods.LidState? {
val definitive = setOf(DualAirPods.LidState.OPEN, DualAirPods.LidState.CLOSED)
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>()
.lastOrNull { it.caseLidState != DualAirPods.LidState.NOT_IN_CASE }
.filterIsInstance<AirPodsPro>() // TODO why is this AirPodsPro specific here?
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState
}
open fun historyTrimmer(
pods: List<ApplePods>
): List<ApplePods> {
var trimmed = pods.takeLast(KnownDevice.MAX_HISTORY)
// We want to keep the case information
// If both pods are removed, and produce more history, we otherwise lose the case battery info.
val caseInfoFat = pods.lastOrNull { it is HasCase && it.batteryCasePercent != null }
val caseInfoTrimmed = trimmed.lastOrNull { it is HasCase && it.batteryCasePercent != null }
if (caseInfoFat != null && caseInfoTrimmed == null) {
trimmed = listOf(caseInfoFat) + trimmed.takeLast(KnownDevice.MAX_HISTORY - 1)
}
return trimmed
}
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val message = current.proximityMessage
@@ -103,7 +120,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach {
knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY))
knownDevices[it.id] = it.copy(history = historyTrimmer(it.history))
}
var recognizedDevice: KnownDevice? = knownDevices.values
@@ -118,7 +135,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
}
if (recognizedDevice == null) {
log(tag) { "searchHistory1: Didn't recognize: $message" }
log(tag, WARN) { "searchHistory1: Didn't recognize: $message" }
}
return recognizedDevice
@@ -1,8 +1,5 @@
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.isBitSet
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.DualPodDevice.Pod
interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasStateDetection, HasDualMicrophone, HasAppleColor {
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasDualMicrophone, HasAppleColor {
val primaryPod: Pod
get() = when (rawStatus.isBitSet(5)) {
@@ -142,21 +139,4 @@ interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEar
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.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,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
@@ -31,17 +31,17 @@ abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<
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()
return filter { known ->
known.history
.filterIsInstance<DualAirPods>()
.filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target }
}
}
override fun searchHistory(current: DualAirPods): KnownDevice? {
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(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.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,15 +20,15 @@ data class AirPodsGen1 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
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.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,19 +20,19 @@ data class AirPodsGen2 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<DualApplePods>.rssi
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.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,18 +20,19 @@ data class AirPodsGen3 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -21,12 +21,12 @@ data class AirPodsMax(
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection {
) : SingleApplePods, HasEarDetection, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<SingleApplePods>.rssi
val isHeadphonesBeingWorn: Boolean
get() = rawStatus.isBitSet(1)
@@ -1,11 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -22,10 +24,26 @@ data class AirPodsPro(
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualAirPods {
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -33,7 +51,7 @@ data class AirPodsPro(
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -62,9 +80,10 @@ data class AirPodsPro(
)
}
companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
}
}
companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
}
}
@@ -0,0 +1,89 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsPro2(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super<HasStateDetectionAirPods>.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 = AirPodsPro2(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 = 0x1420.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro2")
}
}
@@ -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.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,15 +20,15 @@ data class PowerBeatsPro(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
@@ -6,10 +6,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.*
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
@@ -19,8 +16,9 @@ import javax.inject.Inject
/**
* Basically an AirPods GEN1 clone
* Similar data structure but a lot of placeholder values or hardcoded values
* Marketed as TWS i99999
*/
data class Twsi99999 constructor(
data class FakeAirPodsGen1 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
@@ -30,9 +28,9 @@ data class Twsi99999 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.TWS_I99999
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN1
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
@@ -78,22 +76,32 @@ data class Twsi99999 constructor(
}
}
val isThisPodInThecase: Boolean
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
/**
* The data flip bit is set if the left pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
/**
* The data flip bit is UNset if the right pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
override val 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()) {
@@ -109,7 +117,7 @@ data class Twsi99999 constructor(
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<Twsi99999>(TAG) {
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen1>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -117,7 +125,7 @@ data class Twsi99999 constructor(
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = Twsi99999(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsGen1(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -139,6 +147,6 @@ data class Twsi99999 constructor(
companion object {
private val DEVICE_CODE = 0x0220.toUShort()
private val TAG = logTag("PodDevice", "Apple", "TWS", "i99999")
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen1")
}
}
@@ -6,10 +6,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.*
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
@@ -17,10 +14,9 @@ import java.time.Instant
import javax.inject.Inject
/**
* AirPods Pro clone similar to Twsi999999.
* Shorter data structure.
* Similar data structure but a lot of placeholder values or hardcoded values
*/
data class VarunrAirPodsPro constructor(
data class FakeAirPodsGen2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
@@ -30,9 +26,9 @@ data class VarunrAirPodsPro constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.VARUNR_AIRPODS_PRO
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN2
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
@@ -78,22 +74,32 @@ data class VarunrAirPodsPro constructor(
}
}
val isThisPodInThecase: Boolean
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
/**
* The data flip bit is set if the left pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
/**
* The data flip bit is UNset if the right pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
override val 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()) {
@@ -109,7 +115,7 @@ data class VarunrAirPodsPro constructor(
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<VarunrAirPodsPro>(TAG) {
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?
@@ -117,7 +123,7 @@ data class VarunrAirPodsPro constructor(
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = VarunrAirPodsPro(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -138,7 +144,7 @@ data class VarunrAirPodsPro constructor(
}
companion object {
private val DEVICE_CODE = 0x0E20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Varunr", "AirPodsPro")
private val DEVICE_CODE = 0x0F20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen2")
}
}
@@ -0,0 +1,153 @@
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
/**
* AirPods Gen3 clone
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/1054404630118924308
*/
data class FakeAirPodsGen3 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_GEN3
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
}
}
}
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)
}
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 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<FakeAirPodsGen3>(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 = FakeAirPodsGen3(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 = 0x1320.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen3")
}
}
@@ -0,0 +1,170 @@
package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
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
/**
* AirPods Pro clone similar to Twsi999999.
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/962068944196358234
*/
data class FakeAirPodsPro 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, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
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
}
}
}
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)
}
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 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<FakeAirPodsPro>(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 = FakeAirPodsPro(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 = 0x0E20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Pro")
}
}
@@ -0,0 +1,168 @@
package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
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.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
/**
* AirPods Pro2 clone
* https://discord.com/channels/548521543039189022/927235844127993866/1060911213539774504
*/
data class FakeAirPodsPro2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null,
) : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO2
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: scanResult.rssi
private 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<FakeAirPodsPro2>(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 = FakeAirPodsPro2(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 = 0x1420.toUShort()
private val TAG = logTag("PodDevice", "Apple", "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.pods.core.HasEarDetection
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 kotlinx.coroutines.flow.*
import javax.inject.Inject
@@ -46,7 +46,8 @@ class AutoConnect @Inject constructor(
return@map
}
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr }
val bondedDevice = bluetoothManager.bondedDevices().first().firstOrNull { it.address == mainDeviceAddr }
if (bondedDevice == null) {
log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
return@map
@@ -68,7 +69,7 @@ class AutoConnect @Inject constructor(
val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualAirPods -> mainDevice.caseLidState == DualAirPods.LidState.OPEN
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true
}
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.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -40,46 +41,51 @@ class PlayPause @Inject constructor(
}
.distinctUntilChanged()
.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) ->
if (previous is HasEarDetection && current is HasEarDetection) {
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
if (previous.identifier != current.identifier) {
return@onEach
}
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
if (previous is HasEarDetectionDual && current is HasEarDetectionDual && reactionSettings.onePodMode.value) {
log(TAG) { "Ear status changed for dual pod device in single pod mode." }
val previousWorn = previous.isEitherPodInEar
val currentWorn = current.isEitherPodInEar
if (!previousWorn && currentWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
mediaControl.sendPlay()
} else {
log(TAG) { "autoPlay is disabled" }
}
} else if (!currentWorn && previousWorn && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
}
} else if (previous.isBeingWorn != current.isBeingWorn) {
log(TAG) { "Ear status changed for monitored device." }
if (current.isBeingWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
mediaControl.sendPlay()
} else {
log(TAG) { "autoPlay is disabled" }
}
} else if (!current.isBeingWorn && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
}
val previousWorn: Boolean?
val currentWorn: Boolean?
when {
reactionSettings.onePodMode.value && previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
previousWorn = previous.isEitherPodInEar
currentWorn = current.isEitherPodInEar
log(TAG, VERBOSE) { "previous: left=${previous.isLeftPodInEar}, right=${previous.isRightPodInEar}" }
log(TAG, VERBOSE) { "current: left${current.isLeftPodInEar}, right=${current.isRightPodInEar}" }
}
previous is HasEarDetection && current is HasEarDetection -> {
previousWorn = previous.isBeingWorn
currentWorn = current.isBeingWorn
log(TAG, VERBOSE) { "prev.isBeingWorn=${previousWorn}, cur.isBeingWorn=${currentWorn}" }
}
else -> {
log(TAG, VERBOSE) { "Current devices don't support ear detection." }
previousWorn = null
currentWorn = null
}
}
if (previousWorn == false && currentWorn == true && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
log(TAG) { "autoPlay is triggered, sendPlay()" }
mediaControl.sendPlay()
} else {
log(TAG, VERBOSE) { "autoPlay 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.monitor.core.PodMonitor
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 kotlinx.coroutines.flow.*
import java.time.Duration
@@ -34,7 +34,7 @@ class PopUpReaction @Inject constructor(
.withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" }
.mapNotNull { (previous, current) ->
if (previous !is DualAirPods? || current !is DualAirPods) {
if (previous !is DualApplePods? || current !is DualApplePods) {
return@mapNotNull null
}
log(TAG, VERBOSE) {
@@ -57,8 +57,8 @@ class PopUpReaction @Inject constructor(
tryPopWindow(current)
}
private suspend fun tryPopWindow(current: DualAirPods): Event? = when {
current.caseLidState == DualAirPods.LidState.OPEN -> {
private suspend fun tryPopWindow(current: DualApplePods): Event? = when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
val now = Instant.now()
@@ -74,9 +74,9 @@ class PopUpReaction @Inject constructor(
null
}
}
current.caseLidState != DualAirPods.LidState.OPEN -> {
current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) {
DualAirPods.LidState.CLOSED -> {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
coolDowns.remove(current.identifier)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -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>
@@ -1,10 +0,0 @@
<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,11c-1.9954,0 -3,-2.0093 -3,-4s1.0046,-4 3,-4c3.1837,0 5,3.4637 5,6.4886v10.0114c0,0.8284 -0.6716,1.5 -1.5,1.5 -0.8284,0 -1.5,-0.6716 -1.5,-1.5v-8.0113c0,-0.381 -0.1426,-0.7378 -0.3906,-1.0098 -0.4267,0.3435 -0.9676,0.5211 -1.6094,0.5211zM9,11.4887v8.0113c0,0.2761 0.2239,0.5 0.5,0.5s0.5,-0.2239 0.5,-0.5v-10.0114c0,-2.5454 -1.5434,-5.4886 -4,-5.4886 -1.2546,0 -2,1.4907 -2,3s0.7454,3 2,3c0.7809,0 1.2592,-0.3588 1.5257,-1.1581 0.2094,-0.6282 1.1516,-0.3212 0.9508,0.3097l-0.1878,0.5902c0.4499,0.4603 0.7114,1.0823 0.7114,1.7468zM6,8c-0.5523,0 -1,-0.4477 -1,-1s0.4477,-1 1,-1 1,0.4477 1,1 -0.4477,1 -1,1zM16,11.4887v8.0113c0,0.8284 -0.6716,1.5 -1.5,1.5s-1.5,-0.6716 -1.5,-1.5v-10.0114c0,-3.0249 1.8163,-6.4886 5,-6.4886 1.9954,0 3,2.0093 3,4s-1.0046,4 -3,4c-0.6418,0 -1.1826,-0.1777 -1.6094,-0.5211 -0.248,0.2721 -0.3906,0.6288 -0.3906,1.0098zM15.5235,9.1516c-0.2008,-0.631 0.7414,-0.9379 0.9508,-0.3097 0.2665,0.7994 0.7448,1.1581 1.5257,1.1581 1.2546,0 2,-1.4907 2,-3s-0.7454,-3 -2,-3c-2.4566,0 -4,2.9433 -4,5.4886v10.0114c0,0.2761 0.2239,0.5 0.5,0.5s0.5,-0.2239 0.5,-0.5v-8.0113c0,-0.6645 0.2615,-1.2865 0.7114,-1.7468zM18,8c-0.5523,0 -1,-0.4477 -1,-1s0.4477,-1 1,-1 1,0.4477 1,1 -0.4477,1 -1,1z" />
</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>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Н</string>
<string name="general_error_label">Памылка</string>
<string name="general_grant_permission_action">Даць дазвол</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Гэтай праграме патрабуецца дазвол на выкарыстанне Bluetooth для ўзаемадзеяння са спалучанымі прыладамі і стварэння новых злучэнняў.</string>
<string name="permission_bluetooth_scan_label">Сканіраванне Bluetooth</string>
<string name="permission_bluetooth_scan_description">Дазвол на сканіраванне Bluetooth дасць магчымасць гэтай праграме выяўляць і атрымліваць даныя па Bluetooth з навакольных прылад, такіх як вашы AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Гэтай праграме патрабуецца дазвол на выкарыстанне Bluetooth для злучэння са спалучанымі прыладамі.</string>
<string name="permission_access_fine_location_label">Доступ да дакладнага месцазнаходжання</string>
<string name="permission_access_fine_location_description">CAPod выкарыстоўвае дазвол \"Дакладнае месцазнаходжанне\" для атрымання даных Bluetooth Low Energy. Вашы навушнікі з дапамогай тэхналогіі Bluetooth Low Energy перадаюць інфармацыю пра свой стан. Гэта праграма НЕ будзе карыстацца данымі Bluetooth для вызначэння вашага месцазнаходжання.</string>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Chyba</string>
<string name="general_grant_permission_action">Udělit oprávnění</string>
<string name="overview_nomaindevice_label">Žádné hlavní zařízení</string>
@@ -10,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Tato aplikace vyžaduje oprávnění „připojení Bluetooth“ pro interakci se spárovanými zařízeními a zahájení připojení.</string>
<string name="permission_bluetooth_scan_label">Skenování Bluetooth</string>
<string name="permission_bluetooth_scan_description">Oprávnění „Skenování Bluetooth“ umožňuje této aplikaci zjišťovat a přijímat data z okolních Bluetooth zařízení, jako jsou například sluchátka AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Tato aplikace vyžaduje oprávněni k „Bluetooth“ pro připojení ke spárovaným zařízením Bluetooth.</string>
<string name="permission_access_fine_location_label">Přístup k přesné poloze</string>
<string name="permission_access_fine_location_description">CAPod používá pro příjem dat Bluetooth Low Energy oprávnění k „přesné poloze“. Sluchátka používají k vysílání svého stavu technologii Bluetooth Low Energy. Tato aplikace NEPOUŽÍVÁ data Bluetooth k určování vaší polohy.</string>
@@ -19,7 +24,7 @@
<string name="permission_ignore_battery_optimizations_description">Optimalizace baterie zabraňuje této aplikaci spolehlivě přijímat data Bluetooth, pokud běží na pozadí.</string>
<string name="permission_required_title">Je vyžadováno následující oprávnění:</string>
<string name="permission_system_alert_window_label">Okno systémových upozornění</string>
<string name="permission_system_alert_window_description">Aby bylo možné použít unkci „Zobrazit vyskakovací okno“, je nutné umožnot aplikaci CAPod vykreslení přes jiné aplikace.</string>
<string name="permission_system_alert_window_description">Aby bylo možné použít unkci „Zobrazit vyskakovací okno“, je nutné umožnit aplikaci CAPod vykreslení přes jiné aplikace.</string>
<string name="settings_scanner_mode_lowpower_label">Nízký výkon</string>
<string name="settings_scanner_mode_balanced_label">Vyvážený</string>
<string name="settings_scanner_mode_lowlatency_label">Nízká latence</string>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">k.A.</string>
<string name="general_error_label">Fehler</string>
<string name="general_grant_permission_action">Berechtigung erteilen</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Diese App benötigt die \"Bluetooth verbinden\" Berechtigung um mit bereits verbundenen und neuen Geräten zu interagieren.</string>
<string name="permission_bluetooth_scan_label">Bluetooth Suche</string>
<string name="permission_bluetooth_scan_description">Die \"Bluetooth verbinden\" Berechtigung erlaubt dieser App Bluetooth Daten von Geräten wie AirPods zu finden und zu erhalten.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Diese App benötigt die \"Bluetooth\" Berechtigung um sich mit gekoppelten Geräten zu verbinden.</string>
<string name="permission_access_fine_location_label">Auf genauen Standort zugreifen</string>
<string name="permission_access_fine_location_description">CAPod benutzt die \"genauen Gerätestandort verwenden\" Berechtigung um Bluetooth Low Energy Daten zu erhalten. Ihre Kopfhörer nutzen Bluetooth Low Energy um ihren Batterie Status zu übertragen. Diese App greift nicht auf ihren Standort zu.</string>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name_pro">CAPod Pro</string>
<string name="general_value_not_available_label">Δε διατίθεται</string>
<string name="general_error_label">Σφάλμα</string>
<string name="general_grant_permission_action">Παραχώρηση άδειας</string>
@@ -11,6 +12,7 @@
<string name="permission_bluetooth_connect_description">Αυτή η εφαρμογή απαιτεί την άδεια \"Σύνδεση Bluetooth\" για να αλληλεπιδρά με αντιστοιχισμένες συσκευές και να ξεκινά συνδέσεις.</string>
<string name="permission_bluetooth_scan_label">Σάρωση Bluetooth</string>
<string name="permission_bluetooth_scan_description">Η άδεια \"Σάρωση Bluetooth\" επιτρέπει σε αυτήν την εφαρμογή να εντοπίζει και να λαμβάνει δεδομένα Bluetooth από κοντινές συσκευές, όπως τα AirPods σας.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Αυτή η εφαρμογή απαιτεί την άδεια \"Bluetooth\" για να συνδέεται σε αντιστοιχισμένες συσκευές Bluetooth.</string>
<string name="permission_access_fine_location_label">Πρόσβαση ακριβούς τοποθεσίας</string>
<string name="permission_access_fine_location_description">Το CAPod χρησιμοποιεί την άδεια \"Ακριβής τοποθεσία\" για να λαμβάνει δεδομένα χαμηλής ενέργειας Bluetooth. Τα ακουστικά σας χρησιμοποιούν τεχνολογία χαμηλής ενέργειας Bluetooth για να μεταδίδουν την κατάστασή τους. Αυτή η εφαρμογή ΔΕΝ θα χρησιμοποιεί δεδομένα Bluetooth για να προσδιορίσει την τοποθεσία σας.</string>
@@ -1,2 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"></resources>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/D</string>
<string name="general_error_label">Error</string>
<string name="general_grant_permission_action">Conceder permiso</string>
<string name="overview_nomaindevice_label">Ningún dispositivo principal</string>
</resources>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS (libre)</string>
<string name="general_value_not_available_label">Non disponible</string>
<string name="general_error_label">Erreur</string>
<string name="general_grant_permission_action">Accorder lautorisation</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Cette appli exige lautorisation « Connexion Bluetooth » pour interagir avec les appareils jumelés et démarrer les connexions.</string>
<string name="permission_bluetooth_scan_label">Analyse Bluetooth</string>
<string name="permission_bluetooth_scan_description">Lautorisation « Analyse Bluetooth » permet à lappli de découvrir et de réceptionner les données Bluetooth des appareils à proximité tels que vos AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Cette appli exige lautorisation « Bluetooth » pour se connecter aux appareils Bluetooth jumelés.</string>
<string name="permission_access_fine_location_label">Accéder à la position précise</string>
<string name="permission_access_fine_location_description">CAPod utilise lautorisation « position précise » pour recevoir les données « faible énergie » de Bluetooth. Votre casque-micro utilise la technologie « basse énergie » de Bluetooth pour diffuser son état. Cette appli nutilisera PAS les données Bluetooth pour déterminer votre position.</string>
@@ -48,6 +52,7 @@
<string name="pods_none_label_short">Aucun appareil</string>
<string name="pods_charging_label">En charge</string>
<string name="pods_inear_label">Dans loreille</string>
<string name="pods_microphone_label">Microphone</string>
<string name="pods_yours">Les vôtres</string>
<string name="headset_being_worn_label">Porté</string>
<string name="pods_case_unknown_state">État inconnu</string>
@@ -1,4 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">कैपोड</string>
<string name="app_name_pro">कैपोड प्रो</string>
<string name="app_name_foss">कैपोड फोस</string>
<string name="general_value_not_available_label">एन/ए</string>
<string name="general_error_label">त्रुटि</string>
<string name="general_grant_permission_action">अनुदान अनुमति</string>
<string name="overview_nomaindevice_label">कोई प्राथमिक उपकरण नहीं</string>
<string name="overview_nomaindevice_description">सभी खोजे गए डिवाइस आपके होने की संभावना नहीं है। अपने डिवाइस को चालू करें और कनेक्ट करें या सेटिंग समायोजित करें</string>
<string name="overview_bluetooth_disabled_label">ब्लूटूथ अक्षम है</string>
<string name="overview_bluetooth_disabled_description">ब्लूटूथ अक्षम है, इसे सक्षम करें;)</string>
<string name="permission_bluetooth_connect_label">ब्लूटूथ कनेक्ट</string>
<string name="permission_bluetooth_connect_description">इस ऐप को युग्मित उपकरणों के साथ बातचीत करने और कनेक्शन शुरू करने के लिए \"ब्लूटूथ कनेक्ट\" अनुमति की आवश्यकता है।</string>
<string name="permission_bluetooth_scan_label">ब्लूटूथ स्कैनिंग</string>
<string name="permission_bluetooth_scan_description">\"ब्लूटूथ स्कैनिंग\" अनुमति इस ऐप को आपके AirPods जैसे आस-पास के उपकरणों से ब्लूटूथ डेटा खोजने और प्राप्त करने की अनुमति देती है।</string>
<string name="permission_bluetooth_label">ब्लूटूथ</string>
<string name="permission_bluetooth_description">इस ऐप को युग्मित ब्लूटूथ डिवाइस से कनेक्ट करने के लिए \"ब्लूटूथ\" अनुमति की आवश्यकता है।</string>
<string name="permission_access_fine_location_label">बढ़िया स्थान पर पहुंचें</string>
<string name="permission_access_fine_location_description">CAPod ब्लूटूथ कम ऊर्जा डेटा प्राप्त करने के लिए \"ठीक स्थान\" अनुमति का उपयोग करता है। आपके हेडफ़ोन अपनी स्थिति प्रसारित करने के लिए ब्लूटूथ लो एनर्जी तकनीक का उपयोग करते हैं। यह ऐप ब्लूटूथ डेटा का उपयोग डी . के लिए नहीं करेगा</string>
<string name="permission_background_location_label">पृष्ठभूमि स्थान पहुंच</string>
<string name="permission_background_location_description">ऐप बंद होने पर \"शो पॉपअप\" और \"ऑटोकनेक्ट\" जैसी सुविधाओं को सक्षम करने के लिए कैपोड \"पृष्ठभूमि स्थान पहुंच\" का उपयोग करता है। बैकग्राउंड लोकेशन एक्सेस ऐप को ब्लूटूथ लो एनर्जी डेटा w . प्राप्त करने की अनुमति देता है</string>
<string name="permission_ignore_battery_optimizations_label">बैटरी अनुकूलन अक्षम करें</string>
<string name="permission_ignore_battery_optimizations_description">बैटरी ऑप्टिमाइज़ेशन इस ऐप को बैकग्राउंड में होने पर ब्लूटूथ डेटा को मज़बूती से प्राप्त करने से रोकता है।</string>
<string name="permission_required_title">निम्नलिखित अनुमति की आवश्यकता है:</string>
<string name="permission_system_alert_window_label">सिस्टम अलर्ट विंडो</string>
<string name="permission_system_alert_window_description">\"पॉपअप दिखाएं\" सुविधा को संभव बनाने के लिए CAPod को अन्य ऐप्स पर आकर्षित करने दें।</string>
<string name="settings_scanner_mode_lowpower_label">कम बिजली</string>
<string name="settings_scanner_mode_balanced_label">संतुलित</string>
<string name="settings_scanner_mode_lowlatency_label">कम विलंबता</string>
<string name="settings_monitor_mode_manual_label">जब ऐप खुला हो</string>
<string name="settings_monitor_mode_automatic_label">जब डिवाइस कनेक्ट होता है</string>
<string name="settings_monitor_mode_always_label">हमेशा</string>
<string name="settings_reaction_autoconnect_whenseen_label">जब देखा</string>
<string name="settings_reaction_autoconnect_caseopen_label">मामला खुला है</string>
<string name="settings_reaction_autoconnect_inear_label">कान में</string>
<string name="pods_dual_left_label">लेफ्ट पॉड</string>
<string name="pods_dual_right_label">दायां पॉड</string>
<string name="pods_case_label">मामला</string>
<string name="pods_case_status_open_label">खुला हुआ</string>
<string name="pods_case_status_closed_label">बंद किया हुआ</string>
<string name="pods_connection_state_disconnected_label">किसी डिवाइस से कनेक्ट नहीं है</string>
<string name="pods_connection_state_idle_label">डिवाइस से कनेक्ट है, लेकिन निष्क्रिय है</string>
<string name="pods_connection_state_music_label">संगीत मोड में</string>
<string name="pods_connection_state_call_label">कॉल मोड में</string>
<string name="pods_connection_state_ringing_label">बज</string>
<string name="pods_connection_state_hanging_up_label">लटकाना</string>
<string name="pods_connection_state_unknown_label">अज्ञात कनेक्शन स्थिति</string>
<string name="pods_unknown_raw_data_label">कच्चा डेटा</string>
<string name="pods_unknown_label">अज्ञात उपकरण</string>
<string name="pods_unknown_contact_dev">यह एक अज्ञात डिवाइस है, लेकिन यह समान संदेश प्रारूप का उपयोग कर रहा है। आइए इसके लिए समर्थन जोड़ें, मुझसे संपर्क करें :)</string>
<string name="pods_none_label_short">उपकरण नहीं</string>
<string name="pods_charging_label">चार्ज</string>
</resources>
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">T/A</string>
<string name="general_error_label">Error</string>
<string name="general_grant_permission_action">Berikan izin</string>
<string name="overview_nomaindevice_label">Tidak ada perangkat utama</string>
<string name="overview_nomaindevice_description">Semua perangkat yang terdeteksi tidak mungkin menjadi milik anda. Nyalakan &amp; sambungkan perangkat anda atau sesuaikan pengaturan.</string>
@@ -10,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Aplikasi ini memerlukan izin \"Koneksi Bluetooth\" untuk berinteraksi dengan perangkat yang akan dipasangkan &amp; memulai koneksi.</string>
<string name="permission_bluetooth_scan_label">Pemindaian Bluetooth</string>
<string name="permission_bluetooth_scan_description">Izin \"Pemindaian Bluetooth\" memungkinkan aplikasi ini menemukan &amp; menerima data Bluetooth dari perangkat terdekat seperti AirPods anda.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Aplikasi ini memerlukan izin \"Bluetooth\" untuk terhubung ke perangkat bluetooth yang ingin dipasangkan.</string>
<string name="permission_access_fine_location_label">Akses lokasi halus</string>
<string name="permission_access_fine_location_description">CAPod menggunakan izin \"lokasi halus\" untuk menerima data Bluetooth Hemat Energi. Headphone anda menggunakan teknologi Bluetooth Hemat Energi untuk menyiarkan statusnya. Aplikasi ini TIDAK akan menggunakan data Bluetooth untuk menentukan lokasi anda.</string>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Errore</string>
<string name="general_grant_permission_action">Concedi permessi</string>
<string name="overview_nomaindevice_label">Nessun device primario</string>
@@ -10,7 +14,9 @@
<string name="permission_bluetooth_connect_description">Questa applicazione richiede il permesso di \"Connesione bluetooth\" per interagire con i dispotivi collegati e iniziare le connesioni.</string>
<string name="permission_bluetooth_scan_label">Scansione bluetooth</string>
<string name="permission_bluetooth_scan_description">Il permesso di \"Scansione bluetooth\" permette a questa app di trovare e ricevere dati Bluetooth da dispositivi vicini come le tue Airpods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Questa app richiede il permesso \"Bluetooth\" per connettersi ai dispoitivi Bluetooth.</string>
<string name="permission_access_fine_location_label">Accedi alla posizione precisa</string>
<string name="permission_access_fine_location_description">CAPod usa i permessi di Geolocalizzazione per ricevere dati Bluetooth a basso consumo. Le tue cuffie suano la tecnologia Bluetooth a basso consumo per inviare al dispositivo al quale sono connesse il loro stato. Questa app non utilizza i dati Bluetooth per geolocalizzarti.</string>
<string name="permission_background_location_label">Accesso in background alla tua posizione</string>
<string name="permission_background_location_description">CAPod usa l\'accesso in background alla tua posizione per far funzionare opzioni come \"Mostra popup\" e \"AutoConnect\" mentre l\'applicazione é chiusa. L\'accesso in background abiliterà l\'applicazione nella ricezione di dati Bluetooth a basso consume mentre l\'app non é aperta. Questa applicazione non utilizza i dati Bluetooth per geolocalizzarti.</string>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">該当なし</string>
<string name="general_error_label">エラー</string>
<string name="general_grant_permission_action">許可する</string>
@@ -10,16 +13,17 @@
<string name="permission_bluetooth_connect_label">Bluetooth接続</string>
<string name="permission_bluetooth_connect_description">このアプリでは、ペアリングされたデバイスとやり取りして接続を開始するには、「Bluetooth接続」の権限が必要です。</string>
<string name="permission_bluetooth_scan_label">Bluetoothスキャン</string>
<string name="permission_bluetooth_scan_description">「Bluetooth スキャン」権限により、このアプリはAirPodsなどの近くのデバイスからBluetoothのデータを検出して受信することができます。</string>
<string name="permission_bluetooth_scan_description">「Bluetoothスキャン」権限により、このアプリはAirPodsなどの近くのデバイスからBluetoothのデータを検出して受信することができます。</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">このアプリは、ペアリングされたBluetoothデバイスに接続するために「Bluetooth」権限が必要です。</string>
<string name="permission_access_fine_location_label">正確な位置情報</string>
<string name="permission_access_fine_location_description">CAPodは、Bluetooth Low Energyデータを受信するために、「正確な位置情報」権限の許可を使用します。ヘッドホンはBluetooth Low Energyテクノロジーを使用してデバイスの状態を送信します。このアプリは、Bluetoothデータを使用して現在地を特定することはありません。</string>
<string name="permission_background_location_label">バックグラウンドでの位置情報へのアクセス</string>
<string name="permission_background_location_description">CAPodは「バックグラウンドでの位置情報へのアクセス」を使用して、アプリが閉じている間に「ポップアップ表示」や「自動接続」などの機能を有効にします。バックグラウンドでの位置情報アクセスにより、アプリはバックグラウンドでBluetooth Low Energyデータを受信できます。このアプリはBluetoothデータを使用して現在地を特定しません。</string>
<string name="permission_ignore_battery_optimizations_label">バッテリーの最適化を無効にする</string>
<string name="permission_ignore_battery_optimizations_label">バッテリーの最適化を無効</string>
<string name="permission_ignore_battery_optimizations_description">バッテリーの最適化により、アプリがバックグラウンドにある間、このアプリはBluetoothデータを確実に受信できなくなります。</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="settings_scanner_mode_lowpower_label">省電力</string>
<string name="settings_scanner_mode_balanced_label">バランス</string>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">오류</string>
<string name="general_grant_permission_action">권한 부여</string>
<string name="overview_nomaindevice_label">내 기기 없음</string>
@@ -1,15 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="general_value_not_available_label">T/A</string>
<string name="general_error_label">Ralat</string>
<string name="general_grant_permission_action">Berikan kebenaran</string>
<string name="overview_nomaindevice_label">Tiada peranti utama</string>
<string name="overview_nomaindevice_description">Semua peranti yang dikesan tidak mungkin menjadi milik anda. Hidupkan dan sambung peranti anda atau laraskan tetapan.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth dinyahdayakan</string>
<string name="overview_bluetooth_disabled_description">Bluetooth dinyahdayakan, dayakan ia ;)</string>
<string name="overview_bluetooth_disabled_label">Bluetooth dilumpuhkan</string>
<string name="overview_bluetooth_disabled_description">Bluetooth dilumpuhkan, dayakan ia ;)</string>
<string name="permission_bluetooth_connect_label">Sambung bluetooth</string>
<string name="permission_bluetooth_connect_description">Apl ini memerlukan kebenaran \"Bluetooth connect\" untuk berinteraksi dengan pasangan peranti dan memulakan sambungan.</string>
<string name="permission_bluetooth_scan_label">Pengimbasan bluetooth</string>
<string name="permission_bluetooth_scan_description">Kebenaran \"Pengimbasan Bluetooth\" membolehkan apl ini menemui dan menerima data Bluetooth daripada peranti berdekatan seperti AirPods anda.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Apl ini memerlukan kebenaran \"Bluetooth\" untuk menyambung ke peranti bluetooth yang dipasangkan.</string>
<string name="permission_access_fine_location_label">Akses lokasi yang baik</string>
<string name="permission_access_fine_location_description">CAPod menggunakan kebenaran \"lokasi baik\" untuk menerima data Bluetooth Tenaga Rendah. Fon kepala anda menggunakan teknologi Bluetooth Tenaga Rendah untuk menyiarkan statusnya. Apl ini TIDAK akan menggunakan data Bluetooth untuk menentukan lokasi anda.</string>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Fout</string>
<string name="general_grant_permission_action">Toestemming geven</string>
<string name="overview_nomaindevice_label">Geen primair apparaat</string>
@@ -10,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Deze app heeft de toestemming \"Bluetooth verbinding\" nodig om te communiceren met gekoppelde apparaten en verbindingen tot stand te brengen.</string>
<string name="permission_bluetooth_scan_label">Bluetooth scannen</string>
<string name="permission_bluetooth_scan_description">Met de machtiging \'Bluetooth-scannen\' kan deze app Bluetooth-gegevens van apparaten in de buurt, zoals uw AirPods, ontdekken en ontvangen.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Deze app heeft de toestemming van \"Bluetooth\" nodig om verbinding te maken met gekoppelde Bluetooth-apparaten.</string>
<string name="permission_access_fine_location_label">Toegang prima locatie</string>
<string name="permission_access_fine_location_description">CAPod gebruikt de \"prima locatie\"-toestemming om Bluetooth Low Energy-gegevens te ontvangen. Uw hoofdtelefoon gebruikt Bluetooth Low Energy-technologie om hun status uit te zenden. Deze app gebruikt GEEN Bluetooth-gegevens om uw locatie te bepalen.</string>
@@ -31,7 +36,8 @@
<string name="settings_reaction_autoconnect_inear_label">In oor</string>
<string name="pods_dual_left_label">Linker pod</string>
<string name="pods_dual_right_label">Rechter pod</string>
<string name="pods_case_label">Geval</string>
<string name="pods_case_label">Oplaadcase</string>
<string name="pods_case_status_open_label">Open</string>
<string name="pods_case_status_closed_label">Gesloten</string>
<string name="pods_connection_state_disconnected_label">Niet aangesloten op een apparaat</string>
<string name="pods_connection_state_idle_label">Aangesloten op een apparaat, maar inactief</string>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/A</string>
<string name="general_error_label">Feil</string>
<string name="general_grant_permission_action">Gi tillatelse</string>
<string name="overview_nomaindevice_label">Ingen primær enhet</string>
@@ -10,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Denne appen må ha \"Bluetooth-tilkobling\"-tillatelsen for å kunne snakke med sammenkoblede enheter og starte tilkoblinger.</string>
<string name="permission_bluetooth_scan_label">Bluetooth-skanning</string>
<string name="permission_bluetooth_scan_description">\"Bluetooth-skanning\"-tillatelsen lar denne appen oppdage og motta Bluetooth-data fra enheter i nærheten, som for eksempel AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Denne appen må ha \"Bluetooth\"-tillatelsen for å koble til sammenkoblede Bluetooth-enheter.</string>
<string name="permission_access_fine_location_label">Forbedret posisjonsnøyaktighet</string>
<string name="permission_access_fine_location_description">CAPod bruker tillatelsen \"Forbedret posisjonsnøyaktighet\" for å motta Bluetooth Low Energy-data. Hodetelefonene dine bruker Bluetooth Low Energy-teknologi for å kringkaste statusen. Denne appen bruker IKKE Bluetooth-data til å se posisjonen din.</string>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/D</string>
<string name="general_error_label">Błąd</string>
<string name="general_grant_permission_action">Udziel uprawnień</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Aplikacja wymaga uprawnień do \"Połączenia Bluetooth\", aby oddziaływać na sparowane urządzenia i inicjować połączenia.</string>
<string name="permission_bluetooth_scan_label">Skanowanie Bluetooth</string>
<string name="permission_bluetooth_scan_description">Uprawnienie \"Skanowanie Bluetooth\" zezwala aplikacji na odkrywanie i odbieranie danych Bluetooth od urządzeń znajdujących się w pobliżu, takich jak AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Aplikacja wymaga uprawnień \"Bluetooth\", aby łączyć się ze sparowanymi urządzeniami bluetooth.</string>
<string name="permission_access_fine_location_label">Dostęp do dokładnej lokalizacji</string>
<string name="permission_access_fine_location_description">CAPod korzysta z uprawnienia do \"dokładnej lokalizacji\", aby odbierać dane Bluetooth Low Energy. Twoje słuchawki korzystają z technologii Bluetooth Low Energy, aby przekazywać informacje o swoim stanie. Aplikacja NIE BĘDZIE korzystać z danych Bluetooth do ustalenia twojego położenia.</string>
@@ -22,7 +26,7 @@
<string name="permission_system_alert_window_label">Okna alertów systemowych</string>
<string name="permission_system_alert_window_description">Zezwól na wyświetlanie CAPod nad innymi aplikacjami, aby aktywować funkcję \"Wyświetlanie informacji\".</string>
<string name="settings_scanner_mode_lowpower_label">Oszczędzanie energii</string>
<string name="settings_scanner_mode_balanced_label">Zabalansowana</string>
<string name="settings_scanner_mode_balanced_label">Zbalansowana</string>
<string name="settings_scanner_mode_lowlatency_label">Małe opóźnienia</string>
<string name="settings_monitor_mode_manual_label">Gdy aplikacja jest uruchomiona</string>
<string name="settings_monitor_mode_automatic_label">Gdy urządzenie jest podłączone</string>
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Nenhum dispositivo principal</string>
@@ -10,6 +12,7 @@
<string name="permission_bluetooth_connect_description">Este aplicativo requer a permissão \"Conexão Bluetooth\" para interagir com dispositivos emparelhados e iniciar conexões.</string>
<string name="permission_bluetooth_scan_label">Escaneando bluetooth</string>
<string name="permission_bluetooth_scan_description">A permissão \"Escaneamento de Bluetooth\" permite que este aplicativo descubra e receba dados de Bluetooth de dispositivos próximos, como seus AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Este aplicativo requer a permissão \"Bluetooth\" para se conectar a dispositivos bluetooth emparelhados.</string>
<string name="permission_access_fine_location_label">Acesse a boa localização</string>
<string name="permission_access_fine_location_description">O CAPod usa a permissão \"Boa localização\" para receber dados de Bluetooth Low Energy. Seus fones de ouvido usam a tecnologia Bluetooth Low Energy para transmitir seu status. Este aplicativo NÃO usará dados Bluetooth para determinar sua localização.</string>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">Недоступно</string>
<string name="general_error_label">Ошибка</string>
<string name="general_grant_permission_action">Предоставить разрешение</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Этому приложению требуется разрешение \"Подключение по Bluetooth\" для взаимодействия с сопряженными устройствами и инициирования соединений.</string>
<string name="permission_bluetooth_scan_label">Сканирование Bluetooth</string>
<string name="permission_bluetooth_scan_description">Разрешение \"Сканирование Bluetooth\" позволяет этому приложению обнаруживать и получать данные по Bluetooth с близлежащих устройств, таких как Ваши AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Этому приложению требуется разрешение\"Bluetooth\" для подключения к сопряженным устройствам.</string>
<string name="permission_access_fine_location_label">Доступ к точному местоположению</string>
<string name="permission_access_fine_location_description">CAPod необходимо разрешение \"Точное местоположение\" для получения данных Bluetooth Low Energy. Ваши наушники задействуют технологию Bluetooth Low Energy (Bluetooth с низким энергопотреблением) для передачи своего состояния. Это приложение НЕ будет использовать данные Bluetooth для определения Вашего местоположения.</string>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">N/D</string>
<string name="general_error_label">Chyba</string>
<string name="general_grant_permission_action">Udeliť povolenie</string>
@@ -11,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Táto aplikácia vyžaduje povolenie „Bluetooth pripojenie“ na interakciu so spárovanými zariadeniami a iniciovanie pripojení.</string>
<string name="permission_bluetooth_scan_label">Bluetooth skenovanie</string>
<string name="permission_bluetooth_scan_description">Povolenie „Bluetooth skenovanie“ umožňuje tejto aplikácii zisťovať a prijímať údaje z okolitých zariadení, ako sú vaše AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Táto aplikácia vyžaduje povolenie „Bluetooth“ na pripojenie k spárovaným bluetooth zariadeniam.</string>
<string name="permission_access_fine_location_label">Prístup k presnej polohe</string>
<string name="permission_access_fine_location_description">CAPod používa oprávnenie „presná poloha“ na prijímanie údajov Bluetooth Low Energy. Vaše slúchadlá využívajú technológiu Bluetooth Low Energy na vysielanie svojho stavu. Táto aplikácia NEBUDE používať údaje Bluetooth na určenie vašej polohy.</string>
@@ -6,6 +6,7 @@
<string name="overview_nomaindevice_label">Відсутній первинний пристрій</string>
<string name="overview_bluetooth_disabled_label">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_background_location_label">Доступ до місцезнаходження у тлі</string>
<string name="permission_ignore_battery_optimizations_label">Вимкнути оптимізацію батареї</string>
@@ -1,24 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">不可用</string>
<string name="general_error_label">錯誤</string>
<string name="general_grant_permission_action">授予權限</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_description">藍牙已停用,啟用它 ;)</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_description">「藍牙掃描」權限允許應用程式發現附近的裝置並接收藍牙資料,你的 AirPods 需要如此。</string>
<string name="permission_bluetooth_scan_description">「藍牙掃描」權限允許這個應用程式發現附近的裝置並接收藍牙資料,你的 AirPods 需要如此。</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_description">CAPod 使用「精確位置」權限以接收低功耗藍牙資料。你的耳機使用低功耗藍牙技術以廣播其狀態。這個應用程式不會使用藍牙資料來確定你的位置。</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_description">電池效能最佳化使應用程式在背景時無法可靠地接收藍牙資料。</string>
<string name="permission_ignore_battery_optimizations_description">電池效能最佳化使這個應用程式在背景時無法可靠地接收藍牙資料。</string>
<string name="permission_required_title">可能要求下列權限:</string>
<string name="permission_system_alert_window_label">系統警報視窗</string>
<string name="permission_system_alert_window_description">允許 CAPod 在其他應用程式上繪圖,使「顯示彈出式視窗」功能成為可能。</string>
@@ -5,7 +5,7 @@ import com.squareup.moshi.Moshi
import eu.darken.capod.main.core.MonitorMode
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.json.toComparableJson
@@ -25,7 +25,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
)
@Test
fun `reading and writing using manual reader and writer`() = runBlockingTest {
fun `reading and writing using manual reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
@@ -67,7 +67,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
}
@Test
fun `reading and writing using autocreated reader and writer`() = runBlockingTest {
fun `reading and writing using autocreated reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
@@ -109,7 +109,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
}
@Test
fun `enum serialization`() = runBlockingTest {
fun `enum serialization`() = runTest {
val moshi = Moshi.Builder().build()
val monitorMode = mockPreferences.createFlowPreference(
"core.monitor.mode",
@@ -2,7 +2,7 @@ package eu.darken.capod.common.preferences
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.preferences.MockSharedPreferences
@@ -12,7 +12,7 @@ class FlowPreferenceTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@Test
fun `reading and writing strings`() = runBlockingTest {
fun `reading and writing strings`() = runTest {
mockPreferences.createFlowPreference<String?>(
key = "testKey",
defaultValue = "default"
@@ -41,7 +41,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing boolean`() = runBlockingTest {
fun `reading and writing boolean`() = runTest {
mockPreferences.createFlowPreference<Boolean?>(
key = "testKey",
defaultValue = true
@@ -70,7 +70,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing long`() = runBlockingTest {
fun `reading and writing long`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 9000L
@@ -99,7 +99,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing integer`() = runBlockingTest {
fun `reading and writing integer`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 123
@@ -128,7 +128,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing float`() = runBlockingTest {
fun `reading and writing float`() = runTest {
mockPreferences.createFlowPreference<Float?>(
key = "testKey",
defaultValue = 3.6f
@@ -7,26 +7,27 @@ import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsFactoryTest : BaseAirPodsTest() {
@Test
fun `create AirPodsGen1`() = runBlockingTest {
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") {
fun `create AirPodsGen1`() = runTest {
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>()
}
}
@Test
fun `create AirPodsPro`() = runBlockingTest {
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") {
fun `create AirPodsPro`() = runTest {
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>()
}
}
@Test
fun `unknown AppleDevice`() = runBlockingTest {
fun `unknown AppleDevice`() = runTest {
create<ApplePods>("07 19 01 FF FF 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<UnknownAppleDevice>()
}
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BasicSingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `test mapping`() = runBlockingTest {
fun `test mapping`() = runTest {
create<SingleApplePods>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
@@ -22,7 +22,7 @@ class BasicSingleApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test battery headset percent`() = runBlockingTest {
fun `test battery headset percent`() = runTest {
create<SingleApplePods>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
batteryHeadsetPercent shouldBe 0.5f
}
@@ -1,14 +1,15 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class DualApplePodsTest : BaseAirPodsTest() {
@Test
fun `test bit mapping`() = runBlockingTest {
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") {
fun `test bit mapping`() = runTest {
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()
rawDeviceModel shouldBe 0x0e20.toUShort()
@@ -23,14 +24,14 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - active microphone`() = runBlockingTest {
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") {
fun `test AirPodDevice - active microphone`() = runTest {
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
// --^-----
isLeftPodMicrophone shouldBe true
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
// --^-----
isLeftPodMicrophone shouldBe false
@@ -39,26 +40,26 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - left pod ear status`() = runBlockingTest {
fun `test AirPodDevice - left pod ear status`() = runTest {
// 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
// 765432¹0
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
// 765432¹0
isLeftPodInEar shouldBe false
}
// 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
// 7654³210
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
// 7654³210
isLeftPodInEar shouldBe false
@@ -66,26 +67,26 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - right pod ear status`() = runBlockingTest {
fun `test AirPodDevice - right pod ear status`() = runTest {
// 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
// 7654³210
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
// 7654³210
isRightPodInEar shouldBe false
}
// 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
// 765432¹0
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
// 765432¹0
isRightPodInEar shouldBe false
@@ -93,15 +94,15 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - battery status`() = runBlockingTest {
fun `test AirPodDevice - battery status`() = runTest {
// 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
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
}
// 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
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
@@ -109,18 +110,18 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - pod charging`() = runBlockingTest {
fun `test AirPodDevice - pod charging`() = runTest {
/**
* Right pod is charging
*/
// 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
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
}
// 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
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
@@ -130,26 +131,26 @@ class DualApplePodsTest : BaseAirPodsTest() {
* Left pod is charging
*/
// 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
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// 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
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// 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
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
}
// 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
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
@@ -157,89 +158,89 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - case charging`() = runBlockingTest {
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") {
fun `test AirPodDevice - case charging`() = runTest {
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
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
isCaseCharging shouldBe true
}
}
@Test
fun `test AirPodDevice - case lid test`() = runBlockingTest {
fun `test AirPodDevice - case lid test`() = runTest {
// 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
caseLidState shouldBe DualAirPods.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
}
// 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
caseLidState shouldBe DualAirPods.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
}
// 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
caseLidState shouldBe DualAirPods.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
}
// 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
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.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
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
// 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
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
// 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
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
}
@Test
fun `test AirPodDevice - connection state`() = runBlockingTest {
fun `test AirPodDevice - connection state`() = runTest {
// 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
state shouldBe DualAirPods.ConnectionState.DISCONNECTED
state shouldBe HasStateDetectionAirPods.ConnectionState.DISCONNECTED
}
// 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
state shouldBe DualAirPods.ConnectionState.IDLE
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
}
// 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
state shouldBe DualAirPods.ConnectionState.MUSIC
state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
}
// 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
state shouldBe DualAirPods.ConnectionState.CALL
state shouldBe HasStateDetectionAirPods.ConnectionState.CALL
}
// 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
state shouldBe DualAirPods.ConnectionState.RINGING
state shouldBe HasStateDetectionAirPods.ConnectionState.RINGING
}
// 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
state shouldBe DualAirPods.ConnectionState.HANGING_UP
state shouldBe HasStateDetectionAirPods.ConnectionState.HANGING_UP
}
}
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class SingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `default bit mapping Max`() = runBlockingTest {
fun `default bit mapping Max`() = runTest {
create<SingleApplePods>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -23,7 +23,7 @@ class SingleApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test values based on AirPodMax`() = runBlockingTest {
fun `test values based on AirPodMax`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
batteryHeadsetPercent shouldBe 0.5f
@@ -1,17 +1,18 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
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 io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen1Test : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
@Test
fun `fake airpods`() = runBlockingTest {
fun `fake airpods`() = runTest {
create<AirPodsGen1>("07 19 01 02 20 55 AF 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
@@ -34,11 +35,11 @@ class AirPodsGen1Test : BaseAirPodsTest() {
isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.6f
caseLidState shouldBe DualAirPods.LidState.OPEN
state shouldBe DualAirPods.ConnectionState.DISCONNECTED
caseLidState shouldBe DualApplePods.LidState.OPEN
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN1
}
}
}
@@ -1,16 +1,17 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
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 io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `random Neighbor AirPodsGen2`() = runBlockingTest {
fun `random Neighbor AirPodsGen2`() = runTest {
create<AirPodsGen2>("07 19 01 0F 20 02 F9 8F 01 00 05 F2 7E 14 E0 54 0A 53 69 5B 7D F2 15 1F D7 B1 12") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0F20.toUShort()
@@ -33,11 +34,13 @@ class AirPodsGen2Test : BaseAirPodsTest() {
isRightPodInEar shouldBe true
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
model shouldBe PodDevice.Model.AIRPODS_GEN2
}
}
}
@@ -1,16 +1,17 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
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 io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `AirPods Gen3`() = runBlockingTest {
fun `AirPods Gen3`() = runTest {
create<AirPodsGen3>("07 19 01 13 20 75 AA B9 31 00 04 67 9A 57 DF BE F6 90 52 B0 04 1F 8D 89 DA F4 9E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1320.toUShort()
@@ -33,16 +34,18 @@ class AirPodsGen3Test : BaseAirPodsTest() {
isRightPodInEar shouldBe false
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
model shouldBe PodDevice.Model.AIRPODS_GEN3
}
}
@Test
fun `random guy at bus stop`() = runBlockingTest {
fun `random guy at bus stop`() = runTest {
create<AirPodsGen3>("07 19 01 13 20 2B 88 8F 01 00 08 E2 0E 84 37 C5 98 16 D4 B7 37 ED 23 8B 08 EA A1") {
batteryLeftPodPercent shouldBe 0.8f
batteryRightPodPercent shouldBe 0.8f
@@ -55,9 +58,9 @@ class AirPodsGen3Test : BaseAirPodsTest() {
isRightPodInEar shouldBe true
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
}
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsMaxTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max`() = runBlockingTest {
fun `default AirPods Max`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -26,12 +27,14 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
model shouldBe PodDevice.Model.AIRPODS_MAX
}
}
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max flipped values`() = runBlockingTest {
fun `default AirPods Max flipped values`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -0,0 +1,83 @@
package eu.darken.capod.pods.core.apple.airpods
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 AirPodsPro2Test : BaseAirPodsTest() {
/**
* https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084
*/
@Test
fun `test AirPods Pro 2 - unknown setup from #31`() = runTest {
create<AirPodsPro2>("07 19 01 14 20 55 88 F9 51 00 04 20 50 03 CA D5 C9 AC 0F FA 84 78 94 5A 4D DF F5") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x55.toUByte()
rawPodsBattery shouldBe 0x88.toUByte()
rawFlags shouldBe 0xF.toUShort()
rawCaseBattery shouldBe 0x9.toUShort()
rawCaseLidState shouldBe 0x51.toUByte()
rawDeviceColor shouldBe 0x0.toUByte()
rawSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
batteryLeftPodPercent shouldBe 0.8f
batteryRightPodPercent shouldBe 0.8f
isCaseCharging shouldBe true
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.9f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2
}
}
/**
* https://old.reddit.com/message/messages/1hst12h
*/
@Test
fun `test AirPods Pro 2 - unknown setup from reddit user`() = runTest {
create<AirPodsPro2>("07 19 01 14 20 2B 9A 8F 01 00 04 0F 26 1A C4 2B FA 2F B9 B6 08 CD 60 CB DF 75 AB") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x2B.toUByte()
rawPodsBattery shouldBe 0x9A.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0.toUByte()
rawSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
}

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