Compare commits

..
19 Commits
Author SHA1 Message Date
darken 07be4b8258 Release: 2.3.1-rc0 2022-11-07 22:51:12 +01:00
darken 217087c6d4 Clean up tests 2022-11-07 22:50:52 +01:00
Matthias Urhahn 2315a18c86 Improve history caching for AirPods. (#55)
Don't throw away the last history item that contains case infos.
2022-11-07 22:28:13 +01:00
Matthias Urhahn f59e9d2dc5 Fix comparison that determines which list item is the primary device. (#54) 2022-11-07 19:42:31 +01:00
Matthias Urhahn 1da220085c Merge pull request #53 from d4rken-org/improve_automatic_monitor_mode
Improve automatic monitor mode
2022-11-07 19:36:30 +01:00
Matthias Urhahn 962b2a01f1 Merge branch 'main' into improve_automatic_monitor_mode 2022-11-07 19:35:49 +01:00
darken eeda14f424 When in MonitorMode.AUTOMATIC and there is no mainDeviceAddress set, then just keep running while there is any connected headset device. 2022-11-07 19:25:31 +01:00
darken 6269e20085 When selecting automatic monitor-mode, check if the user has selected a device address, if not pop up the selection dialog. 2022-11-07 19:08:52 +01:00
Matthias Urhahn 3d05296eb2 Merge pull request #51 from GitGitro/main
add monochrome icon
2022-11-07 18:36:48 +01:00
GitGitro e90ab95cd0 add monochrome icon, closes #50,closes #23 2022-11-07 15:31:31 +01:00
Matthias Urhahn 23ec2f025e Update README.md 2022-11-05 12:31:21 +01:00
darken 624898dd5e Release: 2.3.0-rc0 2022-11-05 12:20:06 +01:00
darken c6d6604399 Merge remote-tracking branch 'origin/main' into main 2022-11-05 12:19:19 +01:00
darken 7473704954 Update changelog 2022-11-05 12:19:14 +01:00
Matthias Urhahn 7a6b216bf6 Update translations (#49) 2022-11-05 12:11:54 +01:00
Matthias Urhahn 1ef09124af Improve notification theming (#47)
* Make notification themes more dynamic to adapt to day/night/system themes.

* Fix broken style references
2022-11-05 12:11:33 +01:00
darken 6340f73d61 Throttle pod device data for the UI, notifications or widgets.
We don't need faster updates than 1 per second.
(We keep reactions at maximum speed tho)
2022-11-05 12:11:09 +01:00
darken b9f4992a33 A CAPod widget, oh my! ¯\_(ツ)_/¯ 2022-11-05 11:53:09 +01:00
darken 07a1fee013 Fix proguard rule inclusion 2022-10-28 22:27:50 +02:00
56 changed files with 954 additions and 111 deletions
+9 -4
View File
@@ -16,6 +16,7 @@ A companion app that adds support for AirPod specific features to Android:
* Automatically connect phone & AirPods.
* Show popup when case is opened.
* Support for Wear OS
* Widgets
CAPod is ad-free. Some additional features require an in-app purchase.
@@ -36,9 +37,13 @@ Currently supported models:
## 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%2Fplayshields.herokuapp.com%2Fplay%3Fi%3Deu.darken.capod%26l%3DAndroid%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | ? |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
## Support the project
@@ -54,7 +59,7 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
## Thanks to
* The [OpenPods](https://github.com/adolfintel/OpenPods) project,
+1 -1
View File
@@ -1 +1 @@
2.2.1-rc1 20201010
2.3.1-rc0 20301000
@@ -7,6 +7,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.hasCause
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlin.time.Duration
@@ -71,4 +72,11 @@ fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String)
log(tag, ERROR) { "${identifier()} failed: ${it.asLog()}" }
throw it
}
}
fun <T> Flow<T>.throttleLatest(delayMillis: Long): Flow<T> = this
.conflate()
.transform {
emit(it)
delay(delayMillis)
}
@@ -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(
@@ -176,6 +178,15 @@ class PodMonitor @Inject constructor(
}
}
suspend fun latestMainDevice(): PodDevice? {
val currentMain = mainDevice.firstOrNull()
log(TAG) { "Live mainDevice is $currentMain" }
return currentMain ?: podDeviceCache.loadMainDevice()
?.let { podFactory.createPod(it)?.device }
.also { log(TAG) { "Cached mainDevice is $it" } }
}
private fun getUnfilteredFilter(): ScanFilter {
return ScanFilter.Builder().build()
}
@@ -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
}
}
@@ -88,6 +89,22 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
?.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,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="app_name_foss">CAPod FOSS</string>
<string name="general_value_not_available_label">T/A</string>
<string name="general_error_label">Error</string>
<string name="general_grant_permission_action">Berikan izin</string>
<string name="overview_nomaindevice_label">Tidak ada perangkat utama</string>
<string name="overview_nomaindevice_description">Semua perangkat yang terdeteksi tidak mungkin menjadi milik anda. Nyalakan &amp; sambungkan perangkat anda atau sesuaikan pengaturan.</string>
@@ -10,6 +14,7 @@
<string name="permission_bluetooth_connect_description">Aplikasi ini memerlukan izin \"Koneksi Bluetooth\" untuk berinteraksi dengan perangkat yang akan dipasangkan &amp; memulai koneksi.</string>
<string name="permission_bluetooth_scan_label">Pemindaian Bluetooth</string>
<string name="permission_bluetooth_scan_description">Izin \"Pemindaian Bluetooth\" memungkinkan aplikasi ini menemukan &amp; menerima data Bluetooth dari perangkat terdekat seperti AirPods anda.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Aplikasi ini memerlukan izin \"Bluetooth\" untuk terhubung ke perangkat bluetooth yang ingin dipasangkan.</string>
<string name="permission_access_fine_location_label">Akses lokasi halus</string>
<string name="permission_access_fine_location_description">CAPod menggunakan izin \"lokasi halus\" untuk menerima data Bluetooth Hemat Energi. Headphone anda menggunakan teknologi Bluetooth Hemat Energi untuk menyiarkan statusnya. Aplikasi ini TIDAK akan menggunakan data Bluetooth untuk menentukan lokasi anda.</string>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="general_value_not_available_label">T/A</string>
<string name="general_error_label">Ralat</string>
<string name="general_grant_permission_action">Berikan kebenaran</string>
<string name="overview_nomaindevice_label">Tiada peranti utama</string>
@@ -10,6 +11,7 @@
<string name="permission_bluetooth_connect_description">Apl ini memerlukan kebenaran \"Bluetooth connect\" untuk berinteraksi dengan pasangan peranti dan memulakan sambungan.</string>
<string name="permission_bluetooth_scan_label">Pengimbasan bluetooth</string>
<string name="permission_bluetooth_scan_description">Kebenaran \"Pengimbasan Bluetooth\" membolehkan apl ini menemui dan menerima data Bluetooth daripada peranti berdekatan seperti AirPods anda.</string>
<string name="permission_bluetooth_label">Bluetooth</string>
<string name="permission_bluetooth_description">Apl ini memerlukan kebenaran \"Bluetooth\" untuk menyambung ke peranti bluetooth yang dipasangkan.</string>
<string name="permission_access_fine_location_label">Akses lokasi yang baik</string>
<string name="permission_access_fine_location_description">CAPod menggunakan kebenaran \"lokasi baik\" untuk menerima data Bluetooth Tenaga Rendah. Fon kepala anda menggunakan teknologi Bluetooth Tenaga Rendah untuk menyiarkan statusnya. Apl ini TIDAK akan menggunakan data Bluetooth untuk menentukan lokasi anda.</string>
@@ -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 {
fun `create AirPodsGen1`() = runTest {
create<DualAirPods>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
this shouldBe instanceOf<AirPodsGen1>()
}
}
@Test
fun `create AirPodsPro`() = runBlockingTest {
fun `create AirPodsPro`() = runTest {
create<DualAirPods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
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,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 DualApplePodsTest : BaseAirPodsTest() {
@Test
fun `test bit mapping`() = runBlockingTest {
fun `test bit mapping`() = runTest {
create<DualAirPods>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte()
@@ -23,7 +23,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - active microphone`() = runBlockingTest {
fun `test AirPodDevice - active microphone`() = runTest {
create<DualAirPods>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101011
// --^-----
@@ -39,7 +39,7 @@ 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") {
// 00100010
@@ -66,7 +66,7 @@ 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") {
// 00101001
@@ -93,7 +93,7 @@ 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") {
// 88 10001000
@@ -109,7 +109,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - pod charging`() = runBlockingTest {
fun `test AirPodDevice - pod charging`() = runTest {
/**
* Right pod is charging
*/
@@ -157,7 +157,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@Test
fun `test AirPodDevice - case charging`() = runBlockingTest {
fun `test AirPodDevice - case charging`() = runTest {
create<DualAirPods>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
// 0011 0011
isCaseCharging shouldBe false
@@ -169,7 +169,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@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") {
// 31 0011 0001
@@ -210,7 +210,7 @@ class DualApplePodsTest : BaseAirPodsTest() {
}
@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") {
// 31 0011 0001
@@ -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
@@ -4,14 +4,14 @@ import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
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()
@@ -4,13 +4,13 @@ import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
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()
@@ -4,13 +4,13 @@ import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
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()
@@ -42,7 +42,7 @@ class AirPodsGen3Test : BaseAirPodsTest() {
}
@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
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.airpods
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()
@@ -31,7 +31,7 @@ class AirPodsMaxTest : BaseAirPodsTest() {
// 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()
@@ -3,7 +3,7 @@ package eu.darken.capod.pods.core.apple.airpods
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 +12,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()
@@ -47,7 +47,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()
@@ -3,13 +3,13 @@ package eu.darken.capod.pods.core.apple.airpods
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()
@@ -38,7 +38,7 @@ class AirPodsProTest : BaseAirPodsTest() {
}
@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 +55,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 +159,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 +170,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
}
}
}
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.beats
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()
@@ -26,7 +26,7 @@ class BeatsFlexText : BaseAirPodsTest() {
}
@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
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.beats
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()
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.beats
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()
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.beats
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()
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.beats
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()
@@ -3,14 +3,14 @@ package eu.darken.capod.pods.core.apple.beats
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()
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple.misc
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() {
@Test
fun `charging in box`() = runBlockingTest {
fun `charging in box`() = runTest {
create<Twsi99999>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple.misc
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() {
@Test
fun `guessed data`() = runBlockingTest {
fun `guessed data`() = runTest {
create<VarunrAirPodsPro>("07 13 01 0E 20 71 AA 37 36 00 10 00 FF 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0E20.toUShort()
@@ -9,6 +9,7 @@ import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3
@@ -76,8 +77,8 @@ class OverviewFragmentVM @Inject constructor(
permissionTool.missingPermissions,
debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice,
) { _, permissions, isDebugMode, isBluetoothEnabled, mainPod ->
podMonitor.mainDevice.throttleLatest(1000),
) { _, permissions, isDebugMode, isBluetoothEnabled, _ ->
val items = mutableListOf<OverviewAdapter.Item>()
if (permissions.isNotEmpty()) {
@@ -97,10 +98,7 @@ class OverviewFragmentVM @Inject constructor(
return@combine items
}
val podToShow = mainPod ?: podDeviceCache.loadMainDevice()?.let {
log(TAG, VERBOSE) { "Using podDeviceCache: $it" }
podFactory.createPod(it)?.device
}
val podToShow = podMonitor.latestMainDevice()
log(TAG, VERBOSE) { "Showing $podToShow" }
val now = Instant.now()
+1 -1
View File
@@ -51,7 +51,7 @@ android {
}
buildTypes {
val customProguardRules = fileTree(File("../proguard")) {
val customProguardRules = fileTree(File(projectDir, "proguard")) {
include("*.pro")
}
debug {
+12 -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">
@@ -69,6 +68,18 @@
</intent-filter>
</receiver>
<receiver
android:name=".main.ui.widget.WidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/battery_widget_info" />
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
+29
View File
@@ -8,8 +8,16 @@ import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutoReporting
import eu.darken.capod.common.debug.logging.*
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -19,6 +27,9 @@ open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutoReporting
@Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -36,6 +47,24 @@ open class App : Application(), Configuration.Provider {
appScope.launch {
monitorControl.startMonitor(forceStart = true)
}
podMonitor.mainDevice
.distinctUntilChanged()
.throttleLatest(1000)
.onEach {
log(TAG) { "Main device changed, refreshing widgets." }
widgetManager.refreshWidgets()
}
.launchIn(appScope)
upgradeRepo.upgradeInfo
.map { it.isPro }
.distinctUntilChanged()
.onEach {
log(TAG) { "Pro status changed, refreshing widgets." }
widgetManager.refreshWidgets()
}
.launchIn(appScope)
}
override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
@@ -9,6 +9,7 @@ import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.navigation.navVia
import eu.darken.capod.common.permissions.Permission
@@ -96,6 +97,7 @@ class OverviewFragmentVM @Inject constructor(
}
}
.catch { errorEvents.postValue(it) }
.throttleLatest(1000)
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
updateTicker,
@@ -128,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) }
@@ -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")
}
@@ -0,0 +1,41 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WidgetManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val widgetManager by lazy { AppWidgetManager.getInstance(context) }
private val currentWidgetIds: IntArray
get() = widgetManager.getAppWidgetIds(ComponentName(context, PROVIDER_CLASS))
suspend fun refreshWidgets() {
log(TAG) { "refreshWidgets()" }
log(TAG, VERBOSE) { "Notifying these widget IDs: ${currentWidgetIds.toList()}" }
val intent = Intent(context, PROVIDER_CLASS).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, currentWidgetIds)
}
context.sendBroadcast(intent)
}
companion object {
val PROVIDER_CLASS = WidgetProvider::class.java
val TAG = logTag("Widget", "Manager")
}
}
@@ -0,0 +1,235 @@
package eu.darken.capod.main.ui.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.time.Duration
import javax.inject.Inject
@AndroidEntryPoint
class WidgetProvider : AppWidgetProvider() {
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var podDeviceCache: PodDeviceCache
@Inject lateinit var podFactory: PodFactory
@Inject lateinit var upgradeRepo: UpgradeRepo
@AppScope @Inject lateinit var appScope: CoroutineScope
private var asyncBarrier: PendingResult? = null
private fun executeAsync(
tag: String,
timeout: Duration = Duration.ofSeconds(10),
block: suspend () -> Unit
) {
val start = System.currentTimeMillis()
asyncBarrier = goAsync()
log(TAG, VERBOSE) { "executeAsync($tag) starting asyncBarrier=$asyncBarrier " }
appScope.launch {
try {
withTimeout(timeout.toMillis()) { block() }
} catch (e: Exception) {
log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" }
} finally {
asyncBarrier?.finish()
val stop = System.currentTimeMillis()
log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " }
}
}
log(TAG, VERBOSE) { "executeAsync($block) leaving" }
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
log(TAG) { "onUpdate(appWidgetIds=${appWidgetIds.toList()})" }
executeAsync("onUpdate") {
appWidgetIds.forEach { appWidgetId ->
updateWidget(context, appWidgetManager, appWidgetId, null)
}
}
}
override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle?
) {
log(TAG) { "onAppWidgetOptionsChanged(appWidgetId=$appWidgetId, newOptions=$newOptions)" }
executeAsync("onAppWidgetOptionsChanged") {
updateWidget(context, appWidgetManager, appWidgetId, newOptions)
}
}
private suspend fun updateWidget(
context: Context,
widgetManager: AppWidgetManager,
widgetId: Int,
options: Bundle?
) {
log(TAG) { "updateWidget(widgetId=$widgetId, options=$options)" }
val device: PodDevice? = podMonitor.latestMainDevice()
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context)
device is DualPodDevice -> createDualPodLayout(context, device)
device is SinglePodDevice -> createSinglePodLayout(context, device)
device is PodDevice -> createUnknownPodLayout(context, device)
else -> createNoDeviceLayout(context)
}
widgetManager.updateAppWidget(widgetId, layout)
}
private suspend fun createUpgradeRequiredLayout(
context: Context
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.upgrade_capod_label))
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
setViewVisibility(R.id.secondary, View.VISIBLE)
}
private fun createUnknownPodLayout(
context: Context,
podDevice: PodDevice,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.pods_unknown_label))
}
private fun createNoDeviceLayout(
context: Context,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.overview_nomaindevice_label))
}
private fun createDualPodLayout(
context: Context,
podDevice: DualPodDevice,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_dual_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
// Left
setTextViewText(R.id.pod_left_label, podDevice.getBatteryLevelLeftPod(context))
setViewVisibility(
R.id.pod_left_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isLeftPodCharging) View.VISIBLE else View.GONE
)
setViewVisibility(
R.id.pod_left_ear,
if (podDevice is HasEarDetectionDual && podDevice.isLeftPodInEar) View.VISIBLE else View.GONE
)
// Case
setTextViewText(R.id.pod_case_label, (podDevice as? HasCase)?.getBatteryLevelCase(context))
setViewVisibility(
R.id.pod_case_charging,
if (podDevice is HasCase && podDevice.isCaseCharging) View.VISIBLE else View.GONE
)
// Right
setTextViewText(R.id.pod_right_label, podDevice.getBatteryLevelRightPod(context))
setViewVisibility(
R.id.pod_right_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isRightPodCharging) View.VISIBLE else View.GONE
)
setViewVisibility(
R.id.pod_right_ear,
if (podDevice is HasEarDetectionDual && podDevice.isRightPodInEar) View.VISIBLE else View.GONE
)
}
private fun createSinglePodLayout(
context: Context,
podDevice: SinglePodDevice,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(podDevice.batteryHeadsetPercent))
setTextViewText(R.id.headphones_battery_label, podDevice.getBatteryLevelHeadset(context))
setViewVisibility(
R.id.headphones_worn,
if (podDevice is HasEarDetection && podDevice.isBeingWorn) View.VISIBLE else View.GONE
)
if (this is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (isHeadsetBeingCharged) View.VISIBLE else View.GONE)
}
}
companion object {
val TAG = logTag("Widget", "Provider")
}
}
@@ -1,6 +1,7 @@
package eu.darken.capod.monitor.core.worker
import android.app.NotificationManager
import android.bluetooth.BluetoothDevice
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
@@ -17,9 +18,11 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.PodMonitor
@@ -48,6 +51,7 @@ class MonitorWorker @AssistedInject constructor(
private val autoConnect: AutoConnect,
private val popUpReaction: PopUpReaction,
private val popUpWindow: PopUpWindow,
private val widgetManager: WidgetManager,
) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope()
@@ -99,6 +103,7 @@ class MonitorWorker @AssistedInject constructor(
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart { setForeground(monitorNotifications.getForegroundInfo(null)) }
.distinctUntilChanged()
.throttleLatest(1000)
.onEach { currentDevice ->
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
@@ -110,38 +115,49 @@ 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> {
// Cancel worker, ui scans manually
workerScope.coroutineContext.cancelChildren()
}
MonitorMode.MANUAL -> flow<Unit> {
// Cancel worker, ui scans manually
workerScope.coroutineContext.cancelChildren()
}
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()
}
}
}
}
@@ -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>
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Notification.Container"
android:layout_marginHorizontal="8dp"
android:orientation="horizontal">
@@ -2,6 +2,7 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
style="@style/Notification.Container"
android:layout_height="wrap_content"
android:gravity="center_horizontal">
@@ -1,6 +1,7 @@
<?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">
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/PodWidget.Container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
style="@style/PodWidget.Container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/primary"
style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/pods_unknown_label"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/secondary"
style="@style/PodWidget.TextSecondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:ellipsize="end"
android:gravity="center"
android:maxLines="4"
android:textSize="12sp"
android:visibility="gone"
tools:text="@string/pods_unknown_label"
tools:visibility="visible" />
</LinearLayout>
@@ -0,0 +1,112 @@
<?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/PodWidget.Container"
android:orientation="vertical">
<TextView
android:id="@+id/headphones_label"
style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginBottom="2dp"
android:textSize="12sp"
tools:text="AirPods Max" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_left_24"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_left_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_left_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_left_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_right_24"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_right_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_right_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_right_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_case_24"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_case_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="4dp"
tools:text="100%" />
<ImageView
android:id="@+id/pod_case_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,47 @@
<?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/PodWidget.Container"
android:orientation="vertical">
<TextView
android:id="@+id/headphones_label"
style="@style/PodWidget.TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="AirPods Max" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/headphones_battery_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_battery_unknown_24" />
<TextView
android:id="@+id/headphones_battery_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
tools:text="100%" />
<ImageView
android:id="@+id/headphones_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/headphones_worn"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,103 @@
<?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/PodWidget.Container"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_left_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_left_24"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_left_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="100%"
tools:ignore="HardcodedText" />
<ImageView
android:id="@+id/pod_left_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_left_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_right_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_right_24"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_right_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="100%"
tools:ignore="HardcodedText" />
<ImageView
android:id="@+id/pod_right_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/pod_right_ear"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_hearing_24"
tools:ignore="ContentDescription" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/pod_case_icon"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_airpod_case_24"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/pod_case_label"
style="@style/PodInfoItemText.Notification"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="100%"
tools:ignore="HardcodedText" />
<ImageView
android:id="@+id/pod_case_charging"
style="@style/PodInfoItemIcon.Notification"
android:src="@drawable/ic_baseline_power_24"
tools:ignore="ContentDescription" />
</LinearLayout>
</LinearLayout>
@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
+3
View File
@@ -7,6 +7,7 @@
<string name="general_upgrade_action">Tingkatkan</string>
<string name="general_check_action">Memeriksa</string>
<string name="general_close_action">Tutup</string>
<string name="upgrade_capod_label">Perbaharui CAPod</string>
<string name="upgrade_capod_description">Dapatkan fitur tambahan &amp; bantu dukung pengembang.</string>
<string name="settings_monitor_mode_label">Mode monitor</string>
<string name="settings_monitor_mode_description">Dalam keadaan apa aplikasi ini memonitor data Bluetooth.</string>
@@ -52,6 +53,7 @@
<string name="settings_support_description">Jika anda membutuhkan bantuan.</string>
<string name="issue_tracker_label">Pelacak masalah</string>
<string name="issue_tracker_description">Pelacak masalah publik untuk laporan bug &amp; permintaan fitur (hanya dalam bahasa Inggris).</string>
<string name="discord_label">Discord</string>
<string name="discord_description">Tempat nongkrong &amp; bertanya-tanya.</string>
<string name="changelog_label">Catatan perubahan</string>
<string name="settings_label">Pengaturan</string>
@@ -73,4 +75,5 @@
<string name="help_translate_label">Terjemahan</string>
<string name="help_translate_description">Bantu terjemahkan applikasi ini ke bahasa favorit anda.</string>
<string name="translators_thanks_title">Penerjemah</string>
<string name="translators_thanks_description">gelapkan</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="widget_root" type="id" />
</resources>
+2
View File
@@ -89,4 +89,6 @@
<string name="translators_thanks_title">Translators</string>
<string name="translators_thanks_description">darken</string>
<string name="widget_description">A widget showing the last known device status.</string>
</resources>
+37
View File
@@ -7,14 +7,26 @@
<item name="android:layout_marginEnd">8dp</item>
</style>
<style name="Notification">
</style>
<style name="Notification.Container">
<!-- <item name="android:background">?android:attr/colorBackground</item>-->
<item name="android:theme">@style/Theme.Material3.DynamicColors.DayNight</item>
</style>
<style name="PodInfoItemIcon.Notification" parent="TextAppearance.Compat.Notification.Title">
<item name="android:layout_height">20dp</item>
<item name="android:layout_width">20dp</item>
<item name="android:tint">?android:attr/colorAccent</item>
<item name="tint">?android:attr/colorAccent</item>
</style>
<style name="PodInfoItemText.Notification" parent="TextAppearance.Compat.Notification.Title">
<item name="android:singleLine">true</item>
<item name="android:ellipsize">end</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
<style name="PodInfoItemIcon" parent="TextAppearance.MaterialComponents.Body2">
@@ -26,4 +38,29 @@
<item name="android:singleLine">true</item>
<item name="android:ellipsize">end</item>
</style>
<style name="PodWidget">
</style>
<style name="PodWidget.Container">
<item name="android:background">?android:attr/colorBackground</item>
<item name="android:theme">@style/Theme.Material3.DynamicColors.DayNight</item>
<item name="android:paddingStart">16dp</item>
<item name="android:paddingEnd">16dp</item>
<item name="android:paddingTop">8dp</item>
<item name="android:paddingBottom">8dp</item>
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:id">@id/widget_root</item>
<item name="android:gravity">center</item>
</style>
<style name="PodWidget.TextPrimary">
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
<style name="PodWidget.TextSecondary">
<item name="android:textColor">?android:attr/textColorSecondary</item>
</style>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:minWidth="80dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:updatePeriodMillis="86400000"
android:description="@string/widget_description"
android:previewLayout="@layout/widget_preview_layout"
android:initialLayout="@layout/widget_loading_layout"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable|configuration_optional"
tools:targetApi="s" />
@@ -1,3 +1,3 @@
Bugfixes and performance improvements.
¯\_(ツ)_/¯
Now with more widgets!
But also bugfixes and performance improvements.
¯\_(ツ)_/¯
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+2 -2
View File
@@ -1,6 +1,6 @@
### Updated by release.sh ###
project.versioning.major=2
project.versioning.minor=2
project.versioning.minor=3
project.versioning.patch=1
project.versioning.build=1
project.versioning.build=0
#############################