diff --git a/README.md b/README.md index 08e176de..02814c98 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ Currently supported models: | Source | Status | |------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplayshields.herokuapp.com%2Fplay%3Fi%3Deu.darken.capod%26l%3DAndroid%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) | -| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | ? | +| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) | +| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](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/) | diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt index f6f0025d..fef9b6d9 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt @@ -108,6 +108,10 @@ interface PodDevice { "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, diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt index 43fb8ad6..dd447e51 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactoryModule.kt @@ -7,10 +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.FakeAirPodsGen1 -import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3 -import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro -import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2 +import eu.darken.capod.pods.core.apple.misc.* @InstallIn(SingletonComponent::class) @Module @@ -32,6 +29,7 @@ abstract class AppleFactoryModule { @Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory @Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory + @Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory @Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory @Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory @Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2.kt new file mode 100644 index 00000000..6de84fe9 --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2.kt @@ -0,0 +1,150 @@ +package eu.darken.capod.pods.core.apple.misc + +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.isBitSet +import eu.darken.capod.common.lowerNibble +import eu.darken.capod.common.upperNibble +import eu.darken.capod.pods.core.* +import eu.darken.capod.pods.core.apple.ApplePods +import eu.darken.capod.pods.core.apple.ApplePodsFactory +import eu.darken.capod.pods.core.apple.protocol.ProximityPairing +import java.time.Instant +import javax.inject.Inject + +/** + * Similar data structure but a lot of placeholder values or hardcoded values + */ +data class FakeAirPodsGen2 constructor( + override val identifier: PodDevice.Id = PodDevice.Id(), + override val seenLastAt: Instant = Instant.now(), + override val seenFirstAt: Instant = Instant.now(), + override val seenCounter: Int = 1, + override val scanResult: BleScanResult, + override val proximityMessage: ProximityPairing.Message, + override val confidence: Float = PodDevice.BASE_CONFIDENCE, + private val rssiAverage: Int? = null, + private val cachedBatteryPercentage: Float? = null, +) : ApplePods, DualPodDevice, HasEarDetectionDual, HasChargeDetectionDual, HasCase { + + override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN2 + + override val rssi: Int + get() = rssiAverage ?: super.rssi + + /** + * Normally values for the left pod are in the lower nibbles, if the left pod is primary (microphone) + * If the right pod is the primary, the values are flipped. + */ + val areValuesFlipped: Boolean + get() = !rawStatus.isBitSet(5) + + override val batteryLeftPodPercent: Float? + get() { + val value = when (areValuesFlipped) { + true -> rawPodsBattery.upperNibble.toInt() + false -> rawPodsBattery.lowerNibble.toInt() + } + return when (value) { + 15 -> null + else -> if (value > 10) { + log { "Left pod: Above 100% battery: $value" } + 1.0f + } else { + (value / 10f) + } + } + } + + override val batteryRightPodPercent: Float? + get() { + val value = when (areValuesFlipped) { + true -> rawPodsBattery.lowerNibble.toInt() + false -> rawPodsBattery.upperNibble.toInt() + } + return when (value) { + 15 -> null + else -> if (value > 10) { + log { "Right pod: Above 100% battery: $value" } + 1.0f + } else { + value / 10f + } + } + } + + private val isThisPodInThecase: Boolean + get() = rawStatus.isBitSet(6) + + override val isLeftPodInEar: Boolean + get() = when (areValuesFlipped xor isThisPodInThecase) { + true -> rawStatus.isBitSet(3) + false -> rawStatus.isBitSet(1) + } + + override val isRightPodInEar: Boolean + get() = when (areValuesFlipped xor isThisPodInThecase) { + true -> rawStatus.isBitSet(1) + false -> rawStatus.isBitSet(3) + } + + override val isLeftPodCharging: Boolean + get() = when (areValuesFlipped) { + false -> rawFlags.isBitSet(0) + true -> rawFlags.isBitSet(1) + } + + override val isRightPodCharging: Boolean + get() = when (areValuesFlipped) { + false -> rawFlags.isBitSet(1) + true -> rawFlags.isBitSet(0) + } + + override val batteryCasePercent: Float? + get() = when (val value = rawCaseBattery.toInt()) { + 15 -> cachedBatteryPercentage + else -> if (value > 10) { + log { "Case: Above 100% battery: $value" } + 1.0f + } else { + value / 10f + } + } + + override val isCaseCharging: Boolean + get() = rawFlags.isBitSet(2) + + class Factory @Inject constructor() : ApplePodsFactory(TAG) { + + override fun isResponsible(message: ProximityPairing.Message): Boolean = message.run { + // Official message length is 19HEX, i.e. binary 25, did they copy this wrong? + getModelInfo().full == DEVICE_CODE && length == 19 + } + + override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods { + var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message) + val result = searchHistory(basic) + + if (result != null) basic = basic.copy(identifier = result.id) + updateHistory(basic) + + if (result == null) return basic + + return basic.copy( + identifier = result.id, + seenFirstAt = result.seenFirstAt, + seenLastAt = scanResult.receivedAt, + seenCounter = result.seenCounter, + confidence = result.confidence, + cachedBatteryPercentage = result.getLatestCaseBattery(), + rssiAverage = result.averageRssi(basic.rssi), + ) + } + } + + companion object { + private val DEVICE_CODE = 0x0F20.toUShort() + private val TAG = logTag("PodDevice", "Apple", "Fake", "AirPods", "Gen2") + } +} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt index 7f077a5a..4a3c9631 100644 --- a/app-common/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt @@ -10,9 +10,9 @@ class FakeAirPodsGen1Test : BaseAirPodsTest() { @Test fun `charging in box`() = runTest { - create("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") { + create("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 0x0220.toUShort() + rawDeviceModel shouldBe 0x0F20.toUShort() rawStatus shouldBe 0x71.toUByte() rawPodsBattery shouldBe 0xAA.toUByte() rawFlags shouldBe 0x3.toUShort() @@ -34,7 +34,7 @@ class FakeAirPodsGen1Test : BaseAirPodsTest() { batteryCasePercent shouldBe 0.7f - model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN1 + model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN2 } } } \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2Test.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2Test.kt new file mode 100644 index 00000000..d34b9f5e --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2Test.kt @@ -0,0 +1,40 @@ +package eu.darken.capod.pods.core.apple.misc + +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.BaseAirPodsTest +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class FakeAirPodsGen2Test : BaseAirPodsTest() { + + @Test + fun `charging in box`() = runTest { + create("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") { + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x0220.toUShort() + rawStatus shouldBe 0x71.toUByte() + rawPodsBattery shouldBe 0xAA.toUByte() + rawFlags shouldBe 0x3.toUShort() + rawCaseBattery shouldBe 0x7.toUShort() + rawCaseLidState shouldBe 0x32.toUByte() + rawDeviceColor shouldBe 0x00.toUByte() + rawSuffix shouldBe 0x10.toUByte() + + batteryLeftPodPercent shouldBe 1.0f + batteryRightPodPercent shouldBe 1.0f + + isLeftPodInEar shouldBe false + isRightPodInEar shouldBe false + + isLeftPodCharging shouldBe true + isRightPodCharging shouldBe true + + isCaseCharging shouldBe false + + batteryCasePercent shouldBe 0.7f + + model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN1 + } + } +} \ No newline at end of file