Compare commits

...
42 Commits
Author SHA1 Message Date
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
132 changed files with 1967 additions and 253 deletions
+9 -4
View File
@@ -16,6 +16,7 @@ A companion app that adds support for AirPod specific features to Android:
* Automatically connect phone & AirPods. * Automatically connect phone & AirPods.
* Show popup when case is opened. * Show popup when case is opened.
* Support for Wear OS * Support for Wear OS
* Widgets
CAPod is ad-free. Some additional features require an in-app purchase. CAPod is ad-free. Some additional features require an in-app purchase.
@@ -36,9 +37,13 @@ Currently supported models:
## Download ## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | Source | Status |
* [GitHub](https://github.com/d4rken-org/capod/releases/latest) |-----------------------|--------|
* [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.darken.capod) | [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplayshields.herokuapp.com%2Fplay%3Fi%3Deu.darken.capod%26l%3DAndroid%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](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 ## Support the project
@@ -54,7 +59,7 @@ Currently supported models:
## Screenshots ## 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://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 ## Thanks to
* The [OpenPods](https://github.com/adolfintel/OpenPods) project, * The [OpenPods](https://github.com/adolfintel/OpenPods) project,
+1
View File
@@ -0,0 +1 @@
2.5.1-rc0 20501000
@@ -35,7 +35,7 @@ class BleScanner @Inject constructor(
log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" } log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" }
if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" } if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" }
val adapter = bluetoothManager.adapter val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable")
val supportsOffloadFiltering = adapter.isOffloadedFilteringSupported.also { val supportsOffloadFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
@@ -45,7 +45,7 @@ class BleScanner @Inject constructor(
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && !compatMode } && !compatMode
val scanner = bluetoothManager.scanner val scanner = bluetoothManager.scanner ?: throw IllegalStateException("BLE scanner unavailable")
val callback = object : ScanCallback() { val callback = object : ScanCallback() {
var lastScanAt = System.currentTimeMillis() var lastScanAt = System.currentTimeMillis()
@@ -30,12 +30,11 @@ class BluetoothManager2 @Inject constructor(
private val dispatcherProvider: DispatcherProvider, private val dispatcherProvider: DispatcherProvider,
) { ) {
val adapter: BluetoothAdapter val adapter: BluetoothAdapter?
get() = manager.adapter get() = manager.adapter
val scanner: BluetoothLeScanner val scanner: BluetoothLeScanner?
get() = adapter.bluetoothLeScanner get() = adapter?.bluetoothLeScanner
?: throw IllegalStateException("Bluetooth is disabled or permissiong missing")
val isBluetoothEnabled: Flow<Boolean> = callbackFlow { val isBluetoothEnabled: Flow<Boolean> = callbackFlow {
send(manager.adapter?.isEnabled ?: false) 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 -> suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try { try {
@@ -7,6 +7,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.hasCause import eu.darken.capod.common.error.hasCause
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlin.time.Duration import kotlin.time.Duration
@@ -71,4 +72,11 @@ fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String)
log(tag, ERROR) { "${identifier()} failed: ${it.asLog()}" } log(tag, ERROR) { "${identifier()} failed: ${it.asLog()}" }
throw it throw it
} }
}
fun <T> Flow<T>.throttleLatest(delayMillis: Long): Flow<T> = this
.conflate()
.transform {
emit(it)
delay(delayMillis)
} }
@@ -12,6 +12,7 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare 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.GeneralSettings
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
@@ -79,6 +80,7 @@ class PodMonitor @Inject constructor(
val mainDevice: Flow<PodDevice?> = devices val mainDevice: Flow<PodDevice?> = devices
.map { determineMainDevice(it) } .map { determineMainDevice(it) }
.setupCommonEventHandlers(TAG) { "mainDevice" }
.replayingShare(appScope) .replayingShare(appScope)
private fun createBleScanner() = combine( private fun createBleScanner() = combine(
@@ -176,6 +178,15 @@ class PodMonitor @Inject constructor(
} }
} }
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" } }
}
private fun getUnfilteredFilter(): ScanFilter { private fun getUnfilteredFilter(): ScanFilter {
return ScanFilter.Builder().build() return ScanFilter.Builder().build()
} }
@@ -1,5 +1,8 @@
package eu.darken.capod.pods.core package eu.darken.capod.pods.core
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
interface DualPodDevice : PodDevice { interface DualPodDevice : PodDevice {
enum class Pod { enum class Pod {
@@ -10,4 +13,12 @@ interface DualPodDevice : PodDevice {
val batteryLeftPodPercent: Float? val batteryLeftPodPercent: Float?
val batteryRightPodPercent: 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 package eu.darken.capod.pods.core
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
interface HasCase { interface HasCase {
val batteryCasePercent: Float? val batteryCasePercent: Float?
val isCaseCharging: Boolean val isCaseCharging: Boolean
@get:DrawableRes
val caseIcon: Int
get() = R.drawable.devic_airpods_gen1_case
} }
@@ -60,31 +60,31 @@ interface PodDevice {
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
enum class Model( enum class Model(
val label: String, 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( @Json(name = "airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)", label = "AirPods (Gen 1)",
iconRes = R.drawable.ic_device_airpods_gen1, iconRes = R.drawable.devic_airpods_gen1_both,
), ),
@Json(name = "airpods.gen2") AIRPODS_GEN2( @Json(name = "airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)", "AirPods (Gen 2)",
R.drawable.ic_device_airpods_gen2, R.drawable.devic_airpods_gen2_both,
), ),
@Json(name = "airpods.gen3") AIRPODS_GEN3( @Json(name = "airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)", "AirPods (Gen 3)",
R.drawable.ic_device_airpods_gen2, R.drawable.devic_airpods_gen2_both,
), ),
@Json(name = "airpods.pro") AIRPODS_PRO( @Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro", "AirPods Pro",
R.drawable.ic_device_airpods_gen2 R.drawable.devic_airpods_pro2_both
), ),
@Json(name = "airpods.pro2") AIRPODS_PRO2( @Json(name = "airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2", "AirPods Pro 2",
R.drawable.ic_device_airpods_gen2 R.drawable.devic_airpods_pro2_both
), ),
@Json(name = "airpods.max") AIRPODS_MAX( @Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max", "AirPods Max",
R.drawable.ic_device_generic_headphones R.drawable.devic_headphones_generic
), ),
@Json(name = "beats.flex") BEATS_FLEX( @Json(name = "beats.flex") BEATS_FLEX(
"Beats Flex" "Beats Flex"
@@ -104,11 +104,17 @@ interface PodDevice {
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO( @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro" "Power Beats Pro"
), ),
@Json(name = "fakes.tws.i99999") TWS_I99999( @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"TWS i99999" "AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen1_both,
), ),
@Json(name = "fakes.varunr.airpodspro") VARUNR_AIRPODS_PRO( @Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"Fake AirPods Pro" "AirPods Pro? \uD83C\uDFAD",
R.drawable.devic_airpods_pro2_both,
),
@Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both,
), ),
@Json(name = "unknown") UNKNOWN( @Json(name = "unknown") UNKNOWN(
"Unknown" "Unknown"
@@ -7,8 +7,9 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.* import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.* import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.misc.Twsi99999 import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
import eu.darken.capod.pods.core.apple.misc.VarunrAirPodsPro import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@Module @Module
@@ -29,7 +30,7 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakesTwsi999999(factory: Twsi99999.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet @Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
abstract fun fakesVarunrAirPodsPro(factory: VarunrAirPodsPro.Factory): ApplePodsFactory<out ApplePods> @Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.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.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble 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()})" override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object { companion object {
const val MAX_HISTORY = 10 const val MAX_HISTORY = 20
} }
} }
@@ -88,6 +89,22 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
?.caseLidState ?.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? { internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult val scanResult = current.scanResult
val message = current.proximityMessage val message = current.proximityMessage
@@ -103,7 +120,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
.filter { it.history.size > KnownDevice.MAX_HISTORY } .filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList() .toList()
.forEach { .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 var recognizedDevice: KnownDevice? = knownDevices.values
@@ -118,7 +135,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
} }
if (recognizedDevice == null) { if (recognizedDevice == null) {
log(tag) { "searchHistory1: Didn't recognize: $message" } log(tag, WARN) { "searchHistory1: Didn't recognize: $message" }
} }
return recognizedDevice return recognizedDevice
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.airpods 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.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
@@ -26,6 +28,22 @@ data class AirPodsPro(
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO 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? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.airpods 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.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
@@ -26,6 +28,22 @@ data class AirPodsPro2(
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2 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? override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -19,8 +19,9 @@ import javax.inject.Inject
/** /**
* Basically an AirPods GEN1 clone * Basically an AirPods GEN1 clone
* Similar data structure but a lot of placeholder values or hardcoded values * 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 identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(), override val seenFirstAt: Instant = Instant.now(),
@@ -32,7 +33,7 @@ data class Twsi99999 constructor(
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase { ) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
override val model: PodDevice.Model = PodDevice.Model.TWS_I99999 override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN1
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi get() = rssiAverage ?: super<ApplePods>.rssi
@@ -109,7 +110,7 @@ data class Twsi99999 constructor(
override val isCaseCharging: Boolean override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2) 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 { override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong? // Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -117,7 +118,7 @@ data class Twsi99999 constructor(
} }
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods { 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) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -139,6 +140,6 @@ data class Twsi99999 constructor(
companion object { companion object {
private val DEVICE_CODE = 0x0220.toUShort() private val DEVICE_CODE = 0x0220.toUShort()
private val TAG = logTag("PodDevice", "Apple", "TWS", "i99999") private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen1")
} }
} }
@@ -0,0 +1,155 @@
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, HasDualMicrophone, HasCase, HasChargeDetectionDual {
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)
}
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
/**
* 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 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")
}
}
@@ -19,8 +19,9 @@ import javax.inject.Inject
/** /**
* AirPods Pro clone similar to Twsi999999. * AirPods Pro clone similar to Twsi999999.
* Shorter data structure. * Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/962068944196358234
*/ */
data class VarunrAirPodsPro constructor( data class FakeAirPodsPro constructor(
override val identifier: PodDevice.Id = PodDevice.Id(), override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(), override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(), override val seenFirstAt: Instant = Instant.now(),
@@ -32,7 +33,7 @@ data class VarunrAirPodsPro constructor(
private val cachedBatteryPercentage: Float? = null, private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase { ) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
override val model: PodDevice.Model = PodDevice.Model.VARUNR_AIRPODS_PRO override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO
override val rssi: Int override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi get() = rssiAverage ?: super<ApplePods>.rssi
@@ -109,7 +110,7 @@ data class VarunrAirPodsPro constructor(
override val isCaseCharging: Boolean override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2) get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<VarunrAirPodsPro>(TAG) { class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run { override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong? // Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -117,7 +118,7 @@ data class VarunrAirPodsPro constructor(
} }
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods { override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = VarunrAirPodsPro(scanResult = scanResult, proximityMessage = message) var basic = FakeAirPodsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic) val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id) if (result != null) basic = basic.copy(identifier = result.id)
@@ -139,6 +140,6 @@ data class VarunrAirPodsPro constructor(
companion object { companion object {
private val DEVICE_CODE = 0x0E20.toUShort() private val DEVICE_CODE = 0x0E20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Varunr", "AirPodsPro") private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Pro")
} }
} }
@@ -46,7 +46,8 @@ class AutoConnect @Inject constructor(
return@map return@map
} }
val bondedDevice = bluetoothManager.bondedDevices().firstOrNull { it.address == mainDeviceAddr } val bondedDevice = bluetoothManager.bondedDevices().first().firstOrNull { it.address == mainDeviceAddr }
if (bondedDevice == null) { if (bondedDevice == null) {
log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" } log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
return@map return@map
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

@@ -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>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <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_value_not_available_label">Δε διατίθεται</string>
<string name="general_error_label">Σφάλμα</string> <string name="general_error_label">Σφάλμα</string>
<string name="general_grant_permission_action">Παραχώρηση άδειας</string> <string name="general_grant_permission_action">Παραχώρηση άδειας</string>
@@ -1,2 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <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_value_not_available_label">Non disponible</string>
<string name="general_error_label">Erreur</string> <string name="general_error_label">Erreur</string>
<string name="general_grant_permission_action">Accorder lautorisation</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_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_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_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_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_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> <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_none_label_short">Aucun appareil</string>
<string name="pods_charging_label">En charge</string> <string name="pods_charging_label">En charge</string>
<string name="pods_inear_label">Dans loreille</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="pods_yours">Les vôtres</string>
<string name="headset_being_worn_label">Porté</string> <string name="headset_being_worn_label">Porté</string>
<string name="pods_case_unknown_state">État inconnu</string> <string name="pods_case_unknown_state">État inconnu</string>
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <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_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="general_grant_permission_action">Berikan izin</string>
<string name="overview_nomaindevice_label">Tidak ada perangkat utama</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> <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_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_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_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_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_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> <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,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <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_error_label">Ralat</string>
<string name="general_grant_permission_action">Berikan kebenaran</string> <string name="general_grant_permission_action">Berikan kebenaran</string>
<string name="overview_nomaindevice_label">Tiada peranti utama</string> <string name="overview_nomaindevice_label">Tiada peranti utama</string>
@@ -10,6 +11,7 @@
<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_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_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_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_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_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> <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,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <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_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string> <string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Nenhum dispositivo principal</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_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_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_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_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_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> <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>
@@ -5,7 +5,7 @@ import com.squareup.moshi.Moshi
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
import testhelpers.json.toComparableJson import testhelpers.json.toComparableJson
@@ -25,7 +25,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
) )
@Test @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 testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update") val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build() val moshi = Moshi.Builder().build()
@@ -67,7 +67,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
} }
@Test @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 testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update") val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build() val moshi = Moshi.Builder().build()
@@ -109,7 +109,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
} }
@Test @Test
fun `enum serialization`() = runBlockingTest { fun `enum serialization`() = runTest {
val moshi = Moshi.Builder().build() val moshi = Moshi.Builder().build()
val monitorMode = mockPreferences.createFlowPreference( val monitorMode = mockPreferences.createFlowPreference(
"core.monitor.mode", "core.monitor.mode",
@@ -2,7 +2,7 @@ package eu.darken.capod.common.preferences
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
import testhelpers.preferences.MockSharedPreferences import testhelpers.preferences.MockSharedPreferences
@@ -12,7 +12,7 @@ class FlowPreferenceTest : BaseTest() {
private val mockPreferences = MockSharedPreferences() private val mockPreferences = MockSharedPreferences()
@Test @Test
fun `reading and writing strings`() = runBlockingTest { fun `reading and writing strings`() = runTest {
mockPreferences.createFlowPreference<String?>( mockPreferences.createFlowPreference<String?>(
key = "testKey", key = "testKey",
defaultValue = "default" defaultValue = "default"
@@ -41,7 +41,7 @@ class FlowPreferenceTest : BaseTest() {
} }
@Test @Test
fun `reading and writing boolean`() = runBlockingTest { fun `reading and writing boolean`() = runTest {
mockPreferences.createFlowPreference<Boolean?>( mockPreferences.createFlowPreference<Boolean?>(
key = "testKey", key = "testKey",
defaultValue = true defaultValue = true
@@ -70,7 +70,7 @@ class FlowPreferenceTest : BaseTest() {
} }
@Test @Test
fun `reading and writing long`() = runBlockingTest { fun `reading and writing long`() = runTest {
mockPreferences.createFlowPreference<Long?>( mockPreferences.createFlowPreference<Long?>(
key = "testKey", key = "testKey",
defaultValue = 9000L defaultValue = 9000L
@@ -99,7 +99,7 @@ class FlowPreferenceTest : BaseTest() {
} }
@Test @Test
fun `reading and writing integer`() = runBlockingTest { fun `reading and writing integer`() = runTest {
mockPreferences.createFlowPreference<Long?>( mockPreferences.createFlowPreference<Long?>(
key = "testKey", key = "testKey",
defaultValue = 123 defaultValue = 123
@@ -128,7 +128,7 @@ class FlowPreferenceTest : BaseTest() {
} }
@Test @Test
fun `reading and writing float`() = runBlockingTest { fun `reading and writing float`() = runTest {
mockPreferences.createFlowPreference<Float?>( mockPreferences.createFlowPreference<Float?>(
key = "testKey", key = "testKey",
defaultValue = 3.6f 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.shouldBe
import io.kotest.matchers.types.instanceOf import io.kotest.matchers.types.instanceOf
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AirPodsFactoryTest : BaseAirPodsTest() { class AirPodsFactoryTest : BaseAirPodsTest() {
@Test @Test
fun `create AirPodsGen1`() = runBlockingTest { fun `create AirPodsGen1`() = runTest {
create<DualAirPods>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") { create<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") {
this shouldBe instanceOf<AirPodsGen1>() this shouldBe instanceOf<AirPodsGen1>()
} }
} }
@Test @Test
fun `create AirPodsPro`() = runBlockingTest { fun `create AirPodsPro`() = runTest {
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") { create<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") {
this shouldBe instanceOf<AirPodsPro>() this shouldBe instanceOf<AirPodsPro>()
} }
} }
@Test @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") { 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>() this shouldBe instanceOf<UnknownAppleDevice>()
} }
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class BasicSingleApplePodsTest : BaseAirPodsTest() { class BasicSingleApplePodsTest : BaseAirPodsTest() {
@Test @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") { 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort() rawDeviceModel shouldBe 0x0520.toUShort()
@@ -22,7 +22,7 @@ class BasicSingleApplePodsTest : BaseAirPodsTest() {
} }
@Test @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") { 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 batteryHeadsetPercent shouldBe 0.5f
} }
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class DualApplePodsTest : BaseAirPodsTest() { class DualApplePodsTest : BaseAirPodsTest() {
@Test @Test
fun `test bit mapping`() = runBlockingTest { fun `test bit mapping`() = runTest {
create<DualAirPods>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<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") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
@@ -23,7 +23,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - active microphone`() = runBlockingTest { fun `test AirPodDevice - active microphone`() = runTest {
create<DualAirPods>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<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") {
// 00101011 // 00101011
// --^----- // --^-----
@@ -39,7 +39,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - left pod ear status`() = runBlockingTest { fun `test AirPodDevice - left pod ear status`() = runTest {
// Left Pod primary // Left Pod primary
create<DualAirPods>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<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") {
// 00100010 // 00100010
@@ -66,7 +66,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - right pod ear status`() = runBlockingTest { fun `test AirPodDevice - right pod ear status`() = runTest {
// Left Pod primary // Left Pod primary
create<DualAirPods>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<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") {
// 00101001 // 00101001
@@ -93,7 +93,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - battery status`() = runBlockingTest { fun `test AirPodDevice - battery status`() = runTest {
// Right Pod is primary // Right Pod is primary
create<DualAirPods>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") { create<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") {
// 88 10001000 // 88 10001000
@@ -109,7 +109,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - pod charging`() = runBlockingTest { fun `test AirPodDevice - pod charging`() = runTest {
/** /**
* Right pod is charging * Right pod is charging
*/ */
@@ -157,7 +157,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - case charging`() = runBlockingTest { fun `test AirPodDevice - case charging`() = runTest {
create<DualAirPods>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") { create<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") {
// 0011 0011 // 0011 0011
isCaseCharging shouldBe false isCaseCharging shouldBe false
@@ -169,7 +169,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - case lid test`() = runBlockingTest { fun `test AirPodDevice - case lid test`() = runTest {
// Lid open // Lid open
create<DualAirPods>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") { create<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") {
// 31 0011 0001 // 31 0011 0001
@@ -210,7 +210,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
} }
@Test @Test
fun `test AirPodDevice - connection state`() = runBlockingTest { fun `test AirPodDevice - connection state`() = runTest {
// Disconnected // Disconnected
create<DualAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") { create<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") {
// 31 0011 0001 // 31 0011 0001
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class SingleApplePodsTest : BaseAirPodsTest() { class SingleApplePodsTest : BaseAirPodsTest() {
@Test @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") { 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort() rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -23,7 +23,7 @@ class SingleApplePodsTest : BaseAirPodsTest() {
} }
@Test @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") { 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 batteryHeadsetPercent shouldBe 0.5f
@@ -1,17 +1,18 @@
package eu.darken.capod.pods.core.apple.airpods 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.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AirPodsGen1Test : BaseAirPodsTest() { class AirPodsGen1Test : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269 // Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
@Test @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") { 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort() rawDeviceModel shouldBe 0x0220.toUShort()
@@ -39,6 +40,8 @@ class AirPodsGen1Test : BaseAirPodsTest() {
state shouldBe DualAirPods.ConnectionState.DISCONNECTED state shouldBe DualAirPods.ConnectionState.DISCONNECTED
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name 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 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.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AirPodsGen2Test : BaseAirPodsTest() { class AirPodsGen2Test : BaseAirPodsTest() {
@Test @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") { 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0F20.toUShort() rawDeviceModel shouldBe 0x0F20.toUShort()
@@ -38,6 +39,8 @@ class AirPodsGen2Test : BaseAirPodsTest() {
state shouldBe DualAirPods.ConnectionState.MUSIC state shouldBe DualAirPods.ConnectionState.MUSIC
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name 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 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.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AirPodsGen3Test : BaseAirPodsTest() { class AirPodsGen3Test : BaseAirPodsTest() {
@Test @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") { 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1320.toUShort() rawDeviceModel shouldBe 0x1320.toUShort()
@@ -38,11 +39,13 @@ class AirPodsGen3Test : BaseAirPodsTest() {
state shouldBe DualAirPods.ConnectionState.IDLE state shouldBe DualAirPods.ConnectionState.IDLE
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN3
} }
} }
@Test @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") { 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 batteryLeftPodPercent shouldBe 0.8f
batteryRightPodPercent shouldBe 0.8f batteryRightPodPercent shouldBe 0.8f
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.airpods 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.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AirPodsMaxTest : BaseAirPodsTest() { class AirPodsMaxTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/124 // Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test @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") { 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort() rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -26,12 +27,14 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true isHeadphonesBeingWorn shouldBe true
model shouldBe PodDevice.Model.AIRPODS_MAX
} }
} }
// Test data from https://github.com/adolfintel/OpenPods/issues/124 // Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test @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") { 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort() rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -1,9 +1,10 @@
package eu.darken.capod.pods.core.apple.airpods 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.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AirPodsPro2Test : BaseAirPodsTest() { class AirPodsPro2Test : BaseAirPodsTest() {
@@ -12,7 +13,7 @@ class AirPodsPro2Test : BaseAirPodsTest() {
* https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084 * https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084
*/ */
@Test @Test
fun `test AirPods Pro 2 - unknown setup from #31`() = runBlockingTest { 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") { 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() rawPrefix shouldBe 0x01.toUByte()
@@ -40,6 +41,8 @@ class AirPodsPro2Test : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.9f batteryCasePercent shouldBe 0.9f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2
} }
} }
@@ -47,7 +50,7 @@ class AirPodsPro2Test : BaseAirPodsTest() {
* https://old.reddit.com/message/messages/1hst12h * https://old.reddit.com/message/messages/1hst12h
*/ */
@Test @Test
fun `test AirPods Pro 2 - unknown setup from reddit user`() = runBlockingTest { 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") { 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() rawPrefix shouldBe 0x01.toUByte()
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.airpods 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.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class AirPodsProTest : BaseAirPodsTest() { class AirPodsProTest : BaseAirPodsTest() {
@Test @Test
fun `test AirPods Pro - default changed and in case`() = runBlockingTest { fun `test AirPods Pro - default changed and in case`() = runTest {
create<AirPodsPro>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<AirPodsPro>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
@@ -34,11 +35,13 @@ class AirPodsProTest : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.5f batteryCasePercent shouldBe 0.5f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO
} }
} }
@Test @Test
fun `test AirPods from my downstairs neighbour`() = runBlockingTest { fun `test AirPods from my downstairs neighbour`() = runTest {
create<AirPodsPro>("07 19 01 0E 20 00 F3 8F 02 00 04 79 C6 3F F9 C3 15 D9 11 A1 3C B1 58 66 B9 8B 67") { create<AirPodsPro>("07 19 01 0E 20 00 F3 8F 02 00 04 79 C6 3F F9 C3 15 D9 11 A1 3C B1 58 66 B9 8B 67") {
isLeftPodMicrophone shouldBe false isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true isRightPodMicrophone shouldBe true
@@ -55,7 +58,7 @@ class AirPodsProTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/34#issuecomment-565894487 // Test data from https://github.com/adolfintel/OpenPods/issues/34#issuecomment-565894487
@Test @Test
fun `various AirPods Pro messages`() = runBlockingTest { fun `various AirPods Pro messages`() = runTest {
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") { create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f batteryRightPodPercent shouldBe 0.6f
@@ -159,7 +162,7 @@ class AirPodsProTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269 // Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
@Test @Test
fun `fake airpods`() = runBlockingTest { fun `fake airpods`() = runTest {
create<AirPodsGen1>("071901022055AA563100006FE4DF10AF106081033B76D9C7112288") { create<AirPodsGen1>("071901022055AA563100006FE4DF10AF106081033B76D9C7112288") {
batteryLeftPodPercent shouldBe 1.0f batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f batteryRightPodPercent shouldBe 1.0f
@@ -170,4 +173,19 @@ class AirPodsProTest : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.6f batteryCasePercent shouldBe 0.6f
} }
} }
@Test
fun `left pod has no data`() = runTest {
create<AirPodsPro>("07 19 01 0E 20 0B F9 8F 03 00 05 5B 59 67 4C F7 F3 EF 01 BA F4 92 1B 26 E4 90 40") {
rawPodsBattery shouldBe 0xF9.toUByte()
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
}
} }
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class BeatsFlexText : BaseAirPodsTest() { class BeatsFlexText : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105 // Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test @Test
fun `default BeatsFlex`() = runBlockingTest { fun `default BeatsFlex`() = runTest {
create<BeatsFlex>("07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0") { create<BeatsFlex>("07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1020.toUShort() rawDeviceModel shouldBe 0x1020.toUShort()
@@ -22,11 +23,13 @@ class BeatsFlexText : BaseAirPodsTest() {
rawSuffix shouldBe 0x00.toUByte() rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.4f batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_FLEX
} }
} }
@Test @Test
fun `random neighbour`() = runBlockingTest { fun `random neighbour`() = runTest {
create<BeatsFlex>("07 19 01 10 20 0A F6 8F 02 4F 00 95 68 94 9E 99 D6 90 F4 5E 68 3C 58 21 68 9F 0D") { create<BeatsFlex>("07 19 01 10 20 0A F6 8F 02 4F 00 95 68 94 9E 99 D6 90 F4 5E 68 3C 58 21 68 9F 0D") {
batteryHeadsetPercent shouldBe 0.6f batteryHeadsetPercent shouldBe 0.6f
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class BeatsSolo3Test : BaseAirPodsTest() { class BeatsSolo3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests // TODO This is handcrafted data, get actual data for tests
@Test @Test
fun `default BeatsSolo3`() = runBlockingTest { fun `default BeatsSolo3`() = runTest {
create<BeatsSolo3>("07 19 01 06 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") { create<BeatsSolo3>("07 19 01 06 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0620.toUShort() rawDeviceModel shouldBe 0x0620.toUShort()
@@ -22,6 +23,8 @@ class BeatsSolo3Test : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte() rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_SOLO_3
} }
} }
} }
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class BeatsStudio3Test : BaseAirPodsTest() { class BeatsStudio3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests // TODO This is handcrafted data, get actual data for tests
@Test @Test
fun `default BeatsStudio3`() = runBlockingTest { fun `default BeatsStudio3`() = runTest {
create<BeatsStudio3>("07 19 01 09 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") { create<BeatsStudio3>("07 19 01 09 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0920.toUShort() rawDeviceModel shouldBe 0x0920.toUShort()
@@ -22,6 +23,8 @@ class BeatsStudio3Test : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte() rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_STUDIO_3
} }
} }
} }
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class BeatsXTest : BaseAirPodsTest() { class BeatsXTest : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105 // Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test @Test
fun `default BeatsX`() = runBlockingTest { fun `default BeatsX`() = runTest {
create<BeatsX>("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") { create<BeatsX>("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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort() rawDeviceModel shouldBe 0x0520.toUShort()
@@ -22,6 +23,8 @@ class BeatsXTest : BaseAirPodsTest() {
rawSuffix shouldBe 0x00.toUByte() rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.5f batteryHeadsetPercent shouldBe 0.5f
model shouldBe PodDevice.Model.BEATS_X
} }
} }
} }
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class PowerBeats3Test : BaseAirPodsTest() { class PowerBeats3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests // TODO This is handcrafted data, get actual data for tests
@Test @Test
fun `default PowerBeats3`() = runBlockingTest { fun `default PowerBeats3`() = runTest {
create<PowerBeats3>("07 19 01 03 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") { create<PowerBeats3>("07 19 01 03 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() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0320.toUShort() rawDeviceModel shouldBe 0x0320.toUShort()
@@ -22,6 +23,8 @@ class PowerBeats3Test : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte() rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.POWERBEATS_3
} }
} }
} }
@@ -1,16 +1,17 @@
package eu.darken.capod.pods.core.apple.beats package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class PowerBeatsProTest : BaseAirPodsTest() { class PowerBeatsProTest : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests // TODO This is handcrafted data, get actual data for tests
@Test @Test
fun `test PowerBeatsPro`() = runBlockingTest { fun `test PowerBeatsPro`() = runTest {
create<PowerBeatsPro>("07 19 01 0B 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { create<PowerBeatsPro>("07 19 01 0B 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0B20.toUShort() rawDeviceModel shouldBe 0x0B20.toUShort()
@@ -34,6 +35,8 @@ class PowerBeatsProTest : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.5f batteryCasePercent shouldBe 0.5f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.POWERBEATS_PRO
} }
} }
} }
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.misc package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class Twsi99999Test : BaseAirPodsTest() { class FakeAirPodsGen1Test : BaseAirPodsTest() {
@Test @Test
fun `charging in box`() = runBlockingTest { fun `charging in box`() = runTest {
create<Twsi99999>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") { create<FakeAirPodsGen1>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort() rawDeviceModel shouldBe 0x0220.toUShort()
rawStatus shouldBe 0x71.toUByte() rawStatus shouldBe 0x71.toUByte()
@@ -26,6 +27,8 @@ class Twsi99999Test : BaseAirPodsTest() {
isCaseCharging shouldBe false isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN1
} }
} }
} }
@@ -0,0 +1,36 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `charging in case`() = runTest {
create<FakeAirPodsGen3>("07 13 01 13 20 75 AA 37 34 00 10 00 E4 E4 64 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1320.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x34.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1f
batteryRightPodPercent shouldBe 1f
isCaseCharging shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN3
}
}
}
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.misc package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class VarunrAirPodsProTest : BaseAirPodsTest() { class FakeAirPodsProTest : BaseAirPodsTest() {
@Test @Test
fun `guessed data`() = runBlockingTest { fun `guessed data`() = runTest {
create<VarunrAirPodsPro>("07 13 01 0E 20 71 AA 37 36 00 10 00 FF 64 FF 00 00 00 00 00 00") { create<FakeAirPodsPro>("07 13 01 0E 20 71 AA 37 36 00 10 00 FF 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte() rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0E20.toUShort() rawDeviceModel shouldBe 0x0E20.toUShort()
rawStatus shouldBe 0x71.toUByte() rawStatus shouldBe 0x71.toUByte()
@@ -26,6 +27,8 @@ class VarunrAirPodsProTest : BaseAirPodsTest() {
isCaseCharging shouldBe false isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO
} }
} }
} }
@@ -9,6 +9,7 @@ import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3 import eu.darken.capod.common.uix.ViewModel3
@@ -76,8 +77,8 @@ class OverviewFragmentVM @Inject constructor(
permissionTool.missingPermissions, permissionTool.missingPermissions,
debugSettings.isDebugModeEnabled.flow, debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled, bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice, podMonitor.mainDevice.throttleLatest(1000),
) { _, permissions, isDebugMode, isBluetoothEnabled, mainPod -> ) { _, permissions, isDebugMode, isBluetoothEnabled, _ ->
val items = mutableListOf<OverviewAdapter.Item>() val items = mutableListOf<OverviewAdapter.Item>()
if (permissions.isNotEmpty()) { if (permissions.isNotEmpty()) {
@@ -97,10 +98,7 @@ class OverviewFragmentVM @Inject constructor(
return@combine items return@combine items
} }
val podToShow = mainPod ?: podDeviceCache.loadMainDevice()?.let { val podToShow = podMonitor.latestMainDevice()
log(TAG, VERBOSE) { "Using podDeviceCache: $it" }
podFactory.createPod(it)?.device
}
log(TAG, VERBOSE) { "Showing $podToShow" } log(TAG, VERBOSE) { "Showing $podToShow" }
val now = Instant.now() val now = Instant.now()
@@ -29,14 +29,16 @@ class DualPodsCardVH(parent: ViewGroup) :
} }
text = sb text = sb
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD) if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL) else setTypeface(null, Typeface.NORMAL)
if (device is DualAirPods && item.showDebug) { if (device is DualAirPods && item.showDebug) {
append(" [${device.primaryPod.name}]") append(" [${device.primaryPod.name}]")
} }
} }
deviceIcon.setImageResource(device.iconRes) deviceIcon.setImageResource(device.iconRes)
podLeftIcon.setImageResource(device.leftPodIcon)
podRightIcon.setImageResource(device.rightPodIcon)
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
@@ -91,6 +93,7 @@ class DualPodsCardVH(parent: ViewGroup) :
// Case charge state // Case charge state
device.apply { device.apply {
if (this is HasCase) { if (this is HasCase) {
podCaseIcon.setImageResource(caseIcon)
podCaseBatteryIcon.isGone = false podCaseBatteryIcon.isGone = false
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent)) podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context) podCaseBatteryLabel.text = getBatteryLevelCase(context)
@@ -23,8 +23,8 @@ class SinglePodsCardVH(parent: ViewGroup) :
name.apply { name.apply {
text = device.getLabel(context) text = device.getLabel(context)
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD) if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL) else setTypeface(null, Typeface.NORMAL)
} }
deviceIcon.setImageResource(device.iconRes) deviceIcon.setImageResource(device.iconRes)
@@ -16,7 +16,7 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="2dp" android:layout_marginBottom="2dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_airpod_both_24" /> app:srcCompat="@drawable/devic_airpods_gen1_both" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/name" android:id="@+id/name"
@@ -71,7 +71,7 @@
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon"
android:layout_marginHorizontal="2dp" android:layout_marginHorizontal="2dp"
android:contentDescription="@string/pods_dual_left_label" android:contentDescription="@string/pods_dual_left_label"
android:src="@drawable/ic_airpod_left_24" android:src="@drawable/devic_airpods_gen1_left"
app:layout_constraintEnd_toStartOf="@+id/pod_left_wear_icon" app:layout_constraintEnd_toStartOf="@+id/pod_left_wear_icon"
app:layout_constraintHorizontal_chainStyle="spread" app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@@ -145,7 +145,7 @@
<ImageView <ImageView
android:id="@+id/pod_case_icon" android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_case_24" android:src="@drawable/devic_airpods_gen1_case"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
@@ -198,7 +198,7 @@
style="@style/PodInfoItemIcon" style="@style/PodInfoItemIcon"
android:layout_marginHorizontal="2dp" android:layout_marginHorizontal="2dp"
android:contentDescription="@string/pods_dual_right_label" android:contentDescription="@string/pods_dual_right_label"
android:src="@drawable/ic_airpod_right_24" android:src="@drawable/devic_airpods_gen1_right"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="spread_inside" app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@+id/pod_right_wear_icon" app:layout_constraintStart_toEndOf="@+id/pod_right_wear_icon"
@@ -15,7 +15,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_device_generic_headphones" /> app:srcCompat="@drawable/devic_headphones_generic" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/name" android:id="@+id/name"
+4
View File
@@ -4,6 +4,10 @@
<style name="PodInfoItemIcon" parent="TextAppearance.Material3.BodySmall"> <style name="PodInfoItemIcon" parent="TextAppearance.Material3.BodySmall">
<item name="android:layout_height">16dp</item> <item name="android:layout_height">16dp</item>
<item name="android:layout_width">16dp</item> <item name="android:layout_width">16dp</item>
<item name="android:tintMode">multiply</item>
<item name="android:tint">?android:attr/colorAccent</item>
<item name="tint">?android:attr/colorAccent</item>
</style> </style>
<style name="PodInfoItemText" parent="TextAppearance.Material3.BodyMedium"> <style name="PodInfoItemText" parent="TextAppearance.Material3.BodyMedium">
+1 -1
View File
@@ -51,7 +51,7 @@ android {
} }
buildTypes { buildTypes {
val customProguardRules = fileTree(File("../proguard")) { val customProguardRules = fileTree(File(projectDir, "proguard")) {
include("*.pro") include("*.pro")
} }
debug { debug {
+4 -1
View File
@@ -1,2 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources></resources> <resources>
<string name="upgrades_gplay_unavailable_error">Los servicios de Google Play no están disponibles.</string>
<string name="upgrades_no_purchases_found_check_account">No se encontraron compras. ¿Está usando la cuenta correcta?</string>
</resources>
+12 -1
View File
@@ -38,7 +38,6 @@
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
@@ -69,6 +68,18 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver
android:name=".main.ui.widget.WidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/battery_widget_info" />
</receiver>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"
+29
View File
@@ -8,8 +8,16 @@ import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutoReporting import eu.darken.capod.common.debug.autoreport.AutoReporting
import eu.darken.capod.common.debug.logging.* import eu.darken.capod.common.debug.logging.*
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl import eu.darken.capod.monitor.core.worker.MonitorControl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -19,6 +27,9 @@ open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutoReporting @Inject lateinit var autoReporting: AutoReporting
@Inject lateinit var monitorControl: MonitorControl @Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject @AppScope lateinit var appScope: CoroutineScope @Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() { override fun onCreate() {
@@ -36,6 +47,24 @@ open class App : Application(), Configuration.Provider {
appScope.launch { appScope.launch {
monitorControl.startMonitor(forceStart = true) monitorControl.startMonitor(forceStart = true)
} }
podMonitor.mainDevice
.distinctUntilChanged()
.throttleLatest(1000)
.onEach {
log(TAG) { "Main device changed, refreshing widgets." }
widgetManager.refreshWidgets()
}
.launchIn(appScope)
upgradeRepo.upgradeInfo
.map { it.isPro }
.distinctUntilChanged()
.onEach {
log(TAG) { "Pro status changed, refreshing widgets." }
widgetManager.refreshWidgets()
}
.launchIn(appScope)
} }
override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder() override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
@@ -10,9 +10,7 @@ import androidx.annotation.MenuRes
import androidx.annotation.XmlRes import androidx.annotation.XmlRes
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.preferences.Settings import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.main.ui.settings.SettingsFragment import eu.darken.capod.main.ui.settings.SettingsFragment
@@ -69,17 +67,4 @@ abstract class PreferenceFragment2
} }
} }
} }
inline fun <T> LiveData<T>.observe2(
crossinline callback: (T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
} }
@@ -0,0 +1,35 @@
package eu.darken.capod.common.uix
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LiveData
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.error.asErrorDialogBuilder
abstract class PreferenceFragment3 : PreferenceFragment2() {
abstract val vm: ViewModel3
var onErrorEvent: ((Throwable) -> Boolean)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
vm.errorEvents.observe2 {
val showDialog = onErrorEvent?.invoke(it) ?: true
if (showDialog) it.asErrorDialogBuilder(requireContext()).show()
}
}
inline fun <T> LiveData<T>.observe2(
crossinline callback: (T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -9,6 +9,7 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.navigation.navVia import eu.darken.capod.common.navigation.navVia
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
@@ -96,6 +97,7 @@ class OverviewFragmentVM @Inject constructor(
} }
} }
.catch { errorEvents.postValue(it) } .catch { errorEvents.postValue(it) }
.throttleLatest(1000)
val listItems: LiveData<List<OverviewAdapter.Item>> = combine( val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
updateTicker, updateTicker,
@@ -128,24 +130,25 @@ class OverviewFragmentVM @Inject constructor(
if (permissions.isEmpty() && isBluetoothEnabled) { if (permissions.isEmpty() && isBluetoothEnabled) {
pods.map { pods.map {
val now = Instant.now() val now = Instant.now()
val isMainPod = it.identifier == mainPod?.identifier
when (it) { when (it) {
is DualPodDevice -> DualPodsCardVH.Item( is DualPodDevice -> DualPodsCardVH.Item(
now = now, now = now,
device = it, device = it,
showDebug = isDebugMode, showDebug = isDebugMode,
isMainPod = it == mainPod, isMainPod = isMainPod,
) )
is SinglePodDevice -> SinglePodsCardVH.Item( is SinglePodDevice -> SinglePodsCardVH.Item(
now = now, now = now,
device = it, device = it,
showDebug = isDebugMode, showDebug = isDebugMode,
isMainPod = it == mainPod, isMainPod = isMainPod,
) )
else -> UnknownPodDeviceCardVH.Item( else -> UnknownPodDeviceCardVH.Item(
now = now, now = now,
device = it, device = it,
showDebug = isDebugMode, showDebug = isDebugMode,
isMainPod = it == mainPod, isMainPod = isMainPod,
) )
} }
}.run { items.addAll(this) } }.run { items.addAll(this) }
@@ -31,14 +31,16 @@ class DualPodsCardVH(parent: ViewGroup) :
} }
text = sb text = sb
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD) if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL) else setTypeface(null, Typeface.NORMAL)
if (device is DualAirPods && item.showDebug) { if (device is DualAirPods && item.showDebug) {
append(" [${device.primaryPod.name}]") append(" [${device.primaryPod.name}]")
} }
} }
deviceIcon.setImageResource(device.iconRes) deviceIcon.setImageResource(device.iconRes)
podLeftIcon.setImageResource(device.leftPodIcon)
podRightIcon.setImageResource(device.rightPodIcon)
lastSeen.text = context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now)) lastSeen.text = context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now)) firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
@@ -109,6 +111,7 @@ class DualPodsCardVH(parent: ViewGroup) :
// Case charge state // Case charge state
device.apply { device.apply {
if (this is HasCase) { if (this is HasCase) {
podCaseIcon.setImageResource(caseIcon)
podCaseBatteryIcon.isGone = false podCaseBatteryIcon.isGone = false
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent)) podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context) podCaseBatteryLabel.text = getBatteryLevelCase(context)
@@ -23,8 +23,8 @@ class SinglePodsCardVH(parent: ViewGroup) :
name.apply { name.apply {
text = device.getLabel(context) text = device.getLabel(context)
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD) if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL) else setTypeface(null, Typeface.NORMAL)
} }
deviceIcon.setImageResource(device.iconRes) deviceIcon.setImageResource(device.iconRes)
@@ -24,8 +24,8 @@ class UnknownPodDeviceCardVH(parent: ViewGroup) :
val device = item.device val device = item.device
name.apply { name.apply {
text = device.getLabel(context) text = device.getLabel(context)
if (item.isMainPod) setTypeface(typeface, Typeface.BOLD) if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(typeface, Typeface.NORMAL) else setTypeface(null, Typeface.NORMAL)
} }
lastSeen.text = device.lastSeenFormatted(item.now) lastSeen.text = device.lastSeenFormatted(item.now)
@@ -0,0 +1,5 @@
package eu.darken.capod.main.ui.settings.general
sealed class GeneralSettingsEvents {
object SelectDeviceAddressEvent : GeneralSettingsEvents()
}
@@ -10,7 +10,7 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.ScannerMode import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.preferences.PercentSliderPreference import eu.darken.capod.common.preferences.PercentSliderPreference
import eu.darken.capod.common.uix.PreferenceFragment2 import eu.darken.capod.common.uix.PreferenceFragment3
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
@@ -19,9 +19,9 @@ import javax.inject.Inject
@Keep @Keep
@AndroidEntryPoint @AndroidEntryPoint
class GeneralSettingsFragment : PreferenceFragment2() { class GeneralSettingsFragment : PreferenceFragment3() {
private val vm: GeneralSettingsFragmentVM by viewModels() override val vm: GeneralSettingsFragmentVM by viewModels()
@Inject lateinit var generalSettings: GeneralSettings @Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var upgradeRepo: UpgradeRepo @Inject lateinit var upgradeRepo: UpgradeRepo
@@ -61,6 +61,13 @@ class GeneralSettingsFragment : PreferenceFragment2() {
true true
} }
} }
vm.events.observe2 {
when (it) {
GeneralSettingsEvents.SelectDeviceAddressEvent -> mainDeviceAddressPref.performClick()
}
}
mainDeviceModelPref.setOnPreferenceClickListener { mainDeviceModelPref.setOnPreferenceClickListener {
val dialog = ModelSelectionDialogFactory(requireContext()).create( val dialog = ModelSelectionDialogFactory(requireContext()).create(
PodDevice.Model.values().toList(), PodDevice.Model.values().toList(),
@@ -5,20 +5,40 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.uix.ViewModel3 import eu.darken.capod.common.uix.ViewModel3
import kotlinx.coroutines.flow.flow import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class GeneralSettingsFragmentVM @Inject constructor( class GeneralSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle, @Suppress("unused") private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider, dispatcherProvider: DispatcherProvider,
private val bluetoothManager: BluetoothManager2, bluetoothManager: BluetoothManager2,
private val generalSettings: GeneralSettings,
) : ViewModel3(dispatcherProvider) { ) : ViewModel3(dispatcherProvider) {
val bondedDevices = flow { val bondedDevices = bluetoothManager.bondedDevices()
emit(bluetoothManager.bondedDevices().toList()) .map { it.toList() }
}.asLiveData2() .asLiveData2()
val events = SingleLiveEvent<GeneralSettingsEvents>()
init {
generalSettings.monitorMode.flow
.withPrevious()
.filter { (old, new) ->
old != new && new == MonitorMode.AUTOMATIC && generalSettings.mainDeviceAddress.value == null
}
.onEach { events.postValue(GeneralSettingsEvents.SelectDeviceAddressEvent) }
.launchInViewModel()
}
companion object { companion object {
private val TAG = logTag("Settings", "General", "VM") private val TAG = logTag("Settings", "General", "VM")
@@ -9,14 +9,14 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.debug.autoreport.DebugSettings import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.observe2 import eu.darken.capod.common.observe2
import eu.darken.capod.common.uix.PreferenceFragment2 import eu.darken.capod.common.uix.PreferenceFragment3
import javax.inject.Inject import javax.inject.Inject
@Keep @Keep
@AndroidEntryPoint @AndroidEntryPoint
class DebugSettingsFragment : PreferenceFragment2() { class DebugSettingsFragment : PreferenceFragment3() {
private val vm: DebugSettingsFragmentVM by viewModels() override val vm: DebugSettingsFragmentVM by viewModels()
@Inject lateinit var debugSettings: DebugSettings @Inject lateinit var debugSettings: DebugSettings
@@ -10,15 +10,15 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.ClipboardHelper import eu.darken.capod.common.ClipboardHelper
import eu.darken.capod.common.observe2 import eu.darken.capod.common.observe2
import eu.darken.capod.common.uix.PreferenceFragment2 import eu.darken.capod.common.uix.PreferenceFragment3
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import javax.inject.Inject import javax.inject.Inject
@Keep @Keep
@AndroidEntryPoint @AndroidEntryPoint
class SupportFragment : PreferenceFragment2() { class SupportFragment : PreferenceFragment3() {
private val vm: SupportFragmentVM by viewModels() override val vm: SupportFragmentVM by viewModels()
override val preferenceFile: Int = R.xml.preferences_support override val preferenceFile: Int = R.xml.preferences_support
@Inject lateinit var generalSettings: GeneralSettings @Inject lateinit var generalSettings: GeneralSettings
@@ -0,0 +1,41 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WidgetManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val widgetManager by lazy { AppWidgetManager.getInstance(context) }
private val currentWidgetIds: IntArray
get() = widgetManager.getAppWidgetIds(ComponentName(context, PROVIDER_CLASS))
suspend fun refreshWidgets() {
log(TAG) { "refreshWidgets()" }
log(TAG, VERBOSE) { "Notifying these widget IDs: ${currentWidgetIds.toList()}" }
val intent = Intent(context, PROVIDER_CLASS).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, currentWidgetIds)
}
context.sendBroadcast(intent)
}
companion object {
val PROVIDER_CLASS = WidgetProvider::class.java
val TAG = logTag("Widget", "Manager")
}
}
@@ -0,0 +1,239 @@
package eu.darken.capod.main.ui.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.time.Duration
import javax.inject.Inject
@AndroidEntryPoint
class WidgetProvider : AppWidgetProvider() {
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var podDeviceCache: PodDeviceCache
@Inject lateinit var podFactory: PodFactory
@Inject lateinit var upgradeRepo: UpgradeRepo
@AppScope @Inject lateinit var appScope: CoroutineScope
private var asyncBarrier: PendingResult? = null
private fun executeAsync(
tag: String,
timeout: Duration = Duration.ofSeconds(10),
block: suspend () -> Unit
) {
val start = System.currentTimeMillis()
asyncBarrier = goAsync()
log(TAG, VERBOSE) { "executeAsync($tag) starting asyncBarrier=$asyncBarrier " }
appScope.launch {
try {
withTimeout(timeout.toMillis()) { block() }
} catch (e: Exception) {
log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" }
} finally {
asyncBarrier?.finish()
val stop = System.currentTimeMillis()
log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " }
}
}
log(TAG, VERBOSE) { "executeAsync($block) leaving" }
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
log(TAG) { "onUpdate(appWidgetIds=${appWidgetIds.toList()})" }
executeAsync("onUpdate") {
appWidgetIds.forEach { appWidgetId ->
updateWidget(context, appWidgetManager, appWidgetId, null)
}
}
}
override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle?
) {
log(TAG) { "onAppWidgetOptionsChanged(appWidgetId=$appWidgetId, newOptions=$newOptions)" }
executeAsync("onAppWidgetOptionsChanged") {
updateWidget(context, appWidgetManager, appWidgetId, newOptions)
}
}
private suspend fun updateWidget(
context: Context,
widgetManager: AppWidgetManager,
widgetId: Int,
options: Bundle?
) {
log(TAG) { "updateWidget(widgetId=$widgetId, options=$options)" }
val device: PodDevice? = podMonitor.latestMainDevice()
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context)
device is DualPodDevice -> createDualPodLayout(context, device)
device is SinglePodDevice -> createSinglePodLayout(context, device)
device is PodDevice -> createUnknownPodLayout(context, device)
else -> createNoDeviceLayout(context)
}
widgetManager.updateAppWidget(widgetId, layout)
}
private suspend fun createUpgradeRequiredLayout(
context: Context
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.upgrade_capod_label))
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
setViewVisibility(R.id.secondary, View.VISIBLE)
}
private fun createUnknownPodLayout(
context: Context,
podDevice: PodDevice,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.pods_unknown_label))
}
private fun createNoDeviceLayout(
context: Context,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.overview_nomaindevice_label))
}
private fun createDualPodLayout(
context: Context,
podDevice: DualPodDevice,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_dual_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
// Left
setImageViewResource(R.id.pod_left_icon, podDevice.leftPodIcon)
setTextViewText(R.id.pod_left_label, podDevice.getBatteryLevelLeftPod(context))
setViewVisibility(
R.id.pod_left_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isLeftPodCharging) View.VISIBLE else View.GONE
)
setViewVisibility(
R.id.pod_left_ear,
if (podDevice is HasEarDetectionDual && podDevice.isLeftPodInEar) View.VISIBLE else View.GONE
)
// Case
(podDevice as? HasCase)?.let { setImageViewResource(R.id.pod_case_icon, it.caseIcon) }
setTextViewText(R.id.pod_case_label, (podDevice as? HasCase)?.getBatteryLevelCase(context))
setViewVisibility(
R.id.pod_case_charging,
if (podDevice is HasCase && podDevice.isCaseCharging) View.VISIBLE else View.GONE
)
// Right
setImageViewResource(R.id.pod_right_icon, podDevice.rightPodIcon)
setTextViewText(R.id.pod_right_label, podDevice.getBatteryLevelRightPod(context))
setViewVisibility(
R.id.pod_right_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isRightPodCharging) View.VISIBLE else View.GONE
)
setViewVisibility(
R.id.pod_right_ear,
if (podDevice is HasEarDetectionDual && podDevice.isRightPodInEar) View.VISIBLE else View.GONE
)
}
private fun createSinglePodLayout(
context: Context,
podDevice: SinglePodDevice,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
setImageViewResource(R.id.headphones_icon, podDevice.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(podDevice.batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, podDevice.getBatteryLevelHeadset(context))
setViewVisibility(
R.id.headphones_worn,
if (podDevice is HasEarDetection && podDevice.isBeingWorn) View.VISIBLE else View.GONE
)
if (this is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
}
}
companion object {
val TAG = logTag("Widget", "Provider")
}
}
@@ -1,6 +1,7 @@
package eu.darken.capod.monitor.core.worker package eu.darken.capod.monitor.core.worker
import android.app.NotificationManager import android.app.NotificationManager
import android.bluetooth.BluetoothDevice
import android.content.Context import android.content.Context
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@@ -17,9 +18,11 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.MonitorComponent import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.PodMonitor import eu.darken.capod.monitor.core.PodMonitor
@@ -48,6 +51,7 @@ class MonitorWorker @AssistedInject constructor(
private val autoConnect: AutoConnect, private val autoConnect: AutoConnect,
private val popUpReaction: PopUpReaction, private val popUpReaction: PopUpReaction,
private val popUpWindow: PopUpWindow, private val popUpWindow: PopUpWindow,
private val widgetManager: WidgetManager,
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope() private val workerScope = MonitorCoroutineScope()
@@ -99,6 +103,7 @@ class MonitorWorker @AssistedInject constructor(
.setupCommonEventHandlers(TAG) { "PodMonitor" } .setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart { setForeground(monitorNotifications.getForegroundInfo(null)) } .onStart { setForeground(monitorNotifications.getForegroundInfo(null)) }
.distinctUntilChanged() .distinctUntilChanged()
.throttleLatest(1000)
.onEach { currentDevice -> .onEach { currentDevice ->
notificationManager.notify( notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID, MonitorNotifications.NOTIFICATION_ID,
@@ -110,38 +115,49 @@ class MonitorWorker @AssistedInject constructor(
} }
.launchIn(workerScope) .launchIn(workerScope)
generalSettings.monitorMode.flow permissionTool.missingPermissions
.flatMapLatest { monitorMode -> .flatMapLatest { missingPermsFlow ->
val missingPermsFlow = permissionTool.missingPermissions.first()
if (missingPermsFlow.isNotEmpty()) { if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" } log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
workerScope.coroutineContext.cancelChildren() workerScope.coroutineContext.cancelChildren()
return@flatMapLatest emptyFlow() return@flatMapLatest emptyFlow()
} }
combine(
bluetoothManager.connectedDevices().map { knownDevices -> generalSettings.monitorMode.flow,
monitorMode to knownDevices generalSettings.mainDeviceAddress.flow,
bluetoothManager.connectedDevices(),
) { monitorMode, mainAddress, connectedDevices ->
listOf(monitorMode, mainAddress, connectedDevices)
} }
} }
.setupCommonEventHandlers(TAG) { "MonitorMode" } .setupCommonEventHandlers(TAG) { "MonitorMode" }
.flatMapLatest { (monitorMode, devices) -> .flatMapLatest { arguments ->
val monitorMode = arguments[0] as MonitorMode
val mainAddress = arguments[1] as String?
val devices = arguments[2] as Collection<BluetoothDevice>
log(TAG) { "Monitor mode: $monitorMode" } log(TAG) { "Monitor mode: $monitorMode" }
when (monitorMode) { when (monitorMode) {
MonitorMode.MANUAL -> flow<Unit> { MonitorMode.MANUAL -> flow<Unit> {
// Cancel worker, ui scans manually // Cancel worker, ui scans manually
workerScope.coroutineContext.cancelChildren() workerScope.coroutineContext.cancelChildren()
} }
MonitorMode.ALWAYS -> emptyFlow() MonitorMode.ALWAYS -> emptyFlow()
MonitorMode.AUTOMATIC -> flow { MonitorMode.AUTOMATIC -> flow {
val mainAddress = generalSettings.mainDeviceAddress.value when {
if (devices.any { it.address == mainAddress }) { mainAddress == null && devices.isNotEmpty() -> {
log(TAG) { "MainDevice is connected ($mainAddress), aborting any timeout." } log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
} else { }
log(TAG) { "No Pods are connected, canceling worker soon." } devices.any { it.address == mainAddress } -> {
delay(15 * 1000) log(TAG) { "MainDevice is connected ($mainAddress), aborting any timeout." }
log(TAG) { "Canceling worker now, still no Pods connected." } }
else -> {
log(TAG) { "No known Pods are connected, canceling worker soon." }
delay(15 * 1000)
log(TAG) { "Canceling worker now, still no Pods connected." }
workerScope.coroutineContext.cancelChildren() workerScope.coroutineContext.cancelChildren()
}
} }
} }
} }
@@ -27,15 +27,18 @@ class MonitorNotificationViewFactory @Inject constructor(
).apply { ).apply {
device.apply { device.apply {
// Left // Left
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context)) setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context))
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE) setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE) setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
// Case // Case
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
setTextViewText(R.id.pod_case_label, getBatteryLevelCase(context)) setTextViewText(R.id.pod_case_label, getBatteryLevelCase(context))
setViewVisibility(R.id.pod_case_charging, if (isCaseCharging) View.VISIBLE else View.GONE) setViewVisibility(R.id.pod_case_charging, if (isCaseCharging) View.VISIBLE else View.GONE)
// Right // Right
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context)) setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE) setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE) setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
@@ -48,6 +51,7 @@ class MonitorNotificationViewFactory @Inject constructor(
).apply { ).apply {
device.apply { device.apply {
setTextViewText(R.id.headphones_label, getLabel(context)) setTextViewText(R.id.headphones_label, getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent)) setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context)) setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
if (this is HasEarDetection) { if (this is HasEarDetection) {
@@ -50,7 +50,7 @@ class MonitorNotifications @Inject constructor(
setChannelId(NOTIFICATION_CHANNEL_ID) setChannelId(NOTIFICATION_CHANNEL_ID)
setContentIntent(openPi) setContentIntent(openPi)
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
setSmallIcon(R.drawable.ic_device_generic_earbuds) setSmallIcon(R.drawable.devic_earbuds_generic_both)
setOngoing(true) setOngoing(true)
setContentTitle(context.getString(R.string.app_name)) setContentTitle(context.getString(R.string.app_name))
} }
@@ -63,7 +63,7 @@ class MonitorNotifications @Inject constructor(
setStyle(NotificationCompat.BigTextStyle()) setStyle(NotificationCompat.BigTextStyle())
setContentTitle(context.getString(R.string.pods_none_label_short)) setContentTitle(context.getString(R.string.pods_none_label_short))
setSubText(context.getString(R.string.app_name)) setSubText(context.getString(R.string.app_name))
setSmallIcon(R.drawable.ic_device_generic_earbuds) setSmallIcon(R.drawable.devic_earbuds_generic_both)
} }
} }
@@ -1,5 +1,6 @@
package eu.darken.capod.reaction.ui package eu.darken.capod.reaction.ui
import android.bluetooth.BluetoothDevice
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.Keep import androidx.annotation.Keep
@@ -10,7 +11,7 @@ import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.uix.PreferenceFragment2 import eu.darken.capod.common.uix.PreferenceFragment3
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
@@ -21,9 +22,9 @@ import javax.inject.Inject
@Keep @Keep
@AndroidEntryPoint @AndroidEntryPoint
class ReactionSettingsFragment : PreferenceFragment2() { class ReactionSettingsFragment : PreferenceFragment3() {
private val vm: ReactionSettingsFragmentVM by viewModels() override val vm: ReactionSettingsFragmentVM by viewModels()
@Inject lateinit var generalSettings: GeneralSettings @Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var reactionSettings: ReactionSettings @Inject lateinit var reactionSettings: ReactionSettings
@@ -35,6 +36,7 @@ class ReactionSettingsFragment : PreferenceFragment2() {
override val preferenceFile: Int = R.xml.preferences_reactions override val preferenceFile: Int = R.xml.preferences_reactions
private var isPro: Boolean = false private var isPro: Boolean = false
private var bondedDevices: List<BluetoothDevice> = emptyList()
private val autoConnectConditionPref by lazy { findPreference<ListPreference>(settings.autoConnectCondition.key)!! } private val autoConnectConditionPref by lazy { findPreference<ListPreference>(settings.autoConnectCondition.key)!! }
override fun onPreferencesCreated() { override fun onPreferencesCreated() {
@@ -66,10 +68,9 @@ class ReactionSettingsFragment : PreferenceFragment2() {
preference.isChecked = false preference.isChecked = false
return true return true
} else if (generalSettings.mainDeviceAddress.value == null) { } else if (generalSettings.mainDeviceAddress.value == null) {
val devices = vm.bondedDevices
DeviceSelectionDialogFactory(requireContext()).create( DeviceSelectionDialogFactory(requireContext()).create(
devices = devices, devices = bondedDevices,
current = devices.firstOrNull { it.address == generalSettings.mainDeviceAddress.value } current = bondedDevices.firstOrNull { it.address == generalSettings.mainDeviceAddress.value }
) { selected -> ) { selected ->
generalSettings.mainDeviceAddress.value = selected?.address generalSettings.mainDeviceAddress.value = selected?.address
if (selected != null) preference.isChecked = true if (selected != null) preference.isChecked = true
@@ -87,14 +88,21 @@ class ReactionSettingsFragment : PreferenceFragment2() {
return super.onPreferenceTreeClick(preference) return super.onPreferenceTreeClick(preference)
} }
// Some UI interactions shortly turn autoConnect on, then off again
private val previousMonitorMode by lazy { generalSettings.monitorMode.value }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
settings.autoConnect.flow.asLiveData().observe2 { settings.autoConnect.flow.asLiveData().observe2 { isEnabled ->
generalSettings.monitorMode.value = MonitorMode.ALWAYS if (isEnabled) generalSettings.monitorMode.value = MonitorMode.ALWAYS
autoConnectConditionPref.isEnabled = it else generalSettings.monitorMode.value = previousMonitorMode
autoConnectConditionPref.isEnabled = isEnabled
} }
vm.isPro.observe2 { isPro = it } vm.isPro.observe2 { isPro = it }
vm.bondedDevices.observe2 { bondedDevices = it }
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
} }
@@ -18,10 +18,12 @@ class ReactionSettingsFragmentVM @Inject constructor(
private val upgradeRepo: UpgradeRepo, private val upgradeRepo: UpgradeRepo,
) : ViewModel3(dispatcherProvider) { ) : ViewModel3(dispatcherProvider) {
val bondedDevices = bluetoothManager.bondedDevices().toList()
val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveData2() val isPro = upgradeRepo.upgradeInfo.map { it.isPro }.asLiveData2()
val bondedDevices = bluetoothManager.bondedDevices()
.map { it.toList() }
.asLiveData2()
companion object { companion object {
private val TAG = logTag("Settings", "Reaction", "VM") private val TAG = logTag("Settings", "Reaction", "VM")
} }

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