Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 |
@@ -25,16 +25,23 @@ ScreenshotContent.kt (mock data + composables)
|
|||||||
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
||||||
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
||||||
|
|
||||||
|
## Commit policy
|
||||||
|
|
||||||
|
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
|
||||||
|
|
||||||
|
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
|
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||||
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
|
# Use this for local iteration and PRs that touch screenshot content.
|
||||||
./fastlane/generate_screenshots.sh
|
|
||||||
|
|
||||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
|
||||||
./fastlane/generate_screenshots.sh --smoke
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
|
|
||||||
|
# Full run — all 68 locales. Use only when intending to upload to Play Store
|
||||||
|
# (the non-smoke output is .gitignored and should not be committed).
|
||||||
|
./fastlane/generate_screenshots.sh
|
||||||
|
|
||||||
# Copy into fastlane directories (run after generate)
|
# Copy into fastlane directories (run after generate)
|
||||||
./fastlane/copy_screenshots.sh
|
./fastlane/copy_screenshots.sh
|
||||||
|
|
||||||
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
|
|||||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
||||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||||
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
|
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
|
||||||
|
|
||||||
## Removing or Renaming a Screenshot
|
## Removing or Renaming a Screenshot
|
||||||
|
|
||||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||||
2. Run `generate_screenshots.sh`
|
2. Run `generate_screenshots.sh --smoke`
|
||||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/*/images/phoneScreenshots/` and get uploaded to Play Store
|
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||||
|
|
||||||
## After UI Changes
|
## After UI Changes
|
||||||
|
|
||||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./fastlane/generate_screenshots.sh
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
./fastlane/copy_screenshots.sh --clean
|
./fastlane/copy_screenshots.sh --clean
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Refreshing all locales on Play Store
|
||||||
|
|
||||||
|
Periodic, manual operation — not per-PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
|
||||||
|
./fastlane/copy_screenshots.sh --clean
|
||||||
|
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
|
||||||
|
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
|
|
||||||
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
||||||
|
|||||||
@@ -17,6 +17,16 @@
|
|||||||
.kotlin
|
.kotlin
|
||||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||||
app/src/screenshotTest*/reference/
|
app/src/screenshotTest*/reference/
|
||||||
|
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
|
||||||
|
# Full localization is uploaded by occasional manual regen + screenshots_only;
|
||||||
|
# Play Store retains previously-uploaded screenshots for locales not pushed.
|
||||||
|
fastlane/metadata/android/*/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
|
||||||
.codex
|
.codex
|
||||||
protocol-research/
|
protocol-research/
|
||||||
_site/
|
_site/
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ android {
|
|||||||
//noinspection WrongGradleMethod
|
//noinspection WrongGradleMethod
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
maxHeapSize = "4g"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
@@ -37,16 +38,25 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
|||||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DashboardContent() = PreviewWrapper {
|
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
||||||
|
val devices = if (showAap) {
|
||||||
|
listOf(
|
||||||
|
MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||||
|
MockPodDataProvider.singlePodMonitoredWithAap(),
|
||||||
|
MockPodDataProvider.unknownMonitored(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||||
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
|
MockPodDataProvider.unknownMonitored(),
|
||||||
|
)
|
||||||
|
}
|
||||||
OverviewScreen(
|
OverviewScreen(
|
||||||
state = OverviewViewModel.State(
|
state = OverviewViewModel.State(
|
||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = devices,
|
||||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
|
||||||
MockPodDataProvider.singlePodMonitored(),
|
|
||||||
MockPodDataProvider.unknownMonitored(),
|
|
||||||
),
|
|
||||||
isDebugMode = false,
|
isDebugMode = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddProfileContent() = PreviewWrapper {
|
internal fun AddProfileContent() = PreviewWrapper {
|
||||||
|
val pairedAirPods = BluetoothDevice2(
|
||||||
|
address = "AA:BB:CC:DD:EE:FF",
|
||||||
|
name = "AirPods Pro",
|
||||||
|
seenFirstAt = MOCK_NOW,
|
||||||
|
)
|
||||||
|
val bondedItems = listOf(
|
||||||
|
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||||
|
device = pairedAirPods,
|
||||||
|
claimedByProfile = null,
|
||||||
|
),
|
||||||
|
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||||
|
device = BluetoothDevice2(
|
||||||
|
address = "11:22:33:44:55:66",
|
||||||
|
name = "Living Room TV",
|
||||||
|
seenFirstAt = MOCK_NOW,
|
||||||
|
),
|
||||||
|
claimedByProfile = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
DeviceProfileCreationScreen(
|
DeviceProfileCreationScreen(
|
||||||
state = DeviceProfileCreationViewModel.State(
|
state = DeviceProfileCreationViewModel.State(
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
name = "",
|
name = "My AirPods Pro",
|
||||||
nameError = null,
|
nameError = null,
|
||||||
selectedModel = null,
|
selectedModel = PodModel.AIRPODS_PRO2,
|
||||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||||
identityKey = null,
|
identityKey = null,
|
||||||
encryptionKey = null,
|
encryptionKey = null,
|
||||||
selectedDevice = null,
|
selectedDevice = pairedAirPods,
|
||||||
bondedDeviceItems = emptyList(),
|
bondedDeviceItems = bondedItems,
|
||||||
minimumSignalQuality = 0.15f,
|
minimumSignalQuality = 0.15f,
|
||||||
canSave = false,
|
canSave = true,
|
||||||
),
|
),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSave = {},
|
onSave = {},
|
||||||
@@ -212,7 +241,7 @@ private fun PreviewDashboardLight() = DashboardContent()
|
|||||||
|
|
||||||
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewDashboardDark() = DashboardContent()
|
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||||
|
|
||||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class BluetoothDevice2(
|
data class BluetoothDevice2(
|
||||||
internal val internal: BluetoothDevice,
|
val address: BluetoothAddress,
|
||||||
|
val name: String?,
|
||||||
val seenFirstAt: Instant,
|
val seenFirstAt: Instant,
|
||||||
) {
|
internal val internal: BluetoothDevice? = null,
|
||||||
val address: BluetoothAddress
|
)
|
||||||
get() = internal.address
|
|
||||||
|
|
||||||
val name: String?
|
|
||||||
get() = internal.name
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
.map { device ->
|
.map { device ->
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
val wrappedDevices = rawDevices.map { device ->
|
val wrappedDevices = rawDevices.map { device ->
|
||||||
|
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -408,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
@android.annotation.SuppressLint("MissingPermission")
|
@android.annotation.SuppressLint("MissingPermission")
|
||||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
|
val target = device.internal ?: return false
|
||||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||||
.apply { isAccessible = true }
|
.apply { isAccessible = true }
|
||||||
val result = method.invoke(device.internal, alias) as? Boolean ?: false
|
val result = method.invoke(target, alias) as? Boolean ?: false
|
||||||
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -424,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
log(TAG) { "Nudging Android connection to $device" }
|
||||||
|
|
||||||
|
val target = device.internal ?: return@map false
|
||||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||||
"connect", BluetoothDevice::class.java
|
"connect", BluetoothDevice::class.java
|
||||||
).apply { isAccessible = true }
|
).apply { isAccessible = true }
|
||||||
|
|
||||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
|
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
|
||||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||||
accepted
|
accepted
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-mixed",
|
profileId = "preview-dual-mixed",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProMixed(),
|
ble = airPodsProMixed(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-keys",
|
profileId = "preview-dual-keys",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-aap",
|
profileId = "preview-dual-aap",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
@@ -227,12 +230,14 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single",
|
profileId = "preview-single",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMax(),
|
ble = airPodsMax(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single-aap",
|
profileId = "preview-single-aap",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMaxCharging(),
|
ble = airPodsMaxCharging(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.common.flow.SingleEventFlow
|
||||||
import eu.darken.capod.common.navigation.LocalNavigationController
|
import eu.darken.capod.common.navigation.LocalNavigationController
|
||||||
import eu.darken.capod.common.navigation.Nav
|
import eu.darken.capod.common.navigation.Nav
|
||||||
import eu.darken.capod.common.navigation.NavigationController
|
import eu.darken.capod.common.navigation.NavigationController
|
||||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
|||||||
@Inject lateinit var popUpWindow: PopUpWindow
|
@Inject lateinit var popUpWindow: PopUpWindow
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
|
|
||||||
|
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
|
||||||
|
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
|
||||||
|
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
|
||||||
|
private val warmIntents = SingleEventFlow<Intent>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
|||||||
navCtrl.setup(backStack)
|
navCtrl.setup(backStack)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// Cold-start intent (the activity's launching intent).
|
||||||
consumeUpgradeExtra(intent)
|
consumeUpgradeExtra(intent)
|
||||||
|
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
|
||||||
|
// directly from onNewIntent) guarantees navCtrl.setup() has run.
|
||||||
|
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
CapodTheme(state = themeState) {
|
CapodTheme(state = themeState) {
|
||||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
consumeUpgradeExtra(intent)
|
setIntent(intent)
|
||||||
|
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
|
||||||
|
warmIntents.tryEmit(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumeUpgradeExtra(intent: Intent?) {
|
private fun consumeUpgradeExtra(intent: Intent?) {
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
|||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
) {
|
) {
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
batteryPercent = device.batteryLeft,
|
||||||
chargingState = device.leftPodChargingState
|
chargingState = device.leftPodChargingState
|
||||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isLeftInEar ?: false,
|
isInEar = device.isLeftInEar ?: false,
|
||||||
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
batteryPercent = device.batteryRight,
|
||||||
chargingState = device.rightPodChargingState
|
chargingState = device.rightPodChargingState
|
||||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isRightInEar ?: false,
|
isInEar = device.isRightInEar ?: false,
|
||||||
@@ -360,7 +358,7 @@ private fun PodGauge(
|
|||||||
|
|
||||||
// Battery percentage
|
// Battery percentage
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (batteryPercent >= 0f) {
|
color = if (batteryPercent >= 0f) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
@@ -412,7 +410,7 @@ private fun CaseRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase.toBatteryFloat(),
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(8.dp),
|
.height(8.dp),
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
val percent = device.batteryHeadset
|
||||||
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "gaugeProgress",
|
label = "gaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Progress ring
|
// Progress ring
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(88.dp),
|
modifier = Modifier.size(88.dp),
|
||||||
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
|
|
||||||
// Battery text inside ring
|
// Battery text inside ring
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
text = formatBatteryPercent(context, percent),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = if (device.batteryHeadset != null) {
|
color = if (isKnown) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|||||||
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CompactBatterySummary(
|
fun CompactBatterySummary(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hasAnyBattery = device.batteryLeft != null
|
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||||
|| device.batteryRight != null
|
|| isKnownBattery(device.batteryRight)
|
||||||
|| device.batteryHeadset != null
|
|| isKnownBattery(device.batteryHeadset)
|
||||||
|| device.batteryCase != null
|
|| isKnownBattery(device.batteryCase)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
|||||||
percent = device.batteryRight,
|
percent = device.batteryRight,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
|||||||
iconRes = null,
|
iconRes = null,
|
||||||
percent = device.batteryHeadset,
|
percent = device.batteryHeadset,
|
||||||
)
|
)
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MiniPodRing(
|
private fun MiniPodRing(
|
||||||
iconRes: Int?,
|
iconRes: Int?,
|
||||||
percent: Float?,
|
percent: Float,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clamped = percent?.coerceIn(0f, 1f)
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "miniGaugeProgress",
|
label = "miniGaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
|||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
strokeCap = StrokeCap.Round,
|
strokeCap = StrokeCap.Round,
|
||||||
)
|
)
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(28.dp),
|
modifier = Modifier.size(28.dp),
|
||||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
|||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, percent),
|
text = formatBatteryPercent(context, percent),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = if (percent != null) {
|
color = if (isKnown) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase ?: -1f,
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(36.dp)
|
.width(36.dp)
|
||||||
.height(6.dp),
|
.height(6.dp),
|
||||||
|
|||||||
@@ -58,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
|||||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||||
)
|
)
|
||||||
|
|
||||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
!device.isAapConnected -> {
|
||||||
theme = theme,
|
val secondaryRes = if (device.ble != null) {
|
||||||
resolvedBgColor = bgColor,
|
R.string.anc_widget_aap_not_connected_nearby_description
|
||||||
resolvedTextColor = textColor,
|
} else {
|
||||||
resolvedIconColor = iconColor,
|
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
}
|
||||||
)
|
AncWidgetRenderState.Message(
|
||||||
|
theme = theme,
|
||||||
|
resolvedBgColor = bgColor,
|
||||||
|
resolvedTextColor = textColor,
|
||||||
|
resolvedIconColor = iconColor,
|
||||||
|
primaryText = profileLabel ?: device.getLabel(context),
|
||||||
|
secondaryText = context.getString(secondaryRes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
text = formatPercent(state.percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
@@ -311,7 +311,7 @@ private fun PodItemRow(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent.toBatteryOrNull()),
|
text = formatPercent(percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
@@ -354,7 +354,7 @@ private fun TinyPodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent.toBatteryOrNull()),
|
text = formatPercent(percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -382,9 +382,8 @@ private fun DeviceLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatPercent(percent: Float?): String {
|
private fun formatPercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
}
|
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
text = formatGlancePercent(state.percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
@@ -270,7 +270,7 @@ private fun GlancePodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
text = formatGlancePercent(percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
text = formatGlancePercent(percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = GlanceModifier.padding(start = 4.dp),
|
modifier = GlanceModifier.padding(start = 4.dp),
|
||||||
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
|
|||||||
|
|
||||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||||
|
|
||||||
private fun formatGlancePercent(percent: Float?): String {
|
private fun formatGlancePercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ internal data class WidgetDeviceKey(
|
|||||||
val profileId: String?,
|
val profileId: String?,
|
||||||
val profileLabel: String?,
|
val profileLabel: String?,
|
||||||
val model: PodModel,
|
val model: PodModel,
|
||||||
val batteryLeft: Float?,
|
val batteryLeft: Float,
|
||||||
val batteryRight: Float?,
|
val batteryRight: Float,
|
||||||
val batteryCase: Float?,
|
val batteryCase: Float,
|
||||||
val batteryHeadset: Float?,
|
val batteryHeadset: Float,
|
||||||
val isLeftPodCharging: Boolean?,
|
val isLeftPodCharging: Boolean?,
|
||||||
val isRightPodCharging: Boolean?,
|
val isRightPodCharging: Boolean?,
|
||||||
val isCaseCharging: Boolean?,
|
val isCaseCharging: Boolean?,
|
||||||
@@ -27,6 +27,7 @@ internal data class WidgetDeviceKey(
|
|||||||
val isBeingWorn: Boolean?,
|
val isBeingWorn: Boolean?,
|
||||||
val isAapConnected: Boolean,
|
val isAapConnected: Boolean,
|
||||||
val isAapReady: Boolean,
|
val isAapReady: Boolean,
|
||||||
|
val hasBleAdvertisement: Boolean,
|
||||||
val ancMode: AapSetting.AncMode.Value?,
|
val ancMode: AapSetting.AncMode.Value?,
|
||||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||||
@@ -49,6 +50,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
|||||||
isBeingWorn = isBeingWorn,
|
isBeingWorn = isBeingWorn,
|
||||||
isAapConnected = isAapConnected,
|
isAapConnected = isAapConnected,
|
||||||
isAapReady = isAapReady,
|
isAapReady = isAapReady,
|
||||||
|
hasBleAdvertisement = ble != null,
|
||||||
ancMode = ancMode?.current,
|
ancMode = ancMode?.current,
|
||||||
pendingAncMode = pendingAncMode,
|
pendingAncMode = pendingAncMode,
|
||||||
visibleAncModes = visibleAncModes,
|
visibleAncModes = visibleAncModes,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import eu.darken.capod.R
|
|||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
|
|
||||||
object WidgetRenderStateMapper {
|
object WidgetRenderStateMapper {
|
||||||
|
|
||||||
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
|
|||||||
layout = layout,
|
layout = layout,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
leftIcon = device.leftPodIcon,
|
leftIcon = device.leftPodIcon,
|
||||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
leftPercent = device.batteryLeft,
|
||||||
leftCharging = device.isLeftPodCharging == true,
|
leftCharging = device.isLeftPodCharging == true,
|
||||||
leftInEar = device.isLeftInEar == true,
|
leftInEar = device.isLeftInEar == true,
|
||||||
rightIcon = device.rightPodIcon,
|
rightIcon = device.rightPodIcon,
|
||||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
rightPercent = device.batteryRight,
|
||||||
rightCharging = device.isRightPodCharging == true,
|
rightCharging = device.isRightPodCharging == true,
|
||||||
rightInEar = device.isRightInEar == true,
|
rightInEar = device.isRightInEar == true,
|
||||||
caseIcon = device.caseIcon,
|
caseIcon = device.caseIcon,
|
||||||
casePercent = device.batteryCase.toBatteryFloat(),
|
casePercent = device.batteryCase,
|
||||||
caseCharging = device.isCaseCharging == true,
|
caseCharging = device.isCaseCharging == true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
|
|||||||
layout = layout,
|
layout = layout,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
headsetIcon = device.iconRes,
|
headsetIcon = device.iconRes,
|
||||||
percent = device.batteryHeadset.toBatteryFloat(),
|
percent = device.batteryHeadset,
|
||||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||||
charging = device.isHeadsetBeingCharged == true,
|
charging = device.isHeadsetBeingCharged == true,
|
||||||
worn = device.isBeingWorn == true,
|
worn = device.isBeingWorn == true,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
|||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
@@ -138,18 +139,24 @@ data class PodDevice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battery — AAP preferred, BLE fallback, then cached
|
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
|
||||||
val batteryLeft: Float?
|
// for unknown to keep the type primitive; this is the boundary that previously emitted
|
||||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
// Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
|
||||||
|
val batteryLeft: Float
|
||||||
|
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||||
|
?: cached?.left?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryRight: Float?
|
val batteryRight: Float
|
||||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||||
|
?: cached?.right?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryCase: Float?
|
val batteryCase: Float
|
||||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||||
|
?: cached?.case?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryHeadset: Float?
|
val batteryHeadset: Float
|
||||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||||
|
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
/** True when at least one displayed battery value was filled from cache (not live). */
|
/** True when at least one displayed battery value was filled from cache (not live). */
|
||||||
val isBatteryCached: Boolean
|
val isBatteryCached: Boolean
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||||
try {
|
try {
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
log(TAG) { "AAP connected to $address" }
|
log(TAG) { "AAP connected to $address" }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||||
@@ -99,7 +99,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||||
break
|
break
|
||||||
} catch (retryException: Exception) {
|
} catch (retryException: Exception) {
|
||||||
@@ -159,7 +159,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
log(TAG) { "AAP reconnected to $address" }
|
log(TAG) { "AAP reconnected to $address" }
|
||||||
break
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -211,7 +211,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
val bonded = bluetoothManager.bondedDevices().first()
|
val bonded = bluetoothManager.bondedDevices().first()
|
||||||
.firstOrNull { it.address == address }
|
.firstOrNull { it.address == address }
|
||||||
if (bonded != null) {
|
if (bonded != null) {
|
||||||
aapManager.connect(address, bonded.internal, detectedModel)
|
aapManager.connect(address, bonded.internal!!, detectedModel)
|
||||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
|
|||||||
import eu.darken.capod.common.SystemTimeSource
|
import eu.darken.capod.common.SystemTimeSource
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@@ -24,15 +26,17 @@ fun PodDevice.toCachedState(
|
|||||||
if (!isLive) return null
|
if (!isLive) return null
|
||||||
val pid = profileId ?: return null
|
val pid = profileId ?: return null
|
||||||
|
|
||||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
|
||||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
|
||||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
|
||||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
|
||||||
|
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
|
||||||
|
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
|
||||||
val liveDeviceInfo = aap?.deviceInfo
|
val liveDeviceInfo = aap?.deviceInfo
|
||||||
|
|
||||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null && liveDeviceInfo == null) {
|
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
|
||||||
return null
|
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
|
||||||
}
|
) return null
|
||||||
|
|
||||||
val newState = CachedDeviceState(
|
val newState = CachedDeviceState(
|
||||||
profileId = pid,
|
profileId = pid,
|
||||||
@@ -61,15 +65,14 @@ fun PodDevice.toCachedState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeBatterySlot(
|
private fun mergeBatterySlot(
|
||||||
livePercent: Float?,
|
livePercent: Float,
|
||||||
existing: CachedBatterySlot?,
|
existing: CachedBatterySlot?,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
): CachedBatterySlot? {
|
): CachedBatterySlot? {
|
||||||
val live: Float = livePercent ?: return existing
|
if (!isKnownBattery(livePercent)) return existing
|
||||||
val current: CachedBatterySlot = existing ?: return CachedBatterySlot(live, now)
|
val current = existing ?: return CachedBatterySlot(livePercent, now)
|
||||||
|
|
||||||
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||||
return if (current.percent == live && !isStale) current else CachedBatterySlot(live, now)
|
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
setTextViewText(R.id.device, device.getLabel(context))
|
setTextViewText(R.id.device, device.getLabel(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun percentToInt(percent: Float?): Int {
|
private fun percentToInt(percent: Float): Int =
|
||||||
if (percent == null) return 0
|
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
const val BATTERY_UNKNOWN = -1f
|
const val BATTERY_UNKNOWN = -1f
|
||||||
|
|
||||||
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
|
fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f
|
||||||
|
|
||||||
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
|
fun batteryProgress(percent: Float): Float =
|
||||||
|
if (isKnownBattery(percent)) percent.coerceIn(0f, 1f) else 0f
|
||||||
|
|
||||||
fun formatBatteryPercent(context: Context, percent: Float?): String =
|
fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
else context.getString(R.string.general_value_not_available_label)
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
fun getBatteryDrawable(percent: Float): Int = when {
|
||||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
||||||
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
||||||
@@ -42,8 +43,8 @@ fun getBatteryDrawable(percent: Float?): Int = when {
|
|||||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBatteryIcon(percent: Float?): ImageVector = when {
|
fun getBatteryIcon(percent: Float): ImageVector = when {
|
||||||
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||||
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
||||||
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
||||||
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
|
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PopUpContent(
|
fun PopUpContent(
|
||||||
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
// Left pod
|
// Left pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
batteryPercent = device.batteryLeft,
|
||||||
isCharging = device.isLeftPodCharging ?: false,
|
isCharging = device.isLeftPodCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
if (device.hasCase) {
|
if (device.hasCase) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.caseIcon,
|
iconRes = device.caseIcon,
|
||||||
batteryPercent = device.batteryCase.toBatteryFloat(),
|
batteryPercent = device.batteryCase,
|
||||||
isCharging = device.isCaseCharging ?: false,
|
isCharging = device.isCaseCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
// Right pod
|
// Right pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
batteryPercent = device.batteryRight,
|
||||||
isCharging = device.isRightPodCharging ?: false,
|
isCharging = device.isRightPodCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
private fun SinglePodContent(device: PodDevice) {
|
private fun SinglePodContent(device: PodDevice) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.iconRes,
|
iconRes = device.iconRes,
|
||||||
batteryPercent = device.batteryHeadset.toBatteryFloat(),
|
batteryPercent = device.batteryHeadset,
|
||||||
isCharging = device.isHeadsetBeingCharged ?: false,
|
isCharging = device.isHeadsetBeingCharged ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,7 +155,6 @@ private fun BatteryColumn(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val nullablePercent = batteryPercent.toBatteryOrNull()
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -184,14 +181,14 @@ private fun BatteryColumn(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = getBatteryIcon(nullablePercent),
|
imageVector = getBatteryIcon(batteryPercent),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, nullablePercent),
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -157,6 +157,8 @@
|
|||||||
<string name="anc_widget_config_aap_required_hint">This widget requires a direct connection (AAP) to your device. Make sure your device is paired and connected in the app before using this widget.</string>
|
<string name="anc_widget_config_aap_required_hint">This widget requires a direct connection (AAP) to your device. Make sure your device is paired and connected in the app before using this widget.</string>
|
||||||
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
|
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
|
||||||
<string name="anc_widget_aap_not_connected_label">Not connected</string>
|
<string name="anc_widget_aap_not_connected_label">Not connected</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_nearby_description">Nearby but not connected</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_not_nearby_description">Not nearby</string>
|
||||||
<string name="anc_widget_aap_connecting_label">Connecting…</string>
|
<string name="anc_widget_aap_connecting_label">Connecting…</string>
|
||||||
<string name="tile_anc_label">Noise Control</string>
|
<string name="tile_anc_label">Noise Control</string>
|
||||||
<string name="tile_anc_subtitle_permission_required">Permission required</string>
|
<string name="tile_anc_subtitle_permission_required">Permission required</string>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ fun DashboardLight() = DashboardContent()
|
|||||||
@PreviewTest
|
@PreviewTest
|
||||||
@PlayStoreLocalesDark
|
@PlayStoreLocalesDark
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardDark() = DashboardContent()
|
fun DashboardDark() = DashboardContent(showAap = true)
|
||||||
|
|
||||||
@PreviewTest
|
@PreviewTest
|
||||||
@PlayStoreLocales
|
@PlayStoreLocales
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core
|
|||||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import io.kotest.matchers.nulls.shouldBeNull
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
@@ -57,7 +58,7 @@ class PodDeviceCacheTest : BaseTest() {
|
|||||||
device.batteryLeft shouldBe 0.8f
|
device.batteryLeft shouldBe 0.8f
|
||||||
device.batteryRight shouldBe 0.7f
|
device.batteryRight shouldBe 0.7f
|
||||||
device.batteryCase shouldBe 0.5f
|
device.batteryCase shouldBe 0.5f
|
||||||
device.batteryHeadset.shouldBeNull()
|
device.batteryHeadset shouldBe BATTERY_UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -252,6 +252,37 @@ class ToCachedStateTest : BaseTest() {
|
|||||||
result.rightEarbudSerial shouldBe "R-1"
|
result.rightEarbudSerial shouldBe "R-1"
|
||||||
result.marketingVersion shouldBe "9999"
|
result.marketingVersion shouldBe "9999"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DeviceInfo-only update preserves existing slot timestamps (no live refresh)`() {
|
||||||
|
// Regression: if toCachedState consults the unified PodDevice.batteryX getter (which
|
||||||
|
// falls back to cache), an AAP-only DeviceInfo update would re-stamp every slot with
|
||||||
|
// `now`, refreshing stale cached readings indefinitely. Raw live extraction prevents
|
||||||
|
// that.
|
||||||
|
val oldStamp = now.minusSeconds(3600)
|
||||||
|
val existing = CachedDeviceState(
|
||||||
|
profileId = "test-profile",
|
||||||
|
model = PodModel.AIRPODS_PRO3,
|
||||||
|
left = CachedDeviceState.CachedBatterySlot(0.8f, oldStamp),
|
||||||
|
right = CachedDeviceState.CachedBatterySlot(0.7f, oldStamp),
|
||||||
|
case = CachedDeviceState.CachedBatterySlot(0.5f, oldStamp),
|
||||||
|
marketingVersion = "OLD",
|
||||||
|
lastSeenAt = oldStamp,
|
||||||
|
)
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "test-profile",
|
||||||
|
ble = null,
|
||||||
|
aap = AapPodState(deviceInfo = deviceInfo(marketingVersion = "NEW")),
|
||||||
|
profileModel = PodModel.AIRPODS_PRO3,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = device.toCachedState(existing, now).shouldNotBeNull()
|
||||||
|
result.marketingVersion shouldBe "NEW"
|
||||||
|
result.left?.updatedAt shouldBe oldStamp
|
||||||
|
result.right?.updatedAt shouldBe oldStamp
|
||||||
|
result.case?.updatedAt shouldBe oldStamp
|
||||||
|
result.left?.percent shouldBe 0.8f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deviceInfo(
|
private fun deviceInfo(
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ platform :android do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
lane :screenshots_only do
|
||||||
|
sh "bash ./remove_unsupported_languages.sh"
|
||||||
|
supply(
|
||||||
|
track: 'production',
|
||||||
|
package_name: 'eu.darken.capod',
|
||||||
|
skip_upload_apk: 'true',
|
||||||
|
skip_upload_aab: 'true',
|
||||||
|
skip_upload_metadata: 'true',
|
||||||
|
skip_upload_changelogs: 'true',
|
||||||
|
skip_upload_images: 'true',
|
||||||
|
skip_upload_screenshots: 'false',
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
after_all do |lane|
|
after_all do |lane|
|
||||||
# This block is called, only if the executed lane was successful
|
# This block is called, only if the executed lane was successful
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ GEM
|
|||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.8)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1216.0)
|
aws-partitions (1.1244.0)
|
||||||
aws-sdk-core (3.242.0)
|
aws-sdk-core (3.246.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
@@ -17,11 +17,11 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.122.0)
|
aws-sdk-kms (1.124.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.213.0)
|
aws-sdk-s3 (1.220.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
@@ -29,7 +29,7 @@ GEM
|
|||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.1.2)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
@@ -68,11 +68,11 @@ GEM
|
|||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.4)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
fastlane (2.232.1)
|
fastlane (2.233.1)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
abbrev (~> 0.1.2)
|
abbrev (~> 0.1.2)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
@@ -92,7 +92,7 @@ GEM
|
|||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
@@ -122,10 +122,9 @@ GEM
|
|||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.1)
|
xcpretty (~> 0.4.1)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.0.0)
|
fastlane-sirp (1.1.0)
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.96.0)
|
google-apis-androidpublisher_v3 (0.99.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.18.0)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
@@ -135,19 +134,19 @@ GEM
|
|||||||
mutex_m
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
google-apis-iamcredentials_v1 (0.26.0)
|
google-apis-iamcredentials_v1 (0.27.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-playcustomapp_v1 (0.17.0)
|
google-apis-playcustomapp_v1 (0.17.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-storage_v1 (0.60.0)
|
google-apis-storage_v1 (0.62.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-cloud-core (1.8.0)
|
google-cloud-core (1.8.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (2.1.1)
|
google-cloud-env (2.1.1)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.5.0)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.58.0)
|
google-cloud-storage (1.59.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-core (>= 0.18, < 2)
|
google-apis-core (>= 0.18, < 2)
|
||||||
@@ -169,13 +168,13 @@ GEM
|
|||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.18.1)
|
json (2.19.5)
|
||||||
jwt (2.10.2)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.19.1)
|
multi_json (1.21.0)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
@@ -185,13 +184,13 @@ GEM
|
|||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (7.0.2)
|
public_suffix (7.0.5)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.2.0)
|
retriable (3.4.1)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
@@ -205,7 +204,6 @@ GEM
|
|||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 147 KiB |