Compare commits

..
45 Commits
Author SHA1 Message Date
darken 51f9c5c7d8 Release: 2.6.1-rc0 2023-01-20 12:55:28 +01:00
Matthias Urhahn 15141c8528 More granular pod features (#76)
* Make pod features more granular
Don't assume all AirPods/Beats return a correct connection state.

* Refactoring

* Fix refactoring regression

* AirPods Gen1 don't support state detection
2023-01-18 22:15:38 +01:00
Matthias Urhahn 0cdac10fd4 Update README.md 2023-01-14 16:45:11 +01:00
darken aaab548bfd Release: 2.6.0-rc0 2023-01-14 16:44:16 +01:00
Matthias Urhahn 46611b5606 Add preliminary support for Beats Fit Pro (#75) 2023-01-14 16:35:04 +01:00
Matthias Urhahn fdbdb46577 Overhaul play/pause reaction code (#74) 2023-01-14 16:21:57 +01:00
darken e7fd81d0eb Release: 2.5.3-rc1 2023-01-12 12:51:46 +01:00
darken ce50bae6a0 Merge branch 'main' of github.com:d4rken-org/capod into main 2023-01-12 12:51:03 +01:00
darken 8331cf3866 Release: 2.5.3-rc0 2023-01-12 12:50:53 +01:00
Matthias Urhahn 670262e53b Update README.md 2023-01-12 12:50:44 +01:00
Matthias Urhahn c584f942cf Support for fake AirPods 2. Generation (#73) 2023-01-12 12:32:44 +01:00
darken e83b6bb583 Update readme 2023-01-12 12:11:46 +01:00
darken cc5de668f0 Add additional test data for fake AirPods Pro2 2023-01-12 12:07:24 +01:00
darken 653c7b8c4b Release: 2.5.2-rc0 2023-01-06 16:45:28 +01:00
darken 4e38bcd4cc Fix left pod icon tint. 2023-01-06 16:44:30 +01:00
Matthias Urhahn fcbeda4888 Improve feature support for fake AirPods Gen3 (#72)
Remove microphone detection and add ear detection.
2023-01-06 16:40:03 +01:00
Matthias Urhahn 4101fddc5b Improve feature support for fake AirPods Gen1 (#71)
Remove microphone detection and add ear detection and pod charge detection.
2023-01-06 16:39:46 +01:00
darken 86df7bea22 Improve feature support for fake AirPods Pro
Remove microphone detection and add pod charging and ear detection.
2023-01-06 16:02:38 +01:00
darken 4db8c0c286 Add support for fake AirPods Pro 2 2023-01-06 16:02:25 +01:00
darken 1d03335903 Improve dual pod widget layout, use same style as notification. 2023-01-02 18:26:33 +01:00
darken b1b541014f Release: 2.5.1-rc0 2022-12-24 11:57:34 +01:00
darken b40a7e435f Update translations 2022-12-24 11:56:46 +01:00
Matthias Urhahn 8305fb4c5f Airpod pro2 icon iteration (#64)
* Update airpods pro icons to SVG variant

* Fix AirPodsPro2 not using the new icons

* Revert "Update airpods pro icons to SVG variant"

This reverts commit d7ca13d439.

* Add padding to icons
2022-12-24 11:48:32 +01:00
darken 6c678be511 Release: 2.5.0-rc1 2022-12-23 09:54:19 +01:00
darken 2823663f09 Merge branch 'main' of github.com:d4rken-org/capod into main 2022-12-23 09:53:08 +01:00
darken 033c2264f8 Release: 2.5.0-rc0 2022-12-23 09:52:59 +01:00
darken 8eb6ce19f7 Add support for specific left/right/case icons.
Add AirPods Pro 2 Icons by @christoschronopoulos
2022-12-23 09:52:17 +01:00
darken 1cfc4e6f65 Merge branch 'improve_fake_airpod_support' into main 2022-12-23 08:27:00 +01:00
darken 454d76e305 Release: 2.4.0-rc0 2022-12-19 17:10:30 +01:00
darken f3609fc3a5 Update translations 2022-12-19 17:09:55 +01:00
darken 16da51d095 Refactor fake airpods code and improve tests 2022-12-19 17:09:55 +01:00
darken 51fb4c7a29 Update translations 2022-12-19 17:03:17 +01:00
darken 0b27cb55dc Refactor fake airpods code and improve tests 2022-12-19 17:03:17 +01:00
darken 9e836eef69 Add support for fake AirPods Gen3 2022-12-19 16:32:09 +01:00
darken 07be4b8258 Release: 2.3.1-rc0 2022-11-07 22:51:12 +01:00
darken 217087c6d4 Clean up tests 2022-11-07 22:50:52 +01:00
Matthias Urhahn 2315a18c86 Improve history caching for AirPods. (#55)
Don't throw away the last history item that contains case infos.
2022-11-07 22:28:13 +01:00
Matthias Urhahn f59e9d2dc5 Fix comparison that determines which list item is the primary device. (#54) 2022-11-07 19:42:31 +01:00
Matthias Urhahn 1da220085c Merge pull request #53 from d4rken-org/improve_automatic_monitor_mode
Improve automatic monitor mode
2022-11-07 19:36:30 +01:00
Matthias Urhahn 962b2a01f1 Merge branch 'main' into improve_automatic_monitor_mode 2022-11-07 19:35:49 +01:00
darken eeda14f424 When in MonitorMode.AUTOMATIC and there is no mainDeviceAddress set, then just keep running while there is any connected headset device. 2022-11-07 19:25:31 +01:00
darken 6269e20085 When selecting automatic monitor-mode, check if the user has selected a device address, if not pop up the selection dialog. 2022-11-07 19:08:52 +01:00
Matthias Urhahn 3d05296eb2 Merge pull request #51 from GitGitro/main
add monochrome icon
2022-11-07 18:36:48 +01:00
GitGitro e90ab95cd0 add monochrome icon, closes #50,closes #23 2022-11-07 15:31:31 +01:00
Matthias Urhahn 23ec2f025e Update README.md 2022-11-05 12:31:21 +01:00
112 changed files with 1470 additions and 383 deletions
+14 -3
View File
@@ -16,6 +16,7 @@ A companion app that adds support for AirPod specific features to Android:
* Automatically connect phone & AirPods.
* Show popup when case is opened.
* Support for Wear OS
* Widgets
CAPod is ad-free. Some additional features require an in-app purchase.
@@ -33,12 +34,22 @@ Currently supported models:
* Beats Studio 3
* Beats X
* Beats Flex
* Beats Fit Pro
* Fake AirPods 1. Generation 🎭
* Fake AirPods 2. Generation 🎭
* Fake AirPods 3. Generation 🎭
* Fake AirPods Pro1 🎭
* Fake AirPods Pro2 🎭
## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [GitHub](https://github.com/d4rken-org/capod/releases/latest)
* [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.darken.capod)
| Source | Status |
|------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
## Support the project
+1 -1
View File
@@ -1 +1 @@
2.3.0-rc0 20300000
2.6.1-rc0 20601000
@@ -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.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
@@ -79,6 +80,7 @@ class PodMonitor @Inject constructor(
val mainDevice: Flow<PodDevice?> = devices
.map { determineMainDevice(it) }
.setupCommonEventHandlers(TAG) { "mainDevice" }
.replayingShare(appScope)
private fun createBleScanner() = combine(
@@ -1,5 +1,8 @@
package eu.darken.capod.pods.core
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
interface DualPodDevice : PodDevice {
enum class Pod {
@@ -10,4 +13,12 @@ interface DualPodDevice : PodDevice {
val batteryLeftPodPercent: Float?
val batteryRightPodPercent: Float?
@get:DrawableRes
val leftPodIcon: Int
get() = R.drawable.devic_airpods_gen1_left
@get:DrawableRes
val rightPodIcon: Int
get() = R.drawable.devic_airpods_gen1_right
}
@@ -1,9 +1,15 @@
package eu.darken.capod.pods.core
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
interface HasCase {
val batteryCasePercent: Float?
val isCaseCharging: Boolean
@get:DrawableRes
val caseIcon: Int
get() = R.drawable.devic_airpods_gen1_case
}
@@ -60,31 +60,31 @@ interface PodDevice {
@JsonClass(generateAdapter = false)
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.ic_device_generic_earbuds,
@DrawableRes val iconRes: Int = R.drawable.devic_earbuds_generic_both,
) {
@Json(name = "airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.ic_device_airpods_gen1,
iconRes = R.drawable.devic_airpods_gen1_both,
),
@Json(name = "airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.ic_device_airpods_gen2,
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.ic_device_airpods_gen2,
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.ic_device_airpods_gen2
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.ic_device_airpods_gen2
R.drawable.devic_airpods_pro2_both
),
@Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.ic_device_generic_headphones
R.drawable.devic_headphones_generic
),
@Json(name = "beats.flex") BEATS_FLEX(
"Beats Flex"
@@ -104,11 +104,28 @@ interface PodDevice {
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro"
),
@Json(name = "fakes.tws.i99999") TWS_I99999(
"TWS i99999"
@Json(name = "beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro"
),
@Json(name = "fakes.varunr.airpodspro") VARUNR_AIRPODS_PRO(
"Fake AirPods Pro"
@Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen1_both,
),
@Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both,
),
@Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.devic_airpods_pro2_both,
),
@Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.devic_airpods_pro2_both,
),
@Json(name = "unknown") UNKNOWN(
"Unknown"
@@ -7,8 +7,7 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.misc.Twsi99999
import eu.darken.capod.pods.core.apple.misc.VarunrAirPodsPro
import eu.darken.capod.pods.core.apple.misc.*
@InstallIn(SingletonComponent::class)
@Module
@@ -28,8 +27,11 @@ abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakesTwsi999999(factory: Twsi99999.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet
abstract fun fakesVarunrAirPodsPro(factory: VarunrAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
}
@@ -2,6 +2,7 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
@@ -66,7 +67,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 10
const val MAX_HISTORY = 20
}
}
@@ -78,16 +79,32 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualAirPods): DualAirPods.LidState? {
val definitive = setOf(DualAirPods.LidState.OPEN, DualAirPods.LidState.CLOSED)
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>() // TODO why is this AirPodsPro specific here?
.lastOrNull { it.caseLidState != DualAirPods.LidState.NOT_IN_CASE }
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState
}
open fun historyTrimmer(
pods: List<ApplePods>
): List<ApplePods> {
var trimmed = pods.takeLast(KnownDevice.MAX_HISTORY)
// We want to keep the case information
// If both pods are removed, and produce more history, we otherwise lose the case battery info.
val caseInfoFat = pods.lastOrNull { it is HasCase && it.batteryCasePercent != null }
val caseInfoTrimmed = trimmed.lastOrNull { it is HasCase && it.batteryCasePercent != null }
if (caseInfoFat != null && caseInfoTrimmed == null) {
trimmed = listOf(caseInfoFat) + trimmed.takeLast(KnownDevice.MAX_HISTORY - 1)
}
return trimmed
}
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val message = current.proximityMessage
@@ -103,7 +120,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach {
knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY))
knownDevices[it.id] = it.copy(history = historyTrimmer(it.history))
}
var recognizedDevice: KnownDevice? = knownDevices.values
@@ -118,7 +135,7 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
}
if (recognizedDevice == null) {
log(tag) { "searchHistory1: Didn't recognize: $message" }
log(tag, WARN) { "searchHistory1: Didn't recognize: $message" }
}
return recognizedDevice
@@ -1,8 +1,5 @@
package eu.darken.capod.pods.core.apple
import android.content.Context
import androidx.annotation.StringRes
import eu.darken.capod.common.R
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
@@ -10,8 +7,8 @@ import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice.Pod
interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasStateDetection, HasDualMicrophone, HasAppleColor {
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasDualMicrophone, HasAppleColor {
val primaryPod: Pod
get() = when (rawStatus.isBitSet(5)) {
@@ -142,21 +139,4 @@ interface DualAirPods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEar
UNKNOWN(0xFF..0xFF);
}
override val state: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
IDLE(0x04, R.string.pods_connection_state_idle_label),
MUSIC(0x05, R.string.pods_connection_state_music_label),
CALL(0x06, R.string.pods_connection_state_call_label),
RINGING(0x07, R.string.pods_connection_state_ringing_label),
HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label),
UNKNOWN(null, R.string.pods_connection_state_unknown_label);
override fun getLabel(context: Context): String = context.getString(labelRes)
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
}
@@ -4,9 +4,9 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualAirPods>(tag) {
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
fun DualAirPods.getCaseMatchMarkings() = SplitPodsMarkings(
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
@@ -31,17 +31,17 @@ abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<
val model: PodDevice.Model,
)
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualAirPods): Collection<KnownDevice> {
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings()
return filter { known ->
known.history
.filterIsInstance<DualAirPods>()
.filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target }
}
}
override fun searchHistory(current: DualAirPods): KnownDevice? {
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,15 +20,15 @@ data class AirPodsGen1 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,19 +20,19 @@ data class AirPodsGen2 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,18 +20,19 @@ data class AirPodsGen3 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -21,12 +21,12 @@ data class AirPodsMax(
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods, HasEarDetection {
) : SingleApplePods, HasEarDetection, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<SingleApplePods>.rssi
val isHeadphonesBeingWorn: Boolean
get() = rawStatus.isBitSet(1)
@@ -1,11 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -22,10 +24,26 @@ data class AirPodsPro(
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualAirPods {
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -33,7 +51,7 @@ data class AirPodsPro(
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -1,11 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -22,10 +24,26 @@ data class AirPodsPro2(
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualAirPods {
) : DualApplePods, HasStateDetectionAirPods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO2
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -33,7 +51,7 @@ data class AirPodsPro2(
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
@@ -0,0 +1,27 @@
package eu.darken.capod.pods.core.apple.airpods
import android.content.Context
import androidx.annotation.StringRes
import eu.darken.capod.common.R
import eu.darken.capod.pods.core.HasStateDetection
import eu.darken.capod.pods.core.apple.ApplePods
interface HasStateDetectionAirPods : HasStateDetection, ApplePods {
override val state: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) : HasStateDetection.State {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
IDLE(0x04, R.string.pods_connection_state_idle_label),
MUSIC(0x05, R.string.pods_connection_state_music_label),
CALL(0x06, R.string.pods_connection_state_call_label),
RINGING(0x07, R.string.pods_connection_state_ringing_label),
HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label),
UNKNOWN(null, R.string.pods_connection_state_unknown_label);
override fun getLabel(context: Context): String = context.getString(labelRes)
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
}
@@ -0,0 +1,70 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsFitPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FIT_PRO
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = BeatsFitPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
private val DEVICE_CODE = 0x1220.toUShort()
private val TAG = logTag("PodDevice", "Beats", "Fit", "Pro")
}
}
@@ -4,7 +4,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
@@ -20,15 +20,15 @@ data class PowerBeatsPro(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualAirPods.LidState? = null
) : DualAirPods {
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
override val caseLidState: DualAirPods.LidState
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
@@ -6,10 +6,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
@@ -19,8 +16,9 @@ import javax.inject.Inject
/**
* Basically an AirPods GEN1 clone
* Similar data structure but a lot of placeholder values or hardcoded values
* Marketed as TWS i99999
*/
data class Twsi99999 constructor(
data class FakeAirPodsGen1 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
@@ -30,9 +28,9 @@ data class Twsi99999 constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.TWS_I99999
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN1
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
@@ -78,22 +76,32 @@ data class Twsi99999 constructor(
}
}
val isThisPodInThecase: Boolean
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
/**
* The data flip bit is set if the left pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
/**
* The data flip bit is UNset if the right pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
@@ -109,7 +117,7 @@ data class Twsi99999 constructor(
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<Twsi99999>(TAG) {
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen1>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -117,7 +125,7 @@ data class Twsi99999 constructor(
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = Twsi99999(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsGen1(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -139,6 +147,6 @@ data class Twsi99999 constructor(
companion object {
private val DEVICE_CODE = 0x0220.toUShort()
private val TAG = logTag("PodDevice", "Apple", "TWS", "i99999")
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen1")
}
}
@@ -6,10 +6,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
@@ -17,10 +14,9 @@ import java.time.Instant
import javax.inject.Inject
/**
* AirPods Pro clone similar to Twsi999999.
* Shorter data structure.
* Similar data structure but a lot of placeholder values or hardcoded values
*/
data class VarunrAirPodsPro constructor(
data class FakeAirPodsGen2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
@@ -30,9 +26,9 @@ data class VarunrAirPodsPro constructor(
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasDualMicrophone, HasCase {
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.VARUNR_AIRPODS_PRO
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN2
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
@@ -78,22 +74,32 @@ data class VarunrAirPodsPro constructor(
}
}
val isThisPodInThecase: Boolean
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
/**
* The data flip bit is set if the left pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isLeftPodMicrophone: Boolean
get() = rawStatus.isBitSet(5) xor isThisPodInThecase
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
/**
* The data flip bit is UNset if the right pod is primary.
* For the pod that is in the case, this is flipped again though.
*/
override val isRightPodMicrophone: Boolean
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
@@ -109,7 +115,7 @@ data class VarunrAirPodsPro constructor(
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<VarunrAirPodsPro>(TAG) {
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen2>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -117,7 +123,7 @@ data class VarunrAirPodsPro constructor(
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = VarunrAirPodsPro(scanResult = scanResult, proximityMessage = message)
var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -138,7 +144,7 @@ data class VarunrAirPodsPro constructor(
}
companion object {
private val DEVICE_CODE = 0x0E20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Varunr", "AirPodsPro")
private val DEVICE_CODE = 0x0F20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen2")
}
}
@@ -0,0 +1,153 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
/**
* AirPods Gen3 clone
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/1054404630118924308
*/
data class FakeAirPodsGen3 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN3
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen3>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsGen3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x1320.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen3")
}
}
@@ -0,0 +1,170 @@
package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
/**
* AirPods Pro clone similar to Twsi999999.
* Shorter data structure.
* https://discord.com/channels/548521543039189022/927235844127993866/962068944196358234
*/
data class FakeAirPodsPro constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
) : ApplePods, DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: super<ApplePods>.rssi
/**
* Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone)
* If the right pod is the primary, the values are flipped.
*/
val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x0E20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Pro")
}
}
@@ -0,0 +1,168 @@
package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.common.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
/**
* AirPods Pro2 clone
* https://discord.com/channels/548521543039189022/927235844127993866/1060911213539774504
*/
data class FakeAirPodsPro2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null,
) : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase {
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_PRO2
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: scanResult.rssi
private val areValuesFlipped: Boolean
get() = !rawStatus.isBitSet(5)
override val batteryLeftPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
private val isThisPodInThecase: Boolean
get() = rawStatus.isBitSet(6)
override val isLeftPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(3)
false -> rawStatus.isBitSet(1)
}
override val isRightPodInEar: Boolean
get() = when (areValuesFlipped xor isThisPodInThecase) {
true -> rawStatus.isBitSet(1)
false -> rawStatus.isBitSet(3)
}
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> cachedBatteryPercentage
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro2>(TAG) {
override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = FakeAirPodsPro2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenLastAt = scanResult.receivedAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
companion object {
private val DEVICE_CODE = 0x1420.toUShort()
private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Pro2")
}
}
@@ -10,7 +10,7 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@@ -69,7 +69,7 @@ class AutoConnect @Inject constructor(
val conditionFulfilled = when (condition) {
AutoConnectCondition.WHEN_SEEN -> true
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
is DualAirPods -> mainDevice.caseLidState == DualAirPods.LidState.OPEN
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
else -> true
}
AutoConnectCondition.IN_EAR -> when (mainDevice) {
@@ -3,6 +3,7 @@ package eu.darken.capod.reaction.core.playpause
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -40,46 +41,51 @@ class PlayPause @Inject constructor(
}
.distinctUntilChanged()
.withPrevious()
.filter { (previous, current) ->
if (previous == null || current == null) return@filter false
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
val match = previous.identifier == current.identifier
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
match
}
.onEach { (previous, current) ->
if (previous is HasEarDetection && current is HasEarDetection) {
log(TAG, VERBOSE) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
if (previous.identifier != current.identifier) {
return@onEach
}
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
if (previous is HasEarDetectionDual && current is HasEarDetectionDual && reactionSettings.onePodMode.value) {
log(TAG) { "Ear status changed for dual pod device in single pod mode." }
val previousWorn = previous.isEitherPodInEar
val currentWorn = current.isEitherPodInEar
if (!previousWorn && currentWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
mediaControl.sendPlay()
} else {
log(TAG) { "autoPlay is disabled" }
}
} else if (!currentWorn && previousWorn && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
}
} else if (previous.isBeingWorn != current.isBeingWorn) {
log(TAG) { "Ear status changed for monitored device." }
if (current.isBeingWorn && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
mediaControl.sendPlay()
} else {
log(TAG) { "autoPlay is disabled" }
}
} else if (!current.isBeingWorn && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
}
val previousWorn: Boolean?
val currentWorn: Boolean?
when {
reactionSettings.onePodMode.value && previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
previousWorn = previous.isEitherPodInEar
currentWorn = current.isEitherPodInEar
log(TAG, VERBOSE) { "previous: left=${previous.isLeftPodInEar}, right=${previous.isRightPodInEar}" }
log(TAG, VERBOSE) { "current: left${current.isLeftPodInEar}, right=${current.isRightPodInEar}" }
}
previous is HasEarDetection && current is HasEarDetection -> {
previousWorn = previous.isBeingWorn
currentWorn = current.isBeingWorn
log(TAG, VERBOSE) { "prev.isBeingWorn=${previousWorn}, cur.isBeingWorn=${currentWorn}" }
}
else -> {
log(TAG, VERBOSE) { "Current devices don't support ear detection." }
previousWorn = null
currentWorn = null
}
}
if (previousWorn == false && currentWorn == true && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
log(TAG) { "autoPlay is triggered, sendPlay()" }
mediaControl.sendPlay()
} else {
log(TAG, VERBOSE) { "autoPlay is disabled" }
}
} else if (previousWorn == true && currentWorn == false && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
log(TAG) { "autoPause is triggered, sendPause()" }
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
}
}
@@ -7,7 +7,7 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.*
import java.time.Duration
@@ -34,7 +34,7 @@ class PopUpReaction @Inject constructor(
.withPrevious()
.setupCommonEventHandlers(TAG) { "monitor" }
.mapNotNull { (previous, current) ->
if (previous !is DualAirPods? || current !is DualAirPods) {
if (previous !is DualApplePods? || current !is DualApplePods) {
return@mapNotNull null
}
log(TAG, VERBOSE) {
@@ -57,8 +57,8 @@ class PopUpReaction @Inject constructor(
tryPopWindow(current)
}
private suspend fun tryPopWindow(current: DualAirPods): Event? = when {
current.caseLidState == DualAirPods.LidState.OPEN -> {
private suspend fun tryPopWindow(current: DualApplePods): Event? = when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
val now = Instant.now()
@@ -74,9 +74,9 @@ class PopUpReaction @Inject constructor(
null
}
}
current.caseLidState != DualAirPods.LidState.OPEN -> {
current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) {
DualAirPods.LidState.CLOSED -> {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
coolDowns.remove(current.identifier)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -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"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name_pro">CAPod Pro</string>
<string name="general_value_not_available_label">Δε διατίθεται</string>
<string name="general_error_label">Σφάλμα</string>
<string name="general_grant_permission_action">Παραχώρηση άδειας</string>
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="general_error_label">Erro</string>
<string name="general_grant_permission_action">Conceder permissão</string>
<string name="overview_nomaindevice_label">Nenhum dispositivo principal</string>
@@ -10,6 +12,7 @@
<string name="permission_bluetooth_connect_description">Este aplicativo requer a permissão \"Conexão Bluetooth\" para interagir com dispositivos emparelhados e iniciar conexões.</string>
<string name="permission_bluetooth_scan_label">Escaneando bluetooth</string>
<string name="permission_bluetooth_scan_description">A permissão \"Escaneamento de Bluetooth\" permite que este aplicativo descubra e receba dados de Bluetooth de dispositivos próximos, como seus AirPods.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Este aplicativo requer a permissão \"Bluetooth\" para se conectar a dispositivos bluetooth emparelhados.</string>
<string name="permission_access_fine_location_label">Acesse a boa localização</string>
<string name="permission_access_fine_location_description">O CAPod usa a permissão \"Boa localização\" para receber dados de Bluetooth Low Energy. Seus fones de ouvido usam a tecnologia Bluetooth Low Energy para transmitir seu status. Este aplicativo NÃO usará dados Bluetooth para determinar sua localização.</string>
@@ -5,7 +5,7 @@ import com.squareup.moshi.Moshi
import eu.darken.capod.main.core.MonitorMode
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.json.toComparableJson
@@ -25,7 +25,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
)
@Test
fun `reading and writing using manual reader and writer`() = runBlockingTest {
fun `reading and writing using manual reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
@@ -67,7 +67,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
}
@Test
fun `reading and writing using autocreated reader and writer`() = runBlockingTest {
fun `reading and writing using autocreated reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
@@ -109,7 +109,7 @@ class FlowPreferenceMoshiTest : BaseTest() {
}
@Test
fun `enum serialization`() = runBlockingTest {
fun `enum serialization`() = runTest {
val moshi = Moshi.Builder().build()
val monitorMode = mockPreferences.createFlowPreference(
"core.monitor.mode",
@@ -2,7 +2,7 @@ package eu.darken.capod.common.preferences
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.preferences.MockSharedPreferences
@@ -12,7 +12,7 @@ class FlowPreferenceTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@Test
fun `reading and writing strings`() = runBlockingTest {
fun `reading and writing strings`() = runTest {
mockPreferences.createFlowPreference<String?>(
key = "testKey",
defaultValue = "default"
@@ -41,7 +41,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing boolean`() = runBlockingTest {
fun `reading and writing boolean`() = runTest {
mockPreferences.createFlowPreference<Boolean?>(
key = "testKey",
defaultValue = true
@@ -70,7 +70,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing long`() = runBlockingTest {
fun `reading and writing long`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 9000L
@@ -99,7 +99,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing integer`() = runBlockingTest {
fun `reading and writing integer`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 123
@@ -128,7 +128,7 @@ class FlowPreferenceTest : BaseTest() {
}
@Test
fun `reading and writing float`() = runBlockingTest {
fun `reading and writing float`() = runTest {
mockPreferences.createFlowPreference<Float?>(
key = "testKey",
defaultValue = 3.6f
@@ -7,26 +7,27 @@ import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsFactoryTest : BaseAirPodsTest() {
@Test
fun `create AirPodsGen1`() = runBlockingTest {
create<DualAirPods>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
fun `create AirPodsGen1`() = runTest {
create<DualApplePods>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
this shouldBe instanceOf<AirPodsGen1>()
}
}
@Test
fun `create AirPodsPro`() = runBlockingTest {
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
fun `create AirPodsPro`() = runTest {
create<DualApplePods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
this shouldBe instanceOf<AirPodsPro>()
}
}
@Test
fun `unknown AppleDevice`() = runBlockingTest {
fun `unknown AppleDevice`() = runTest {
create<ApplePods>("07 19 01 FF FF 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
this shouldBe instanceOf<UnknownAppleDevice>()
}
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BasicSingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `test mapping`() = runBlockingTest {
fun `test mapping`() = runTest {
create<SingleApplePods>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
@@ -22,7 +22,7 @@ class BasicSingleApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test battery headset percent`() = runBlockingTest {
fun `test battery headset percent`() = runTest {
create<SingleApplePods>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
batteryHeadsetPercent shouldBe 0.5f
}
@@ -1,14 +1,15 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class DualApplePodsTest : BaseAirPodsTest() {
@Test
fun `test bit mapping`() = runBlockingTest {
create<DualAirPods>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
fun `test bit mapping`() = runTest {
create<DualApplePods>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0e20.toUShort()
@@ -23,14 +24,14 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - active microphone`() = runBlockingTest {
create<DualAirPods>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
fun `test AirPodDevice - active microphone`() = runTest {
create<DualApplePods>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101011
// --^-----
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
}
create<DualAirPods>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001011
// --^-----
isLeftPodMicrophone shouldBe false
@@ -39,26 +40,26 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - left pod ear status`() = runBlockingTest {
fun `test AirPodDevice - left pod ear status`() = runTest {
// Left Pod primary
create<DualAirPods>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100010
// 765432¹0
isLeftPodInEar shouldBe true
}
create<DualAirPods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100000
// 765432¹0
isLeftPodInEar shouldBe false
}
// Right Pod is primary
create<DualAirPods>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001001
// 7654³210
isLeftPodInEar shouldBe true
}
create<DualAirPods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001
// 7654³210
isLeftPodInEar shouldBe false
@@ -66,26 +67,26 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - right pod ear status`() = runBlockingTest {
fun `test AirPodDevice - right pod ear status`() = runTest {
// Left Pod primary
create<DualAirPods>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101001
// 7654³210
isRightPodInEar shouldBe true
}
create<DualAirPods>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100001
// 7654³210
isRightPodInEar shouldBe false
}
// Right Pod is primary
create<DualAirPods>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000011
// 765432¹0
isRightPodInEar shouldBe true
}
create<DualAirPods>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001
// 765432¹0
isRightPodInEar shouldBe false
@@ -93,15 +94,15 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - battery status`() = runBlockingTest {
fun `test AirPodDevice - battery status`() = runTest {
// Right Pod is primary
create<DualAirPods>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
create<DualApplePods>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// 88 10001000
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
}
// Left Pod primary
create<DualAirPods>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
create<DualApplePods>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// F8 11111000
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
@@ -109,18 +110,18 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - pod charging`() = runBlockingTest {
fun `test AirPodDevice - pod charging`() = runTest {
/**
* Right pod is charging
*/
// This is the left
create<DualAirPods>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") {
create<DualApplePods>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") {
// 1001 0100
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
}
// This is the right
create<DualAirPods>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") {
create<DualApplePods>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") {
// 1010 0100
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
@@ -130,26 +131,26 @@ class DualApplePodsTest : BaseAirPodsTest() {
* Left pod is charging
*/
// This is the left
create<DualAirPods>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") {
create<DualApplePods>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") {
// 1001 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// This is the right
create<DualAirPods>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") {
create<DualApplePods>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") {
// 1010 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// Both charging
create<DualAirPods>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") {
create<DualApplePods>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") {
// 1011 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
}
// Both not charging
create<DualAirPods>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") {
create<DualApplePods>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") {
// 1000 1111
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
@@ -157,89 +158,89 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - case charging`() = runBlockingTest {
create<DualAirPods>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
fun `test AirPodDevice - case charging`() = runTest {
create<DualApplePods>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
// 0011 0011
isCaseCharging shouldBe false
}
create<DualAirPods>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") {
create<DualApplePods>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") {
// 0101 0011
isCaseCharging shouldBe true
}
}
@Test
fun `test AirPodDevice - case lid test`() = runBlockingTest {
fun `test AirPodDevice - case lid test`() = runTest {
// Lid open
create<DualAirPods>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") {
create<DualApplePods>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") {
// 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
}
// Lid open, left pod in case
create<DualAirPods>("07 19 01 0E 20 51 9A 93 >31< 00 00 95 D0 A5 D7 E3 F4 F1 38 38 99 61 3B 57 95 37 B7") {
create<DualApplePods>("07 19 01 0E 20 51 9A 93 >31< 00 00 95 D0 A5 D7 E3 F4 F1 38 38 99 61 3B 57 95 37 B7") {
// 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
}
// Lid open, left pod in case
create<DualAirPods>("07 19 01 0E 20 71 A9 92 31 00 00 DB 48 7F 32 8C CE 80 6F D9 27 98 D6 76 45 9D 62") {
create<DualApplePods>("07 19 01 0E 20 71 A9 92 31 00 00 DB 48 7F 32 8C CE 80 6F D9 27 98 D6 76 45 9D 62") {
// 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
}
// Lid just closed
create<DualAirPods>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
create<DualApplePods>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
// 39 0011 1001
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
// Lid closed
create<DualAirPods>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
create<DualApplePods>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
// 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
// Lid closed, right pod in case
create<DualAirPods>("07 19 01 0E 20 51 9A 93 >38< 00 00 3A D8 85 76 B0 91 48 31 DA FF 6C 4A 2B C2 67 F4") {
create<DualApplePods>("07 19 01 0E 20 51 9A 93 >38< 00 00 3A D8 85 76 B0 91 48 31 DA FF 6C 4A 2B C2 67 F4") {
// 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
// Lid closed, left pod in case
create<DualAirPods>("07 19 01 0E 20 71 A9 92 38 00 00 44 91 C4 8B 85 98 DD 55 4E 6A CA BC B5 CA 8D 37") {
create<DualApplePods>("07 19 01 0E 20 71 A9 92 38 00 00 44 91 C4 8B 85 98 DD 55 4E 6A CA BC B5 CA 8D 37") {
// 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
}
@Test
fun `test AirPodDevice - connection state`() = runBlockingTest {
fun `test AirPodDevice - connection state`() = runTest {
// Disconnected
create<DualAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") {
create<HasStateDetectionAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") {
// 31 0011 0001
state shouldBe DualAirPods.ConnectionState.DISCONNECTED
state shouldBe HasStateDetectionAirPods.ConnectionState.DISCONNECTED
}
// Connected idle
create<DualAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") {
create<HasStateDetectionAirPods>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") {
// 39 0011 1001
state shouldBe DualAirPods.ConnectionState.IDLE
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
}
// Connected and playing music
create<DualAirPods>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") {
create<HasStateDetectionAirPods>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") {
// 38 0011 1000
state shouldBe DualAirPods.ConnectionState.MUSIC
state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
}
// Connected and call active
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") {
create<HasStateDetectionAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") {
// 38 0011 1000
state shouldBe DualAirPods.ConnectionState.CALL
state shouldBe HasStateDetectionAirPods.ConnectionState.CALL
}
// Connected and call active
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") {
create<HasStateDetectionAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") {
// 38 0011 1000
state shouldBe DualAirPods.ConnectionState.RINGING
state shouldBe HasStateDetectionAirPods.ConnectionState.RINGING
}
// Switching?
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
create<HasStateDetectionAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
// 38 0011 1000
state shouldBe DualAirPods.ConnectionState.HANGING_UP
state shouldBe HasStateDetectionAirPods.ConnectionState.HANGING_UP
}
}
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class SingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `default bit mapping Max`() = runBlockingTest {
fun `default bit mapping Max`() = runTest {
create<SingleApplePods>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -23,7 +23,7 @@ class SingleApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test values based on AirPodMax`() = runBlockingTest {
fun `test values based on AirPodMax`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
batteryHeadsetPercent shouldBe 0.5f
@@ -1,17 +1,18 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen1Test : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
@Test
fun `fake airpods`() = runBlockingTest {
fun `fake airpods`() = runTest {
create<AirPodsGen1>("07 19 01 02 20 55 AF 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
@@ -34,11 +35,11 @@ class AirPodsGen1Test : BaseAirPodsTest() {
isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.6f
caseLidState shouldBe DualAirPods.LidState.OPEN
state shouldBe DualAirPods.ConnectionState.DISCONNECTED
caseLidState shouldBe DualApplePods.LidState.OPEN
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN1
}
}
}
@@ -1,16 +1,17 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `random Neighbor AirPodsGen2`() = runBlockingTest {
fun `random Neighbor AirPodsGen2`() = runTest {
create<AirPodsGen2>("07 19 01 0F 20 02 F9 8F 01 00 05 F2 7E 14 E0 54 0A 53 69 5B 7D F2 15 1F D7 B1 12") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0F20.toUShort()
@@ -33,11 +34,13 @@ class AirPodsGen2Test : BaseAirPodsTest() {
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null
caseLidState shouldBe DualAirPods.LidState.NOT_IN_CASE
caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
state shouldBe DualAirPods.ConnectionState.MUSIC
state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN2
}
}
}
@@ -1,16 +1,17 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `AirPods Gen3`() = runBlockingTest {
fun `AirPods Gen3`() = runTest {
create<AirPodsGen3>("07 19 01 13 20 75 AA B9 31 00 04 67 9A 57 DF BE F6 90 52 B0 04 1F 8D 89 DA F4 9E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1320.toUShort()
@@ -33,16 +34,18 @@ class AirPodsGen3Test : BaseAirPodsTest() {
isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.9f
caseLidState shouldBe DualAirPods.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
state shouldBe DualAirPods.ConnectionState.IDLE
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN3
}
}
@Test
fun `random guy at bus stop`() = runBlockingTest {
fun `random guy at bus stop`() = runTest {
create<AirPodsGen3>("07 19 01 13 20 2B 88 8F 01 00 08 E2 0E 84 37 C5 98 16 D4 B7 37 ED 23 8B 08 EA A1") {
batteryLeftPodPercent shouldBe 0.8f
batteryRightPodPercent shouldBe 0.8f
@@ -55,9 +58,9 @@ class AirPodsGen3Test : BaseAirPodsTest() {
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null
caseLidState shouldBe DualAirPods.LidState.NOT_IN_CASE
caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
state shouldBe DualAirPods.ConnectionState.UNKNOWN
state shouldBe HasStateDetectionAirPods.ConnectionState.UNKNOWN
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsMaxTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max`() = runBlockingTest {
fun `default AirPods Max`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -26,12 +27,14 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
model shouldBe PodDevice.Model.AIRPODS_MAX
}
}
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max flipped values`() = runBlockingTest {
fun `default AirPods Max flipped values`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
@@ -1,9 +1,10 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsPro2Test : BaseAirPodsTest() {
@@ -12,7 +13,7 @@ class AirPodsPro2Test : BaseAirPodsTest() {
* https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084
*/
@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") {
rawPrefix shouldBe 0x01.toUByte()
@@ -40,6 +41,8 @@ class AirPodsPro2Test : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.9f
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
*/
@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") {
rawPrefix shouldBe 0x01.toUByte()
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsProTest : BaseAirPodsTest() {
@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") {
rawPrefix shouldBe 0x01.toUByte()
@@ -34,11 +35,13 @@ class AirPodsProTest : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.5f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO
}
}
@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") {
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
@@ -55,7 +58,7 @@ class AirPodsProTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/34#issuecomment-565894487
@Test
fun `various AirPods Pro messages`() = runBlockingTest {
fun `various AirPods Pro messages`() = runTest {
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
batteryLeftPodPercent 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
fun `fake airpods`() = runBlockingTest {
fun `fake airpods`() = runTest {
create<AirPodsGen1>("071901022055AA563100006FE4DF10AF106081033B76D9C7112288") {
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
@@ -170,4 +173,19 @@ class AirPodsProTest : BaseAirPodsTest() {
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
}
}
}
@@ -0,0 +1,43 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsFitProTest : BaseAirPodsTest() {
/**
* From https://github.com/d4rken-org/capod/issues/33#issuecomment-1256235651
*/
@Test
fun `test basics`() = runTest {
create<BeatsFitPro>("07 19 01 12 20 20 FA 8F 01 11 24 9B 9B 23 52 60 5A 8C 32 1C A5 C2 81 51 82 AF C8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1220.toUShort()
rawStatus shouldBe 0x20.toUByte()
rawPodsBattery shouldBe 0xFA.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x11.toUByte()
rawSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe null
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.UNKNOWN.name
model shouldBe PodDevice.Model.BEATS_FIT_PRO
}
}
}
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsFlexText : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@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") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1020.toUShort()
@@ -22,11 +23,13 @@ class BeatsFlexText : BaseAirPodsTest() {
rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_FLEX
}
}
@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") {
batteryHeadsetPercent shouldBe 0.6f
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsSolo3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@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") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0620.toUShort()
@@ -22,6 +23,8 @@ class BeatsSolo3Test : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_SOLO_3
}
}
}
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsStudio3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@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") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0920.toUShort()
@@ -22,6 +23,8 @@ class BeatsStudio3Test : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_STUDIO_3
}
}
}
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsXTest : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@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") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
@@ -22,6 +23,8 @@ class BeatsXTest : BaseAirPodsTest() {
rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.5f
model shouldBe PodDevice.Model.BEATS_X
}
}
}
@@ -1,15 +1,16 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeats3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@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") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0320.toUShort()
@@ -22,6 +23,8 @@ class PowerBeats3Test : BaseAirPodsTest() {
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.POWERBEATS_3
}
}
}
@@ -1,16 +1,17 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeatsProTest : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@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") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0B20.toUShort()
@@ -34,6 +35,8 @@ class PowerBeatsProTest : BaseAirPodsTest() {
batteryCasePercent shouldBe 0.5f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.POWERBEATS_PRO
}
}
}
@@ -0,0 +1,40 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen1Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runTest {
create<FakeAirPodsGen2>("07 13 01 0F 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0F20.toUShort()
rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x32.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN2
}
}
}
@@ -1,15 +1,16 @@
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.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class Twsi99999Test : BaseAirPodsTest() {
class FakeAirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runBlockingTest {
create<Twsi99999>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
fun `charging in box`() = runTest {
create<FakeAirPodsGen1>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
rawStatus shouldBe 0x71.toUByte()
@@ -23,9 +24,17 @@ class Twsi99999Test : BaseAirPodsTest() {
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN1
}
}
}
@@ -0,0 +1,39 @@
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
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isCaseCharging shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN3
}
}
}
@@ -0,0 +1,61 @@
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 FakeAirPodsPro2Test : BaseAirPodsTest() {
@Test
fun `guessed data`() = runTest {
create<FakeAirPodsPro2>("07 13 01 14 20 75 AA 58 35 00 10 00 E4 E4 26 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x5.toUShort()
rawCaseBattery shouldBe 0x8.toUShort()
rawCaseLidState shouldBe 0x35.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe true
batteryCasePercent shouldBe 0.8f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO2
}
}
/**
* https://discord.com/channels/548521543039189022/927235844127993866/1063027552765087754
*/
@Test
fun `user supplied`() = runTest {
create<FakeAirPodsPro2>("07 13 01 14 20 75 AA 72 39 00 00 6F E4 E4 93 30 00 30 30 30 30") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1420.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x7.toUShort()
rawCaseBattery shouldBe 0x2.toUShort()
rawCaseLidState shouldBe 0x39.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x0.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe true
batteryCasePercent shouldBe 0.2f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO2
}
}
}
@@ -1,15 +1,16 @@
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.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class VarunrAirPodsProTest : BaseAirPodsTest() {
class FakeAirPodsProTest : BaseAirPodsTest() {
@Test
fun `guessed data`() = runBlockingTest {
create<VarunrAirPodsPro>("07 13 01 0E 20 71 AA 37 36 00 10 00 FF 64 FF 00 00 00 00 00 00") {
fun `guessed data`() = runTest {
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()
rawDeviceModel shouldBe 0x0E20.toUShort()
rawStatus shouldBe 0x71.toUByte()
@@ -23,9 +24,17 @@ class VarunrAirPodsProTest : BaseAirPodsTest() {
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO
}
}
}
@@ -8,7 +8,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsDualItemBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import java.time.Instant
class DualPodsCardVH(parent: ViewGroup) :
@@ -32,11 +32,13 @@ class DualPodsCardVH(parent: ViewGroup) :
if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(null, Typeface.NORMAL)
if (device is DualAirPods && item.showDebug) {
if (device is DualApplePods && item.showDebug) {
append(" [${device.primaryPod.name}]")
}
}
deviceIcon.setImageResource(device.iconRes)
podLeftIcon.setImageResource(device.leftPodIcon)
podRightIcon.setImageResource(device.rightPodIcon)
lastSeen.text = device.lastSeenFormatted(item.now)
@@ -91,6 +93,7 @@ class DualPodsCardVH(parent: ViewGroup) :
// Case charge state
device.apply {
if (this is HasCase) {
podCaseIcon.setImageResource(caseIcon)
podCaseBatteryIcon.isGone = false
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
@@ -16,7 +16,7 @@
app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="2dp"
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
android:id="@+id/name"
@@ -71,7 +71,7 @@
style="@style/PodInfoItemIcon"
android:layout_marginHorizontal="2dp"
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_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
@@ -145,7 +145,7 @@
<ImageView
android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon"
android:src="@drawable/ic_airpod_case_24"
android:src="@drawable/devic_airpods_gen1_case"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -198,7 +198,7 @@
style="@style/PodInfoItemIcon"
android:layout_marginHorizontal="2dp"
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_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@+id/pod_right_wear_icon"
@@ -15,7 +15,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="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
android:id="@+id/name"
+4
View File
@@ -4,6 +4,10 @@
<style name="PodInfoItemIcon" parent="TextAppearance.Material3.BodySmall">
<item name="android:layout_height">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 name="PodInfoItemText" parent="TextAppearance.Material3.BodyMedium">
-1
View File
@@ -38,7 +38,6 @@
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
@@ -130,24 +130,25 @@ class OverviewFragmentVM @Inject constructor(
if (permissions.isEmpty() && isBluetoothEnabled) {
pods.map {
val now = Instant.now()
val isMainPod = it.identifier == mainPod?.identifier
when (it) {
is DualPodDevice -> DualPodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
isMainPod = isMainPod,
)
is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
isMainPod = isMainPod,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = it,
showDebug = isDebugMode,
isMainPod = it == mainPod,
isMainPod = isMainPod,
)
}
}.run { items.addAll(this) }
@@ -8,8 +8,8 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsDualItemBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualAirPods.LidState
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import java.time.Duration
import java.time.Instant
@@ -34,11 +34,13 @@ class DualPodsCardVH(parent: ViewGroup) :
if (item.isMainPod) setTypeface(null, Typeface.BOLD)
else setTypeface(null, Typeface.NORMAL)
if (device is DualAirPods && item.showDebug) {
if (device is DualApplePods && item.showDebug) {
append(" [${device.primaryPod.name}]")
}
}
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))
firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
@@ -109,6 +111,7 @@ class DualPodsCardVH(parent: ViewGroup) :
// Case charge state
device.apply {
if (this is HasCase) {
podCaseIcon.setImageResource(caseIcon)
podCaseBatteryIcon.isGone = false
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
@@ -126,7 +129,7 @@ class DualPodsCardVH(parent: ViewGroup) :
// Case lid state
device.apply {
if (this is DualAirPods) {
if (this is DualApplePods) {
podCaseLidLabel.text = when (caseLidState) {
LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
@@ -0,0 +1,5 @@
package eu.darken.capod.main.ui.settings.general
sealed class GeneralSettingsEvents {
object SelectDeviceAddressEvent : GeneralSettingsEvents()
}
@@ -61,6 +61,13 @@ class GeneralSettingsFragment : PreferenceFragment3() {
true
}
}
vm.events.observe2 {
when (it) {
GeneralSettingsEvents.SelectDeviceAddressEvent -> mainDeviceAddressPref.performClick()
}
}
mainDeviceModelPref.setOnPreferenceClickListener {
val dialog = ModelSelectionDialogFactory(requireContext()).create(
PodDevice.Model.values().toList(),
@@ -5,21 +5,41 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
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.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
@HiltViewModel
class GeneralSettingsFragmentVM @Inject constructor(
private val handle: SavedStateHandle,
private val dispatcherProvider: DispatcherProvider,
private val bluetoothManager: BluetoothManager2,
@Suppress("unused") private val handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
bluetoothManager: BluetoothManager2,
private val generalSettings: GeneralSettings,
) : ViewModel3(dispatcherProvider) {
val bondedDevices = bluetoothManager.bondedDevices()
.map { it.toList() }
.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 {
private val TAG = logTag("Settings", "General", "VM")
}
@@ -172,6 +172,7 @@ class WidgetProvider : AppWidgetProvider() {
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,
@@ -183,6 +184,7 @@ class WidgetProvider : AppWidgetProvider() {
)
// 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,
@@ -190,6 +192,7 @@ class WidgetProvider : AppWidgetProvider() {
)
// Right
setImageViewResource(R.id.pod_right_icon, podDevice.rightPodIcon)
setTextViewText(R.id.pod_right_label, podDevice.getBatteryLevelRightPod(context))
setViewVisibility(
R.id.pod_right_charging,
@@ -216,6 +219,7 @@ class WidgetProvider : AppWidgetProvider() {
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))
@@ -1,6 +1,7 @@
package eu.darken.capod.monitor.core.worker
import android.app.NotificationManager
import android.bluetooth.BluetoothDevice
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
@@ -114,21 +115,27 @@ class MonitorWorker @AssistedInject constructor(
}
.launchIn(workerScope)
generalSettings.monitorMode.flow
.flatMapLatest { monitorMode ->
val missingPermsFlow = permissionTool.missingPermissions.first()
permissionTool.missingPermissions
.flatMapLatest { missingPermsFlow ->
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing for $monitorMode: $missingPermsFlow" }
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
workerScope.coroutineContext.cancelChildren()
return@flatMapLatest emptyFlow()
}
bluetoothManager.connectedDevices().map { knownDevices ->
monitorMode to knownDevices
combine(
generalSettings.monitorMode.flow,
generalSettings.mainDeviceAddress.flow,
bluetoothManager.connectedDevices(),
) { monitorMode, mainAddress, connectedDevices ->
listOf(monitorMode, mainAddress, connectedDevices)
}
}
.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" }
when (monitorMode) {
MonitorMode.MANUAL -> flow<Unit> {
@@ -137,15 +144,20 @@ class MonitorWorker @AssistedInject constructor(
}
MonitorMode.ALWAYS -> emptyFlow()
MonitorMode.AUTOMATIC -> flow {
val mainAddress = generalSettings.mainDeviceAddress.value
if (devices.any { it.address == mainAddress }) {
log(TAG) { "MainDevice is connected ($mainAddress), aborting any timeout." }
} else {
log(TAG) { "No Pods are connected, canceling worker soon." }
delay(15 * 1000)
log(TAG) { "Canceling worker now, still no Pods connected." }
when {
mainAddress == null && devices.isNotEmpty() -> {
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
}
devices.any { it.address == mainAddress } -> {
log(TAG) { "MainDevice is connected ($mainAddress), aborting any timeout." }
}
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()
}
}
}
}
@@ -6,7 +6,7 @@ import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject
@@ -16,26 +16,29 @@ class MonitorNotificationViewFactory @Inject constructor(
) {
fun createContentView(device: PodDevice): RemoteViews = when (device) {
is DualAirPods -> createDualApplePods(device)
is DualApplePods -> createDualApplePods(device)
is SingleApplePods -> createSingleApplePods(device)
else -> createUnknownDevice(device)
}
private fun createDualApplePods(device: DualAirPods): RemoteViews = RemoteViews(
private fun createDualApplePods(device: DualApplePods): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_small
).apply {
device.apply {
// Left
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setTextViewText(R.id.pod_left_label, getBatteryLevelLeftPod(context))
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
// Case
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
setTextViewText(R.id.pod_case_label, getBatteryLevelCase(context))
setViewVisibility(R.id.pod_case_charging, if (isCaseCharging) View.VISIBLE else View.GONE)
// Right
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setTextViewText(R.id.pod_right_label, getBatteryLevelRightPod(context))
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
@@ -48,6 +51,7 @@ class MonitorNotificationViewFactory @Inject constructor(
).apply {
device.apply {
setTextViewText(R.id.headphones_label, getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, getBatteryLevelHeadset(context))
if (this is HasEarDetection) {
@@ -50,7 +50,7 @@ class MonitorNotifications @Inject constructor(
setChannelId(NOTIFICATION_CHANNEL_ID)
setContentIntent(openPi)
priority = NotificationCompat.PRIORITY_LOW
setSmallIcon(R.drawable.ic_device_generic_earbuds)
setSmallIcon(R.drawable.devic_earbuds_generic_both)
setOngoing(true)
setContentTitle(context.getString(R.string.app_name))
}
@@ -63,7 +63,7 @@ class MonitorNotifications @Inject constructor(
setStyle(NotificationCompat.BigTextStyle())
setContentTitle(context.getString(R.string.pods_none_label_short))
setSubText(context.getString(R.string.app_name))
setSmallIcon(R.drawable.ic_device_generic_earbuds)
setSmallIcon(R.drawable.devic_earbuds_generic_both)
}
}
@@ -12,7 +12,7 @@ import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import javax.inject.Inject
@@ -26,13 +26,13 @@ class PopUpPodViewFactory @Inject constructor(
private val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
fun createContentView(parent: ViewGroup, device: PodDevice): View = when (device) {
is DualAirPods -> createDualApplePods(parent, device)
is DualApplePods -> createDualApplePods(parent, device)
// Unused, has no case to trigger reaction?
is SingleApplePods -> createSingleApplePods(parent, device)
else -> throw IllegalArgumentException("Unexpected device: $device")
}
private fun createDualApplePods(parent: ViewGroup, device: DualAirPods): View =
private fun createDualApplePods(parent: ViewGroup, device: DualApplePods): View =
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
device.apply {
podIcon.setImageResource(iconRes)
@@ -41,14 +41,17 @@ class PopUpPodViewFactory @Inject constructor(
signal.isInvisible = debugSettings.isDebugModeEnabled.value
// Left
podLeftIcon.setImageResource(device.leftPodIcon)
podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
// Case
podCaseIcon.setImageResource(device.caseIcon)
podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
podCaseBatteryLabel.text = getBatteryLevelCase(context)
// Right
podRightIcon.setImageResource(device.rightPodIcon)
podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
podRightBatteryLabel.text = getBatteryLevelRightPod(context)
}
@@ -0,0 +1,40 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="28.575"
android:viewportHeight="28.575">
<path
android:pathData="M14.288,4.762A9.525,9.525 0,0 0,7.327 7.787A6.91,6.91 0,0 0,14.101 13.357A6.91,6.91 0,0 0,20.934 7.472A9.525,9.525 0,0 0,14.288 4.762zM11.876,7.789A1.08,1.08 0,0 1,12.956 8.869A1.08,1.08 0,0 1,11.876 9.949A1.08,1.08 0,0 1,10.797 8.869A1.08,1.08 0,0 1,11.876 7.789zM16.301,7.789A1.08,1.08 0,0 1,17.381 8.869A1.08,1.08 0,0 1,16.301 9.949A1.08,1.08 0,0 1,15.221 8.869A1.08,1.08 0,0 1,16.301 7.789z"
android:strokeLineJoin="round"
android:strokeWidth="0.147417"
android:fillColor="#ffffff"
android:strokeLineCap="round"/>
<path
android:pathData="M11.33,12.714L10.689,13.825A1.038,1.038 75,0 1,9.271 14.205L9.271,14.205A1.038,1.038 75,0 1,8.891 12.787L9.533,11.676A1.038,1.038 75,0 1,10.95 11.296L10.95,11.296A1.038,1.038 75,0 1,11.33 12.714z"
android:strokeLineJoin="round"
android:strokeWidth="0.119"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:strokeLineCap="round"/>
<path
android:pathData="M16.833,12.714L17.475,13.825A1.038,1.038 105,0 0,18.893 14.205L18.893,14.205A1.038,1.038 105,0 0,19.273 12.787L18.631,11.676A1.038,1.038 105,0 0,17.213 11.296L17.213,11.296A1.038,1.038 105,0 0,16.833 12.714z"
android:strokeLineJoin="round"
android:strokeWidth="0.119"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:strokeLineCap="round"/>
<path
android:pathData="M11.331,15.805C10.562,15.802 9.752,16.161 9.018,16.825L9.018,19.202C9.921,20.377 10.92,20.352 11.931,20.192L11.931,23.51A9.525,9.525 0,0 0,13.607 23.787L13.607,17.279C13.018,16.272 12.202,15.807 11.331,15.805zM10.253,17.712A0.306,0.306 0,0 1,10.559 18.017A0.306,0.306 0,0 1,10.253 18.323A0.306,0.306 0,0 1,9.948 18.017A0.306,0.306 0,0 1,10.253 17.712z"
android:strokeLineJoin="round"
android:strokeWidth="0.10993"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:strokeLineCap="round"/>
<path
android:pathData="M16.915,15.805C16.044,15.807 15.228,16.272 14.638,17.279L14.638,23.805A9.525,9.525 0,0 0,16.315 23.593L16.315,20.192C17.326,20.352 18.325,20.377 19.228,19.202L19.228,16.825C18.494,16.161 17.684,15.802 16.915,15.805zM18.031,17.712A0.306,0.306 0,0 1,18.337 18.017A0.306,0.306 0,0 1,18.031 18.323A0.306,0.306 0,0 1,17.725 18.017A0.306,0.306 0,0 1,18.031 17.712z"
android:strokeLineJoin="round"
android:strokeWidth="0.10993"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:strokeLineCap="round"/>
</vector>
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Notification.Container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Notification.Container"
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
android:orientation="horizontal"
android:paddingVertical="8dp">
<FrameLayout
android:layout_width="0dp"
@@ -15,12 +16,13 @@
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_left_24" />
android:src="@drawable/devic_airpods_gen1_left" />
<TextView
android:id="@+id/pod_left_label"
@@ -28,16 +30,21 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingHorizontal="2dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_left_charging"
style="@style/PodInfoItemIcon.Notification"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
android:id="@+id/pod_left_ear"
style="@style/PodInfoItemIcon.Notification"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -49,12 +56,13 @@
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_case_24" />
android:src="@drawable/devic_airpods_gen1_case" />
<TextView
android:id="@+id/pod_case_label"
@@ -62,11 +70,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_case_charging"
style="@style/PodInfoItemIcon.Notification"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
@@ -78,12 +89,13 @@
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_right_24" />
android:src="@drawable/devic_airpods_gen1_right" />
<TextView
android:id="@+id/pod_right_label"
@@ -91,16 +103,21 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingHorizontal="2dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_right_charging"
style="@style/PodInfoItemIcon.Notification"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
android:id="@+id/pod_right_ear"
style="@style/PodInfoItemIcon.Notification"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -4,6 +4,7 @@
android:layout_width="match_parent"
style="@style/Notification.Container"
android:layout_height="wrap_content"
android:paddingVertical="8dp"
android:gravity="center_horizontal">
<TextView
@@ -17,6 +18,7 @@
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
@@ -37,11 +39,15 @@
<ImageView
android:id="@+id/headphones_charging"
style="@style/PodInfoItemIcon.Notification"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_baseline_power_24" />
<ImageView
android:id="@+id/headphones_worn"
style="@style/PodInfoItemIcon.Notification"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_baseline_hearing_24" />
</LinearLayout>
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Notification.Container"
android:layout_width="match_parent"
android:paddingVertical="8dp"
android:layout_height="wrap_content">
<TextView
@@ -11,8 +13,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:drawableStart="@drawable/ic_baseline_question_mark_24"
android:gravity="center"
tools:text="100%" />
tools:text="100%"
app:drawableStartCompat="@drawable/ic_baseline_question_mark_24" />
</LinearLayout>

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