mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911a09c743 | ||
|
|
2672dbdcf6 | ||
|
|
3ddd5f3cfd | ||
|
|
31f4d4aafa | ||
|
|
e06dc933b8 | ||
|
|
1418be999d | ||
|
|
df06a49949 | ||
|
|
b6dcc74383 | ||
|
|
fead8381bc | ||
|
|
2ab9f8ace6 | ||
|
|
81effcb349 | ||
|
|
3dd1f8151a | ||
|
|
272a7121a9 | ||
|
|
52a7850c1d | ||
|
|
b297ad8564 | ||
|
|
a3810353f7 | ||
|
|
d4dddb1f38 | ||
|
|
21560a1f3a | ||
|
|
bdfb6bb073 | ||
|
|
aac0590017 | ||
|
|
afba33dd83 | ||
|
|
5de5dee52f | ||
|
|
ba3e253569 | ||
|
|
4488af0ab7 | ||
|
|
aae8ad62bd | ||
|
|
9606a33f15 | ||
|
|
f626d70538 | ||
|
|
a91ef44686 | ||
|
|
e3bef4e74b | ||
|
|
358bf5e244 | ||
|
|
fba2d3a59d | ||
|
|
0abba4ece2 | ||
|
|
e9b0694b67 | ||
|
|
49bbf1bc85 | ||
|
|
f5b6a5c605 | ||
|
|
fccc6cedd5 | ||
|
|
a03766448e | ||
|
|
f0c9d318ff | ||
|
|
5c5cc6f28e | ||
|
|
246b96bfb4 | ||
|
|
352e020b54 | ||
|
|
72f5690ea6 | ||
|
|
1ed1199574 | ||
|
|
fd4a43de96 | ||
|
|
e406fd4cc1 | ||
|
|
1e555cc712 |
@@ -21,11 +21,14 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
|||||||
import eu.darken.capod.common.theming.CapodTheme
|
import eu.darken.capod.common.theming.CapodTheme
|
||||||
import eu.darken.capod.main.ui.overview.OverviewScreen
|
import eu.darken.capod.main.ui.overview.OverviewScreen
|
||||||
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||||
|
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
||||||
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
||||||
@@ -52,6 +55,29 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
|||||||
MockPodDataProvider.unknownMonitored(),
|
MockPodDataProvider.unknownMonitored(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Plausible estimates for the mock battery levels: runtime under each gauge, charge ETA in
|
||||||
|
// the charging chip of whichever mock pod is charging.
|
||||||
|
val estimates = if (showAap) {
|
||||||
|
mapOf(
|
||||||
|
"preview-dual-aap" to BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE),
|
||||||
|
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
"preview-single-aap" to BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(480, 0.05f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 45),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mapOf(
|
||||||
|
"preview-dual-mixed" to BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 20),
|
||||||
|
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
"preview-single" to BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(1020, 0.05f, BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
OverviewScreen(
|
OverviewScreen(
|
||||||
state = OverviewViewModel.State(
|
state = OverviewViewModel.State(
|
||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
@@ -65,6 +91,7 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
|||||||
),
|
),
|
||||||
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
|
batteryEstimates = estimates,
|
||||||
),
|
),
|
||||||
onRequestPermission = {},
|
onRequestPermission = {},
|
||||||
onBluetoothSettings = {},
|
onBluetoothSettings = {},
|
||||||
@@ -229,7 +256,7 @@ internal fun HomescreenWidgetContent() {
|
|||||||
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
|
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
|
||||||
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||||
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||||
),
|
).withLocalizedPreviewEstimates(LocalContext.current),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="foss_upgrade_donate_label">تبرع</string>
|
<string name="foss_upgrade_donate_label">تبرُّع</string>
|
||||||
<string name="foss_upgrade_alreadydonated_label">أنا متبرّع فعلًا</string>
|
<string name="foss_upgrade_alreadydonated_label">لقد تبرّعتُ بالفعل</string>
|
||||||
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على AirPods</string>
|
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على سمّاعات AirPods</string>
|
||||||
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
|
<string name="upgrade_foss_preamble">برنامج CAPod FOSS مجاني ومفتوح المصدر. إذا وجدتهُ مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
|
||||||
<string name="upgrade_foss_sponsor_action">دعم المشروع</string>
|
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
|
||||||
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من Google Play.</string>
|
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</string>
|
||||||
<string name="upgrade_foss_sponsor_returned_early">هل عدت فعلًا؟ دعمكم يُبقي CAPod حيًا.</string>
|
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يُبقي CAPod حيًّا.</string>
|
||||||
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
|
<string name="upgrade_badge_label">البرمجيات الحرة والمفتوحة المصدر</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
|
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
|
||||||
<string name="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
|
<string name="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
|
||||||
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
|
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
|
||||||
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez seklōšanas. Bez Google Play saistībām.</string>
|
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez izsekošanas. Bez Google Play saistībām.</string>
|
||||||
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Jūsu atbalsts uztur CAPod pie dzīvības.</string>
|
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Tavs atbalsts uztur CAPod esamību.</string>
|
||||||
<string name="upgrade_badge_label">FOSS</string>
|
<string name="upgrade_badge_label">FOSS</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متوفّرة.</string>
|
<string name="upgrades_gplay_unavailable_error">خدمات جوجل بلاي غير متوفّرة.</string>
|
||||||
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
<string name="upgrades_no_purchases_found_check_account">لم يُعثَر على أيّ مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
||||||
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
|
<string name="upgrades_gplay_billing_error_label">خطأ في جوجل بلاي</string>
|
||||||
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
|
<string name="upgrades_gplay_billing_error_description">حدث خطأ في سوق جوجل بلاي. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nالخطأ: %s</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
|
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة جوجل بلاي</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من Google Play. يُرجى مسح ذاكرة التخزين المؤقّت لـ Google Play وإعادة تشغيل هاتفك.\n\nخطأ %s</string>
|
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من سوق جوجل بلاي. يُرجى مسح ذاكرة التخزين المؤقّت لسوق جوجل بلاي وإعادة تشغيل هاتفك.\n\nالخطأ %s</string>
|
||||||
<string name="upgrade_restore_action">استعادة المشتريات</string>
|
<string name="upgrade_restore_action">استعادة عملية الشراء</string>
|
||||||
<string name="upgrade_badge_label">النسخة الاحترافية</string>
|
<string name="upgrade_badge_label">النسخة الاحترافية</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string>
|
<string name="upgrades_gplay_unavailable_error">Google Play pakalpojumi nav pieejami.</string>
|
||||||
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmantojat pareizo kontu?</string>
|
<string name="upgrades_no_purchases_found_check_account">Nav atrasts neviens pirkums. Vai izmanto pareizo kontu?</string>
|
||||||
<string name="upgrades_gplay_billing_error_label">Google Play kļūda</string>
|
<string name="upgrades_gplay_billing_error_label">Google Play kļūda</string>
|
||||||
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūdzu, mēģiniet vēlreiz vēlāk vai pārstartējiet tālruni.\n\nKļūda: %s</string>
|
<string name="upgrades_gplay_billing_error_description">Google Play radās kļūda. Lūgums mēģināt vēlreiz vēlāk vai pārsāknēt tālruni.\n\nKļūda: %s</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_label">Google Play norēķinu kļūda</string>
|
<string name="upgrades_gplay_billing_result_error_label">Google Play norēķinu kļūda</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play jūsu pirkuma informāciju. Notīriet Google Play kešatmiņu un pārstartējiet tālruni.\n\nKļūda %s</string>
|
<string name="upgrades_gplay_billing_result_error_description">Radās kļūda, pieprasot no Google Play pirkuma informāciju. Jāiztīra Google Play kešatmiņa un jāpārsāknē tālrunis.\n\nKļūda %s</string>
|
||||||
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
|
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
|
||||||
<string name="upgrade_badge_label">Pro</string>
|
<string name="upgrade_badge_label">Pro</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -43,6 +43,11 @@
|
|||||||
android:name="android.hardware.bluetooth_le"
|
android:name="android.hardware.bluetooth_le"
|
||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
|
||||||
|
<!-- Package visibility (API 30+): resolving the MIUI/HyperOS autostart settings screen. -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.miui.securitycenter" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
|||||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||||
import eu.darken.capod.main.ui.widget.toWidgetKey
|
import eu.darken.capod.main.ui.widget.toWidgetKey
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.displayKey
|
||||||
|
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||||
|
|
||||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -36,6 +40,7 @@ open class App : Application() {
|
|||||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||||
@Inject lateinit var widgetManager: WidgetManager
|
@Inject lateinit var widgetManager: WidgetManager
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
|
@Inject lateinit var batteryEstimator: BatteryEstimator
|
||||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -62,8 +67,15 @@ open class App : Application() {
|
|||||||
|
|
||||||
appScope.launch { widgetManager.refreshWidgets() }
|
appScope.launch { widgetManager.refreshWidgets() }
|
||||||
|
|
||||||
deviceMonitor.devicesWithProfiles()
|
combine(
|
||||||
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
|
deviceMonitor.devicesWithProfiles(),
|
||||||
|
batteryEstimator.estimates,
|
||||||
|
) { devices, estimates -> devices to estimates }
|
||||||
|
// Estimate display values can change while the device key is stable (e.g. a charge
|
||||||
|
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
|
||||||
|
.distinctUntilChangedBy { (devices, estimates) ->
|
||||||
|
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
|
||||||
|
}
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
.onEach {
|
.onEach {
|
||||||
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
|
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ fun BleScanResult.logSummary(): String {
|
|||||||
.sortedBy { it.key }
|
.sortedBy { it.key }
|
||||||
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
|
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
|
||||||
.ifEmpty { "-" }
|
.ifEmpty { "-" }
|
||||||
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmName("logBleScanResultCollectionSummary")
|
@JvmName("logBleScanResultCollectionSummary")
|
||||||
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
|
|||||||
.sorted()
|
.sorted()
|
||||||
.joinToString(separator = ",")
|
.joinToString(separator = ",")
|
||||||
.ifEmpty { "-" }
|
.ifEmpty { "-" }
|
||||||
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmName("logFrameworkScanResultCollectionSummary")
|
@JvmName("logFrameworkScanResultCollectionSummary")
|
||||||
|
|||||||
@@ -212,6 +212,37 @@ object MockPodDataProvider {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual pods with a fully-populated AAP session: ANC on, both pods in-ear, left pod as the
|
||||||
|
* primary (microphone) pod. Used to preview the card with all status flags AND a battery estimate.
|
||||||
|
*/
|
||||||
|
fun dualPodFullyLoaded(): PodDevice = PodDevice(
|
||||||
|
profileId = "preview-dual-full",
|
||||||
|
label = "My AirPods Pro",
|
||||||
|
ble = airPodsProWithKeys(),
|
||||||
|
aap = AapPodState(
|
||||||
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
|
settings = mapOf(
|
||||||
|
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||||
|
current = AapSetting.AncMode.Value.ON,
|
||||||
|
supported = listOf(
|
||||||
|
AapSetting.AncMode.Value.OFF,
|
||||||
|
AapSetting.AncMode.Value.ON,
|
||||||
|
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||||
|
AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||||
|
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
|
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||||
|
),
|
||||||
|
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(
|
||||||
|
pod = AapSetting.PrimaryPod.Pod.LEFT,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
|
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
|
||||||
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-reactions",
|
profileId = "preview-dual-reactions",
|
||||||
@@ -459,7 +490,6 @@ private class MockSingleBlePodSnapshot(
|
|||||||
override val isBeingWorn: Boolean = _isBeingWorn
|
override val isBeingWorn: Boolean = _isBeingWorn
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("PropertyName")
|
|
||||||
private class MockSingleAppleBlePodSnapshot(
|
private class MockSingleAppleBlePodSnapshot(
|
||||||
private val _model: PodModel,
|
private val _model: PodModel,
|
||||||
private val _label: String,
|
private val _label: String,
|
||||||
@@ -498,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
|
|||||||
override val isBeingWorn: Boolean = _isBeingWorn
|
override val isBeingWorn: Boolean = _isBeingWorn
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("PropertyName")
|
|
||||||
private class MockDualAppleBlePodSnapshot(
|
private class MockDualAppleBlePodSnapshot(
|
||||||
private val _model: PodModel,
|
private val _model: PodModel,
|
||||||
private val _label: String,
|
private val _label: String,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.serialization.SerializationCapod
|
|||||||
import eu.darken.capod.common.theming.ThemeColor
|
import eu.darken.capod.common.theming.ThemeColor
|
||||||
import eu.darken.capod.common.theming.ThemeMode
|
import eu.darken.capod.common.theming.ThemeMode
|
||||||
import eu.darken.capod.common.theming.ThemeStyle
|
import eu.darken.capod.common.theming.ThemeStyle
|
||||||
|
import eu.darken.capod.monitor.core.MonitorSessionMark
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
||||||
@@ -81,8 +82,27 @@ class GeneralSettings @Inject constructor(
|
|||||||
|
|
||||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity of the currently running monitor session, null while it isn't running.
|
||||||
|
* Set atomically on monitor start, cleared on clean [android.app.Service.onDestroy] — an OS
|
||||||
|
* force-stop skips onDestroy, so a non-null value at the next monitor start is evidence of an
|
||||||
|
* unclean death (see MonitorKillDetector).
|
||||||
|
*/
|
||||||
|
val monitorSessionMark = dataStore.createValue<MonitorSessionMark?>(
|
||||||
|
"core.monitor.health.session", null, json, onErrorFallbackToDefault = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Timestamp of the newest [android.app.ApplicationExitInfo] record already processed. */
|
||||||
|
val exitInfoWatermark = dataStore.createValue("core.monitor.health.exitinfo.watermark", 0L)
|
||||||
|
|
||||||
|
/** Timestamp of the most recent detected OS kill of the monitor (0 = none). */
|
||||||
|
val lastOsKillAt = dataStore.createValue("core.monitor.health.oskill.last", 0L)
|
||||||
|
|
||||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||||
|
|
||||||
|
/** When the "your phone stopped CAPod" hint was dismissed; re-shown only for newer kills. */
|
||||||
|
val osKillHintDismissedAt = dataStore.createValue("ui.hint.oskill.dismissed", 0L)
|
||||||
|
|
||||||
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
|
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
|
||||||
|
|
||||||
val themeMode = dataStore.createValue(
|
val themeMode = dataStore.createValue(
|
||||||
|
|||||||
@@ -64,8 +64,10 @@ class PermissionTool @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Whether [permission] is applicable for the user's current configuration.
|
* Whether [permission] is applicable for the user's current configuration.
|
||||||
* Three permissions are conditional on monitor mode or popup usage:
|
* Three permissions are conditional on monitor mode or popup usage:
|
||||||
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed
|
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] whenever the monitor service is expected to
|
||||||
* - [Permission.ACCESS_BACKGROUND_LOCATION] same
|
* run unattended (AUTOMATIC and ALWAYS) — those users depend on a long-lived foreground
|
||||||
|
* service that aggressive vendor battery management may otherwise kill
|
||||||
|
* - [Permission.ACCESS_BACKGROUND_LOCATION] only when always-on scanning is needed
|
||||||
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
|
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
|
||||||
* Everything else is unconditionally applicable.
|
* Everything else is unconditionally applicable.
|
||||||
*/
|
*/
|
||||||
@@ -74,7 +76,7 @@ class PermissionTool @Inject constructor(
|
|||||||
monitorMode: MonitorMode,
|
monitorMode: MonitorMode,
|
||||||
anyPopupEnabled: Boolean,
|
anyPopupEnabled: Boolean,
|
||||||
): Boolean = when (permission) {
|
): Boolean = when (permission) {
|
||||||
Permission.IGNORE_BATTERY_OPTIMIZATION,
|
Permission.IGNORE_BATTERY_OPTIMIZATION -> monitorMode != MonitorMode.MANUAL
|
||||||
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
|
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
|
||||||
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
|
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
|
||||||
else -> true
|
else -> true
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
|
|||||||
import eu.darken.capod.common.settings.SettingsSection
|
import eu.darken.capod.common.settings.SettingsSection
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||||
@@ -181,6 +182,8 @@ fun DeviceSettingsScreenHost(
|
|||||||
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
|
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
|
||||||
onOpenIssueTracker = { vm.openIssueTracker() },
|
onOpenIssueTracker = { vm.openIssueTracker() },
|
||||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||||
|
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
|
||||||
|
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +227,8 @@ fun DeviceSettingsScreen(
|
|||||||
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
||||||
onOpenIssueTracker: () -> Unit = {},
|
onOpenIssueTracker: () -> Unit = {},
|
||||||
onOpenAapTracker: () -> Unit = {},
|
onOpenAapTracker: () -> Unit = {},
|
||||||
|
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||||
|
onResetBatteryEstimate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val device = state.device
|
val device = state.device
|
||||||
val features = device?.model?.features
|
val features = device?.model?.features
|
||||||
@@ -291,6 +296,23 @@ fun DeviceSettingsScreen(
|
|||||||
info = info,
|
info = info,
|
||||||
labels = rememberDeviceInfoDetailLabels(),
|
labels = rememberDeviceInfoDetailLabels(),
|
||||||
formatDate = { instant -> dateFormatter.format(instant) },
|
formatDate = { instant -> dateFormatter.format(instant) },
|
||||||
|
batteryHealth = when {
|
||||||
|
state.batteryHealth != null -> BatteryHealthTexts(
|
||||||
|
left = state.batteryHealth.left?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
right = state.batteryHealth.right?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
headset = state.batteryHealth.headset?.let {
|
||||||
|
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state.batteryHealthPending -> BatteryHealthTexts(
|
||||||
|
pending = stringResource(R.string.device_settings_info_battery_health_pending),
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
DeviceInfoCard(
|
DeviceInfoCard(
|
||||||
deviceInfo = device.deviceInfo,
|
deviceInfo = device.deviceInfo,
|
||||||
@@ -363,10 +385,26 @@ fun DeviceSettingsScreen(
|
|||||||
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
||||||
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
||||||
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
||||||
|
onOpenIssueTracker = onOpenIssueTracker,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Battery (time-remaining estimate for any live device; charge limit needs AAP) ──
|
||||||
|
if (device != null && features != null && device.isLive) {
|
||||||
|
item("battery_section") {
|
||||||
|
BatteryCard(
|
||||||
|
device = device,
|
||||||
|
features = features,
|
||||||
|
isPro = isPro,
|
||||||
|
chargeCapControlEnabled = enabled,
|
||||||
|
estimateEnabled = state.batteryEstimateEnabled,
|
||||||
|
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
|
||||||
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
|
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
|
||||||
onChargedThresholdChange = onChargedThresholdChange,
|
onChargedThresholdChange = onChargedThresholdChange,
|
||||||
onChargedSlotScopeChange = onChargedSlotScopeChange,
|
onChargedSlotScopeChange = onChargedSlotScopeChange,
|
||||||
onOpenIssueTracker = onOpenIssueTracker,
|
onEstimateEnabledChange = onBatteryEstimateEnabledChange,
|
||||||
|
onResetEstimate = onResetBatteryEstimate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,18 +477,6 @@ fun DeviceSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Battery ──────────────────────────────────
|
|
||||||
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
|
|
||||||
item("battery_section") {
|
|
||||||
BatteryCard(
|
|
||||||
device = device,
|
|
||||||
features = features,
|
|
||||||
enabled = enabled,
|
|
||||||
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connections ───────────────────────────────
|
// ── Connections ───────────────────────────────
|
||||||
val connectedDevices = device.connectedDevices
|
val connectedDevices = device.connectedDevices
|
||||||
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
|
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
|
||||||
|
|||||||
+44
-2
@@ -22,6 +22,10 @@ import eu.darken.capod.main.core.MonitorMode
|
|||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryHealth
|
||||||
|
import eu.darken.capod.monitor.core.battery.DrainProfile
|
||||||
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
@@ -55,6 +59,8 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
private val bluetoothManager: BluetoothManager2,
|
private val bluetoothManager: BluetoothManager2,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
|
private val batteryEstimator: BatteryEstimator,
|
||||||
|
private val drainStore: BatteryDrainStore,
|
||||||
private val monitorModeResolver: MonitorModeResolver,
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
@@ -122,6 +128,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
monitorModeResolver.effectiveMode,
|
monitorModeResolver.effectiveMode,
|
||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
nudgeCapabilityStore.availability,
|
nudgeCapabilityStore.availability,
|
||||||
|
drainStore.profiles,
|
||||||
) { args ->
|
) { args ->
|
||||||
val device = args[1] as PodDevice?
|
val device = args[1] as PodDevice?
|
||||||
val upgrade = args[2] as UpgradeRepo.Info
|
val upgrade = args[2] as UpgradeRepo.Info
|
||||||
@@ -134,9 +141,12 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
||||||
val nudgeAvailability = args[7] as NudgeAvailability
|
val nudgeAvailability = args[7] as NudgeAvailability
|
||||||
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val drainProfiles = args[8] as Map<ProfileId, DrainProfile>
|
||||||
|
val appleProfile = profiles.filterIsInstance<AppleDeviceProfile>()
|
||||||
.firstOrNull { it.id == profileId }
|
.firstOrNull { it.id == profileId }
|
||||||
?.stemActions
|
val stemActions = appleProfile?.stemActions
|
||||||
val connectedAddresses = connectedDevices.map { it.address }.toSet()
|
val connectedAddresses = connectedDevices.map { it.address }.toSet()
|
||||||
val systemBtName = device?.address?.let { addr ->
|
val systemBtName = device?.address?.let { addr ->
|
||||||
try {
|
try {
|
||||||
@@ -158,6 +168,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
|
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
|
||||||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
|
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
|
||||||
} == true,
|
} == true,
|
||||||
|
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
|
||||||
|
// Health rides on the same learned data as the estimate — the per-device toggle
|
||||||
|
// governs both.
|
||||||
|
batteryHealth = device
|
||||||
|
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
||||||
|
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
|
||||||
|
// A model with a rating WILL eventually produce a health figure — surface the
|
||||||
|
// feature as "still determining" until the listening sessions accumulate. Without
|
||||||
|
// a paired device the listening gate can never open, so no promise is made.
|
||||||
|
batteryHealthPending = device != null &&
|
||||||
|
(appleProfile?.batteryEstimateEnabled ?: true) &&
|
||||||
|
device.model.batterySpec != null &&
|
||||||
|
device.hasSelectedPairedDevice,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
@@ -183,6 +206,12 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
|
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
|
||||||
val systemBluetoothName: String? = null,
|
val systemBluetoothName: String? = null,
|
||||||
val hasCustomLongPressStemAction: Boolean = false,
|
val hasCustomLongPressStemAction: Boolean = false,
|
||||||
|
val batteryEstimateEnabled: Boolean = true,
|
||||||
|
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
|
||||||
|
val batteryHealth: BatteryHealth.PerPod? = null,
|
||||||
|
/** True when health CAN be derived for this device (rated model, feature on) — shows the
|
||||||
|
* "still determining" placeholder while [batteryHealth] is null. */
|
||||||
|
val batteryHealthPending: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
||||||
}
|
}
|
||||||
@@ -473,6 +502,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
|
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Battery estimate (per-profile) ───────────────────────────────────────
|
||||||
|
|
||||||
|
fun setBatteryEstimateEnabled(enabled: Boolean) = launch {
|
||||||
|
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
|
||||||
|
updateProfileNow { it.copy(batteryEstimateEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetBatteryEstimate() = launch {
|
||||||
|
val profileId = targetProfileId.value ?: return@launch
|
||||||
|
log(TAG, INFO) { "resetBatteryEstimate($profileId)" }
|
||||||
|
batteryEstimator.reset(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun navToPressControls() = launch {
|
fun navToPressControls() = launch {
|
||||||
log(TAG, INFO) { "navToPressControls()" }
|
log(TAG, INFO) { "navToPressControls()" }
|
||||||
|
|||||||
@@ -2,41 +2,176 @@ package eu.darken.capod.main.ui.devicesettings.cards
|
|||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||||
|
import androidx.compose.material.icons.twotone.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.twotone.RestartAlt
|
||||||
|
import androidx.compose.material.icons.twotone.Schedule
|
||||||
|
import androidx.compose.material.icons.twotone.Workspaces
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
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.common.settings.SettingsBaseItem
|
||||||
import eu.darken.capod.common.settings.SettingsSection
|
import eu.darken.capod.common.settings.SettingsSection
|
||||||
|
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||||
|
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
|
||||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||||
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.aap.protocol.AapSetting
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
|
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
|
* The device's "Battery" settings, grouping everything charge/battery-related. Time remaining &
|
||||||
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
|
* battery health lead, then a divider, then the charging-side settings:
|
||||||
* used by every other boolean setting.
|
* - The time-remaining/health estimate: a per-device toggle (disabling pauses/hides without
|
||||||
|
* discarding learned data) and a reset that wipes the learned drain, charge and health data so
|
||||||
|
* it starts over from the model's rated battery life.
|
||||||
|
* - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise
|
||||||
|
* [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session.
|
||||||
|
* - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it
|
||||||
|
* works for any live device (BLE or AAP), not just when the phone is the audio source.
|
||||||
|
*
|
||||||
|
* Each row is gated independently; the whole card hides when nothing applies.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BatteryCard(
|
internal fun BatteryCard(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
features: PodModel.Features,
|
features: PodModel.Features,
|
||||||
enabled: Boolean,
|
isPro: Boolean,
|
||||||
|
chargeCapControlEnabled: Boolean,
|
||||||
|
estimateEnabled: Boolean,
|
||||||
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||||
|
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
|
||||||
|
onChargedThresholdChange: (Int) -> Unit = {},
|
||||||
|
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
||||||
|
onEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||||
|
onResetEstimate: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (!features.hasDynamicEndOfCharge) return
|
val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
|
||||||
val cap = device.dynamicEndOfCharge ?: return
|
// Per-profile controls (charge notification + estimate) apply to any live, profile-matched device.
|
||||||
|
val showLiveControls = device.isLive && device.profileId != null
|
||||||
|
if (chargeCap == null && !showLiveControls) return
|
||||||
|
|
||||||
|
val reactions = device.reactions
|
||||||
|
var showResetConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showChargedScopeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
|
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
|
||||||
SettingsSwitchItem(
|
// Time remaining & battery health lead — the headline feature. Charging-side settings
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
// (Apple's charge limit, the charged notification) follow below the divider.
|
||||||
title = stringResource(R.string.device_settings_charge_cap_label),
|
if (showLiveControls) {
|
||||||
subtitle = stringResource(R.string.device_settings_charge_cap_description),
|
SettingsSwitchItem(
|
||||||
checked = cap.enabled,
|
icon = Icons.TwoTone.Schedule,
|
||||||
onCheckedChange = onDynamicEndOfChargeChange,
|
title = stringResource(R.string.device_battery_estimate_toggle_label),
|
||||||
enabled = enabled,
|
subtitle = stringResource(R.string.device_battery_estimate_card_desc),
|
||||||
|
checked = estimateEnabled,
|
||||||
|
onCheckedChange = onEstimateEnabledChange,
|
||||||
|
)
|
||||||
|
SettingsBaseItem(
|
||||||
|
icon = Icons.TwoTone.RestartAlt,
|
||||||
|
title = stringResource(R.string.device_battery_estimate_reset_action),
|
||||||
|
subtitle = stringResource(R.string.device_battery_estimate_reset_desc),
|
||||||
|
onClick = { showResetConfirm = true },
|
||||||
|
)
|
||||||
|
// Same divider style the sibling device-settings cards use (see ReactionsCard).
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (chargeCap != null) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
title = stringResource(R.string.device_settings_charge_cap_label),
|
||||||
|
subtitle = stringResource(R.string.device_settings_charge_cap_description),
|
||||||
|
checked = chargeCap.enabled,
|
||||||
|
onCheckedChange = onDynamicEndOfChargeChange,
|
||||||
|
enabled = chargeCapControlEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showLiveControls) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = Icons.TwoTone.NotificationsActive,
|
||||||
|
title = stringResource(R.string.settings_charged_notification_label),
|
||||||
|
subtitle = stringResource(R.string.settings_charged_notification_description),
|
||||||
|
checked = reactions.notifyWhenCharged,
|
||||||
|
onCheckedChange = onNotifyWhenChargedChange,
|
||||||
|
requiresUpgrade = !isPro,
|
||||||
|
)
|
||||||
|
if (reactions.notifyWhenCharged) {
|
||||||
|
var thresholdValue by remember(reactions.chargedThreshold) {
|
||||||
|
mutableIntStateOf(reactions.chargedThreshold)
|
||||||
|
}
|
||||||
|
SettingsSliderItem(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
title = stringResource(R.string.settings_charged_threshold_label),
|
||||||
|
value = thresholdValue.toFloat(),
|
||||||
|
onValueChange = { thresholdValue = it.toInt() },
|
||||||
|
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
|
||||||
|
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
|
||||||
|
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
|
||||||
|
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
|
||||||
|
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
|
||||||
|
valueLabel = { "${it.toInt()}%" },
|
||||||
|
)
|
||||||
|
if (features.hasCase) {
|
||||||
|
SettingsBaseItem(
|
||||||
|
icon = Icons.TwoTone.Workspaces,
|
||||||
|
title = stringResource(R.string.settings_charged_scope_label),
|
||||||
|
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
|
||||||
|
onClick = { showChargedScopeDialog = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showChargedScopeDialog) {
|
||||||
|
ChargedSlotScopeDialog(
|
||||||
|
current = reactions.chargedSlotScope,
|
||||||
|
onSelect = {
|
||||||
|
onChargedSlotScopeChange(it)
|
||||||
|
showChargedScopeDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showChargedScopeDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showResetConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showResetConfirm = false },
|
||||||
|
title = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_title)) },
|
||||||
|
text = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showResetConfirm = false
|
||||||
|
onResetEstimate()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.device_battery_estimate_reset_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showResetConfirm = false }) {
|
||||||
|
Text(text = stringResource(R.string.general_cancel_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,8 +183,10 @@ private fun BatteryCardEnabledPreview() = PreviewWrapper {
|
|||||||
val device = state.device!!
|
val device = state.device!!
|
||||||
BatteryCard(
|
BatteryCard(
|
||||||
device = device,
|
device = device,
|
||||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
|
||||||
enabled = true,
|
isPro = true,
|
||||||
|
chargeCapControlEnabled = true,
|
||||||
|
estimateEnabled = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +197,9 @@ private fun BatteryCardDisabledPreview() = PreviewWrapper {
|
|||||||
val device = state.device!!
|
val device = state.device!!
|
||||||
BatteryCard(
|
BatteryCard(
|
||||||
device = device,
|
device = device,
|
||||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
|
||||||
enabled = false,
|
isPro = false,
|
||||||
|
chargeCapControlEnabled = false,
|
||||||
|
estimateEnabled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -18,6 +18,9 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
|
|||||||
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
|
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
|
||||||
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
||||||
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
||||||
|
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
|
||||||
|
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
|
||||||
|
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal data class DeviceInfoDetailLabels(
|
internal data class DeviceInfoDetailLabels(
|
||||||
@@ -31,14 +34,52 @@ internal data class DeviceInfoDetailLabels(
|
|||||||
val rightSerial: String,
|
val rightSerial: String,
|
||||||
val leftBonded: String,
|
val leftBonded: String,
|
||||||
val rightBonded: String,
|
val rightBonded: String,
|
||||||
|
val batteryHealth: String,
|
||||||
|
val leftBatteryHealth: String,
|
||||||
|
val rightBatteryHealth: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted.
|
||||||
|
* [pending] is a placeholder shown under the health label while NO value exists yet — the feature
|
||||||
|
* stays discoverable while the estimator is still accumulating listening sessions.
|
||||||
|
*/
|
||||||
|
internal data class BatteryHealthTexts(
|
||||||
|
val left: String? = null,
|
||||||
|
val right: String? = null,
|
||||||
|
val headset: String? = null,
|
||||||
|
val pending: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
|
||||||
|
if (health == null) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
when {
|
||||||
|
health.left != null && health.right != null -> add(
|
||||||
|
DeviceDetailItem.Paired(
|
||||||
|
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
|
||||||
|
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
|
||||||
|
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
|
||||||
|
}
|
||||||
|
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
|
||||||
|
if (isEmpty() && health.pending != null) {
|
||||||
|
add(DeviceDetailItem.Single(labels.batteryHealth, health.pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun buildDeviceInfoDetailItems(
|
internal fun buildDeviceInfoDetailItems(
|
||||||
info: AapDeviceInfo?,
|
info: AapDeviceInfo?,
|
||||||
labels: DeviceInfoDetailLabels,
|
labels: DeviceInfoDetailLabels,
|
||||||
|
batteryHealth: BatteryHealthTexts? = null,
|
||||||
formatDate: (Instant) -> String,
|
formatDate: (Instant) -> String,
|
||||||
): List<DeviceDetailItem> {
|
): List<DeviceDetailItem> {
|
||||||
if (info == null) return emptyList()
|
// Battery health is derived locally, so it's available (and shows the info button) even for
|
||||||
|
// BLE-only devices that never produce an AAP device-info response.
|
||||||
|
if (info == null) return healthItems(batteryHealth, labels)
|
||||||
return buildList {
|
return buildList {
|
||||||
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
||||||
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
||||||
@@ -82,5 +123,6 @@ internal fun buildDeviceInfoDetailItems(
|
|||||||
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
||||||
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
||||||
}
|
}
|
||||||
|
addAll(healthItems(batteryHealth, labels))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||||
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
|
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
|
||||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
|
||||||
import androidx.compose.material.icons.twotone.BluetoothConnected
|
import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||||
import androidx.compose.material.icons.twotone.Hearing
|
import androidx.compose.material.icons.twotone.Hearing
|
||||||
import androidx.compose.material.icons.twotone.LooksOne
|
import androidx.compose.material.icons.twotone.LooksOne
|
||||||
@@ -39,7 +38,6 @@ import eu.darken.capod.common.settings.SettingsSection
|
|||||||
import eu.darken.capod.common.settings.SettingsSliderItem
|
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||||
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
||||||
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
|
|
||||||
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
|
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
|
||||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
@@ -47,7 +45,6 @@ import eu.darken.capod.pods.core.apple.PodModel
|
|||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import eu.darken.capod.profiles.core.ReactionConfig
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
|
||||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -67,9 +64,6 @@ internal fun ReactionsCard(
|
|||||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||||
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
|
|
||||||
onChargedThresholdChange: (Int) -> Unit = {},
|
|
||||||
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
|
||||||
onOpenIssueTracker: () -> Unit = {},
|
onOpenIssueTracker: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val reactions = device.reactions
|
val reactions = device.reactions
|
||||||
@@ -77,7 +71,6 @@ internal fun ReactionsCard(
|
|||||||
|
|
||||||
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
||||||
var showConversationActionDialog by remember { mutableStateOf(false) }
|
var showConversationActionDialog by remember { mutableStateOf(false) }
|
||||||
var showChargedScopeDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
||||||
if (features.hasEarDetection) {
|
if (features.hasEarDetection) {
|
||||||
@@ -258,40 +251,6 @@ internal fun ReactionsCard(
|
|||||||
text = stringResource(R.string.settings_popup_info_not_in_app),
|
text = stringResource(R.string.settings_popup_info_not_in_app),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ReactionsDivider()
|
|
||||||
SettingsSwitchItem(
|
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
|
||||||
title = stringResource(R.string.settings_charged_notification_label),
|
|
||||||
subtitle = stringResource(R.string.settings_charged_notification_description),
|
|
||||||
checked = reactions.notifyWhenCharged,
|
|
||||||
onCheckedChange = onNotifyWhenChargedChange,
|
|
||||||
requiresUpgrade = !isPro,
|
|
||||||
)
|
|
||||||
if (reactions.notifyWhenCharged) {
|
|
||||||
var thresholdValue by remember(reactions.chargedThreshold) {
|
|
||||||
mutableIntStateOf(reactions.chargedThreshold)
|
|
||||||
}
|
|
||||||
SettingsSliderItem(
|
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
|
||||||
title = stringResource(R.string.settings_charged_threshold_label),
|
|
||||||
value = thresholdValue.toFloat(),
|
|
||||||
onValueChange = { thresholdValue = it.toInt() },
|
|
||||||
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
|
|
||||||
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
|
|
||||||
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
|
|
||||||
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
|
|
||||||
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
|
|
||||||
valueLabel = { "${it.toInt()}%" },
|
|
||||||
)
|
|
||||||
if (features.hasCase) {
|
|
||||||
SettingsBaseItem(
|
|
||||||
icon = Icons.TwoTone.Workspaces,
|
|
||||||
title = stringResource(R.string.settings_charged_scope_label),
|
|
||||||
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
|
|
||||||
onClick = { showChargedScopeDialog = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showConversationActionDialog) {
|
if (showConversationActionDialog) {
|
||||||
@@ -305,17 +264,6 @@ internal fun ReactionsCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showChargedScopeDialog) {
|
|
||||||
ChargedSlotScopeDialog(
|
|
||||||
current = reactions.chargedSlotScope,
|
|
||||||
onSelect = {
|
|
||||||
onChargedSlotScopeChange(it)
|
|
||||||
showChargedScopeDialog = false
|
|
||||||
},
|
|
||||||
onDismiss = { showChargedScopeDialog = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAutoConnectConditionDialog) {
|
if (showAutoConnectConditionDialog) {
|
||||||
AutoConnectConditionDialog(
|
AutoConnectConditionDialog(
|
||||||
current = reactions.autoConnectCondition,
|
current = reactions.autoConnectCondition,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
|||||||
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
|
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||||
|
import eu.darken.capod.main.ui.overview.cards.MonitorKilledHintCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||||
@@ -65,6 +66,7 @@ import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
|
|||||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -174,6 +176,9 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
|||||||
onManageDevices = { vm.goToDeviceManager() },
|
onManageDevices = { vm.goToDeviceManager() },
|
||||||
onSettings = { vm.goToSettings() },
|
onSettings = { vm.goToSettings() },
|
||||||
onTroubleShooter = { vm.goToTroubleShooter() },
|
onTroubleShooter = { vm.goToTroubleShooter() },
|
||||||
|
onOsKillShowInstructions = { vm.onOsKillShowInstructions() },
|
||||||
|
onOsKillAutostartSettings = { vm.onOsKillAutostartSettings() },
|
||||||
|
onOsKillDismiss = { vm.dismissOsKillHint() },
|
||||||
onUpgrade = { vm.onUpgrade() },
|
onUpgrade = { vm.onUpgrade() },
|
||||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||||
@@ -197,6 +202,9 @@ fun OverviewScreen(
|
|||||||
onManageDevices: () -> Unit,
|
onManageDevices: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
onTroubleShooter: () -> Unit = {},
|
onTroubleShooter: () -> Unit = {},
|
||||||
|
onOsKillShowInstructions: () -> Unit = {},
|
||||||
|
onOsKillAutostartSettings: () -> Unit = {},
|
||||||
|
onOsKillDismiss: () -> Unit = {},
|
||||||
onUpgrade: () -> Unit,
|
onUpgrade: () -> Unit,
|
||||||
onToggleUnmatched: () -> Unit,
|
onToggleUnmatched: () -> Unit,
|
||||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||||
@@ -293,6 +301,18 @@ fun OverviewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. OS killed the monitor — historical fact, shown regardless of current BT/scan state
|
||||||
|
if (state.showOsKillHint) {
|
||||||
|
item(key = "os_kill_hint") {
|
||||||
|
MonitorKilledHintCard(
|
||||||
|
showAutostartAction = state.showOsKillAutostartAction,
|
||||||
|
onShowInstructions = onOsKillShowInstructions,
|
||||||
|
onAutostartSettings = onOsKillAutostartSettings,
|
||||||
|
onDismiss = onOsKillDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Bluetooth disabled card
|
// 2. Bluetooth disabled card
|
||||||
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
|
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
|
||||||
item(key = "bluetooth_disabled") {
|
item(key = "bluetooth_disabled") {
|
||||||
@@ -333,6 +353,7 @@ fun OverviewScreen(
|
|||||||
showDebug = state.isDebug,
|
showDebug = state.isDebug,
|
||||||
now = state.now,
|
now = state.now,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
|
batteryEstimate = state.estimateFor(device),
|
||||||
onToggleCollapse = if (isToggleable) {
|
onToggleCollapse = if (isToggleable) {
|
||||||
{ onToggleDeviceExpansion(device) }
|
{ onToggleDeviceExpansion(device) }
|
||||||
} else null,
|
} else null,
|
||||||
@@ -407,6 +428,7 @@ private fun PodDeviceCard(
|
|||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
isCollapsed: Boolean = false,
|
isCollapsed: Boolean = false,
|
||||||
|
batteryEstimate: BatteryEstimate? = null,
|
||||||
onToggleCollapse: (() -> Unit)? = null,
|
onToggleCollapse: (() -> Unit)? = null,
|
||||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||||
onUpgrade: () -> Unit,
|
onUpgrade: () -> Unit,
|
||||||
@@ -417,6 +439,7 @@ private fun PodDeviceCard(
|
|||||||
device.hasDualPods -> DualPodsCard(
|
device.hasDualPods -> DualPodsCard(
|
||||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onToggleCollapse = onToggleCollapse,
|
onToggleCollapse = onToggleCollapse,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
onUpgrade = onUpgrade,
|
onUpgrade = onUpgrade,
|
||||||
@@ -426,6 +449,7 @@ private fun PodDeviceCard(
|
|||||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
||||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onToggleCollapse = onToggleCollapse,
|
onToggleCollapse = onToggleCollapse,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
onUpgrade = onUpgrade,
|
onUpgrade = onUpgrade,
|
||||||
@@ -444,7 +468,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
|||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = listOf(
|
||||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
MockPodDataProvider.singlePodMonitored(),
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
MockPodDataProvider.unknownMonitored(),
|
MockPodDataProvider.unknownMonitored(),
|
||||||
),
|
),
|
||||||
@@ -457,6 +481,16 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
|||||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||||
showUnmatchedDevices = false,
|
showUnmatchedDevices = false,
|
||||||
showReactionsHint = true,
|
showReactionsHint = true,
|
||||||
|
userExpandedIds = setOf("preview-single"),
|
||||||
|
batteryEstimates = mapOf(
|
||||||
|
"preview-dual-full" to BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
"preview-single" to BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onRequestPermission = {},
|
onRequestPermission = {},
|
||||||
onBluetoothSettings = {},
|
onBluetoothSettings = {},
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ import eu.darken.capod.main.core.GeneralSettings
|
|||||||
import eu.darken.capod.main.core.MonitorMode
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.MonitorKillGuidance
|
||||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||||
import eu.darken.capod.monitor.core.tierRank
|
import eu.darken.capod.monitor.core.tierRank
|
||||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
@@ -59,7 +63,9 @@ class OverviewViewModel @Inject constructor(
|
|||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
private val aapManager: AapConnectionManager,
|
private val aapManager: AapConnectionManager,
|
||||||
private val monitorModeResolver: MonitorModeResolver,
|
private val monitorModeResolver: MonitorModeResolver,
|
||||||
|
private val batteryEstimator: BatteryEstimator,
|
||||||
private val timeSource: TimeSource,
|
private val timeSource: TimeSource,
|
||||||
|
private val killGuidance: MonitorKillGuidance,
|
||||||
) : ViewModel4(dispatcherProvider) {
|
) : ViewModel4(dispatcherProvider) {
|
||||||
|
|
||||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||||
@@ -85,8 +91,21 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val reactionsHintDismissed: Boolean,
|
val reactionsHintDismissed: Boolean,
|
||||||
val hideUnmatchedDevices: Boolean,
|
val hideUnmatchedDevices: Boolean,
|
||||||
val showTroubleshootSuggestion: Boolean,
|
val showTroubleshootSuggestion: Boolean,
|
||||||
|
val showOsKillHint: Boolean,
|
||||||
|
val batteryEstimates: Map<String, BatteryEstimate>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the OS killed the monitor (see MonitorKillDetector) and the user hasn't dismissed
|
||||||
|
* the hint since — dismissal only hides kills up to that point, a newer kill re-shows it.
|
||||||
|
*/
|
||||||
|
private val osKillHint = combineFlows(
|
||||||
|
generalSettings.lastOsKillAt.flow,
|
||||||
|
generalSettings.osKillHintDismissedAt.flow,
|
||||||
|
) { lastKillAt, dismissedAt ->
|
||||||
|
lastKillAt > 0 && lastKillAt > dismissedAt
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
|
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
|
||||||
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
|
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
|
||||||
@@ -120,8 +139,16 @@ class OverviewViewModel @Inject constructor(
|
|||||||
generalSettings.reactionsHintDismissed.flow,
|
generalSettings.reactionsHintDismissed.flow,
|
||||||
generalSettings.hideUnmatchedDevices.flow,
|
generalSettings.hideUnmatchedDevices.flow,
|
||||||
troubleshootSuggestion,
|
troubleshootSuggestion,
|
||||||
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion ->
|
osKillHint,
|
||||||
OverviewUiSettings(reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion)
|
batteryEstimator.estimates,
|
||||||
|
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, showOsKillHint, batteryEstimates ->
|
||||||
|
OverviewUiSettings(
|
||||||
|
reactionsHintDismissed = reactionsHintDismissed,
|
||||||
|
hideUnmatchedDevices = hideUnmatched,
|
||||||
|
showTroubleshootSuggestion = showTroubleshootSuggestion,
|
||||||
|
showOsKillHint = showOsKillHint,
|
||||||
|
batteryEstimates = batteryEstimates,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -210,6 +237,9 @@ class OverviewViewModel @Inject constructor(
|
|||||||
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
|
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
|
||||||
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
|
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
|
||||||
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
|
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
|
||||||
|
showOsKillHint = uiSettings.showOsKillHint,
|
||||||
|
showOsKillAutostartAction = killGuidance.hasAutostartSettings,
|
||||||
|
batteryEstimates = uiSettings.batteryEstimates,
|
||||||
)
|
)
|
||||||
}.asLiveState()
|
}.asLiveState()
|
||||||
|
|
||||||
@@ -228,9 +258,18 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val showReactionsHint: Boolean = false,
|
val showReactionsHint: Boolean = false,
|
||||||
val hideUnmatchedDevices: Boolean = false,
|
val hideUnmatchedDevices: Boolean = false,
|
||||||
val showTroubleshootSuggestion: Boolean = false,
|
val showTroubleshootSuggestion: Boolean = false,
|
||||||
|
val showOsKillHint: Boolean = false,
|
||||||
|
val showOsKillAutostartAction: Boolean = false,
|
||||||
|
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-remaining estimate to show for [device], or null when the device has the estimate
|
||||||
|
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
|
||||||
|
*/
|
||||||
|
fun estimateFor(device: PodDevice): BatteryEstimate? = batteryEstimates.estimateFor(device)
|
||||||
|
|
||||||
/** Profile list order used as tiebreaker within each connection tier. */
|
/** Profile list order used as tiebreaker within each connection tier. */
|
||||||
private val profileOrder: Map<String, Int> by lazy {
|
private val profileOrder: Map<String, Int> by lazy {
|
||||||
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
||||||
@@ -337,6 +376,25 @@ class OverviewViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onOsKillShowInstructions() {
|
||||||
|
log(TAG, INFO) { "onOsKillShowInstructions()" }
|
||||||
|
killGuidance.openKillInstructions()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOsKillAutostartSettings() {
|
||||||
|
log(TAG, INFO) { "onOsKillAutostartSettings()" }
|
||||||
|
killGuidance.openAutostartSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissOsKillHint() {
|
||||||
|
log(TAG, INFO) { "dismissOsKillHint()" }
|
||||||
|
launch {
|
||||||
|
// Anchor the dismissal to the kill being dismissed, not wall-clock "now" — clock
|
||||||
|
// jumps must neither keep the card visible nor suppress future kills.
|
||||||
|
generalSettings.osKillHintDismissedAt.value(generalSettings.lastOsKillAt.value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun requestPermission(permission: Permission) {
|
fun requestPermission(permission: Permission) {
|
||||||
log(TAG, INFO) { "requestPermission($permission)" }
|
log(TAG, INFO) { "requestPermission($permission)" }
|
||||||
requestPermissionEvent.tryEmit(permission)
|
requestPermissionEvent.tryEmit(permission)
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ 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.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
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.devices.DualApplePods
|
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.formatBatteryDurationShort
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ fun DualPodsCard(
|
|||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
isCollapsed: Boolean = false,
|
isCollapsed: Boolean = false,
|
||||||
|
batteryEstimate: BatteryEstimate? = null,
|
||||||
onToggleCollapse: (() -> Unit)? = null,
|
onToggleCollapse: (() -> Unit)? = null,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||||
onUpgrade: (() -> Unit)? = null,
|
onUpgrade: (() -> Unit)? = null,
|
||||||
@@ -192,6 +195,7 @@ fun DualPodsCard(
|
|||||||
device = device,
|
device = device,
|
||||||
showDebug = showDebug,
|
showDebug = showDebug,
|
||||||
now = now,
|
now = now,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -204,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
|
batteryEstimate: BatteryEstimate?,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Circular battery gauges side by side
|
// Circular battery gauges side by side
|
||||||
@@ -233,6 +239,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||||
showMicrophone = device.hasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||||
|
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
@@ -245,6 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||||
showMicrophone = device.hasDualMicrophone,
|
showMicrophone = device.hasDualMicrophone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||||
|
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +309,8 @@ private fun PodGauge(
|
|||||||
isMicrophone: Boolean,
|
isMicrophone: Boolean,
|
||||||
showMicrophone: Boolean,
|
showMicrophone: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
timeRemaining: String? = null,
|
||||||
|
untilCharged: String? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
||||||
@@ -356,16 +368,27 @@ private fun PodGauge(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Battery percentage
|
// Battery percentage, with the time-remaining estimate inline next to it
|
||||||
Text(
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
text = formatBatteryPercent(context, batteryPercent),
|
Text(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
color = if (batteryPercent >= 0f) {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
MaterialTheme.colorScheme.onSurface
|
color = if (batteryPercent >= 0f) {
|
||||||
} else {
|
MaterialTheme.colorScheme.onSurface
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
} else {
|
||||||
},
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
},
|
||||||
|
modifier = Modifier.alignByBaseline(),
|
||||||
|
)
|
||||||
|
if (timeRemaining != null) {
|
||||||
|
Text(
|
||||||
|
text = "· $timeRemaining",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.alignByBaseline(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
@@ -380,6 +403,7 @@ private fun PodGauge(
|
|||||||
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
||||||
inEarLabel = stringResource(R.string.pods_inear_label),
|
inEarLabel = stringResource(R.string.pods_inear_label),
|
||||||
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
||||||
|
chargingDetail = untilCharged,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,10 +478,49 @@ private fun CaseRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||||
DualPodsCard(
|
DualPodsCard(
|
||||||
device = MockPodDataProvider.dualPodMonitoredWithAap(),
|
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
showDebug = false,
|
showDebug = false,
|
||||||
now = SystemTimeSource.now(),
|
now = SystemTimeSource.now(),
|
||||||
isPro = false,
|
isPro = false,
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
|
||||||
|
),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
|
||||||
|
// LEARNED: rate seeded from persisted history on reconnect, before this session has gathered
|
||||||
|
// enough live samples.
|
||||||
|
DualPodsCard(
|
||||||
|
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
|
showDebug = false,
|
||||||
|
now = SystemTimeSource.now(),
|
||||||
|
isPro = false,
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
|
||||||
|
// SPEC: rated estimate shown immediately on connect, before any drain has been measured.
|
||||||
|
DualPodsCard(
|
||||||
|
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||||
|
showDebug = false,
|
||||||
|
now = SystemTimeSource.now(),
|
||||||
|
isPro = false,
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
left = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
|
||||||
|
right = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
|
||||||
|
),
|
||||||
onDeviceSettings = {},
|
onDeviceSettings = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package eu.darken.capod.main.ui.overview.cards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.twotone.BatterySaver
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.compose.Preview2
|
||||||
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MonitorKilledHintCard(
|
||||||
|
showAutostartAction: Boolean,
|
||||||
|
onShowInstructions: () -> Unit,
|
||||||
|
onAutostartSettings: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.TwoTone.BatterySaver,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_os_kill_hint_label),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.overview_os_kill_hint_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Stacked instead of a single Row — up to three actions don't fit on narrow screens,
|
||||||
|
// especially localized or at increased font scale.
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
) {
|
||||||
|
Button(onClick = onShowInstructions) {
|
||||||
|
Text(text = stringResource(R.string.overview_os_kill_hint_instructions_action))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAutostartAction) {
|
||||||
|
OutlinedButton(onClick = onAutostartSettings) {
|
||||||
|
Text(text = stringResource(R.string.overview_os_kill_hint_autostart_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(text = stringResource(R.string.overview_os_kill_hint_dismiss_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun MonitorKilledHintCardPreview() = PreviewWrapper {
|
||||||
|
MonitorKilledHintCard(
|
||||||
|
showAutostartAction = true,
|
||||||
|
onShowInstructions = {},
|
||||||
|
onAutostartSettings = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -57,10 +57,12 @@ 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.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
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.batteryProgress
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||||
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 eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -73,6 +75,7 @@ fun SinglePodsCard(
|
|||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
isCollapsed: Boolean = false,
|
isCollapsed: Boolean = false,
|
||||||
|
batteryEstimate: BatteryEstimate? = null,
|
||||||
onToggleCollapse: (() -> Unit)? = null,
|
onToggleCollapse: (() -> Unit)? = null,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||||
onUpgrade: (() -> Unit)? = null,
|
onUpgrade: (() -> Unit)? = null,
|
||||||
@@ -178,6 +181,7 @@ fun SinglePodsCard(
|
|||||||
device = device,
|
device = device,
|
||||||
showDebug = showDebug,
|
showDebug = showDebug,
|
||||||
now = now,
|
now = now,
|
||||||
|
batteryEstimate = batteryEstimate,
|
||||||
onAncModeChange = onAncModeChange,
|
onAncModeChange = onAncModeChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -190,6 +194,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
showDebug: Boolean,
|
showDebug: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
|
batteryEstimate: BatteryEstimate?,
|
||||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -251,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battery text inside ring
|
// Battery percentage + time remaining, stacked inside the ring
|
||||||
Text(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
text = formatBatteryPercent(context, percent),
|
Text(
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
text = formatBatteryPercent(context, percent),
|
||||||
color = if (isKnown) {
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
MaterialTheme.colorScheme.onSurface
|
color = if (isKnown) {
|
||||||
} else {
|
MaterialTheme.colorScheme.onSurface
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
} else {
|
||||||
},
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
val headsetEstimate = batteryEstimate?.headset
|
||||||
|
if (headsetEstimate != null) {
|
||||||
|
Text(
|
||||||
|
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -277,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = stringResource(R.string.pods_charging_optimized_label),
|
label = stringResource(R.string.pods_charging_optimized_label),
|
||||||
)
|
)
|
||||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
AapPodState.ChargingState.CHARGING -> {
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
// Time-until-charged lives in the charging chip; the in-ring estimate
|
||||||
label = stringResource(R.string.pods_charging_label),
|
// always means runtime, so the two can't be confused.
|
||||||
)
|
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
|
||||||
|
?.let { formatBatteryDurationShort(context, it) }
|
||||||
|
StatusChip(
|
||||||
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
|
label = untilCharged
|
||||||
|
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
|
||||||
|
?: stringResource(R.string.pods_charging_label),
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
if (device.isBeingWorn == true) {
|
if (device.isBeingWorn == true) {
|
||||||
@@ -333,6 +358,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview2
|
||||||
|
@Composable
|
||||||
|
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
|
||||||
|
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
|
||||||
|
SinglePodsCard(
|
||||||
|
device = MockPodDataProvider.singlePodMonitored(),
|
||||||
|
showDebug = false,
|
||||||
|
now = SystemTimeSource.now(),
|
||||||
|
batteryEstimate = BatteryEstimate(
|
||||||
|
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
|
||||||
|
),
|
||||||
|
onDeviceSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ fun StatusChipRow(
|
|||||||
inEarLabel: String,
|
inEarLabel: String,
|
||||||
microphoneLabel: String,
|
microphoneLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
chargingDetail: String? = null,
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = modifier.animateContentSize(),
|
modifier = modifier.animateContentSize(),
|
||||||
@@ -82,9 +83,11 @@ fun StatusChipRow(
|
|||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = chargingOptimizedLabel,
|
label = chargingOptimizedLabel,
|
||||||
)
|
)
|
||||||
|
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
|
||||||
|
// estimate line under the percentage always means runtime, so the two can't be confused.
|
||||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||||
icon = Icons.TwoTone.BatteryChargingFull,
|
icon = Icons.TwoTone.BatteryChargingFull,
|
||||||
label = chargingLabel,
|
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
|
||||||
)
|
)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
@@ -122,6 +125,7 @@ private fun StatusChipRowAllPreview() = PreviewWrapper {
|
|||||||
chargingOptimizedLabel = "Optimized",
|
chargingOptimizedLabel = "Optimized",
|
||||||
inEarLabel = "In Ear",
|
inEarLabel = "In Ear",
|
||||||
microphoneLabel = "Mic",
|
microphoneLabel = "Mic",
|
||||||
|
chargingDetail = "25m",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ import eu.darken.capod.common.debug.logging.logTag
|
|||||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
import eu.darken.capod.common.upgrade.isPro
|
import eu.darken.capod.common.upgrade.isPro
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.takeFor
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||||
@@ -40,6 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
fun upgradeRepo(): UpgradeRepo
|
fun upgradeRepo(): UpgradeRepo
|
||||||
fun widgetSettings(): WidgetSettings
|
fun widgetSettings(): WidgetSettings
|
||||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||||
|
fun batteryEstimator(): BatteryEstimator
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
@@ -99,6 +105,13 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||||
?.collectAsState(initial = initialDevice)
|
?.collectAsState(initial = initialDevice)
|
||||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||||
|
// Empty while the monitor service isn't running — estimates simply stay hidden.
|
||||||
|
val rawEstimate by config.profileId
|
||||||
|
?.let { pid ->
|
||||||
|
remember(pid) { ep.batteryEstimator().estimates.map { it[pid] }.distinctUntilChanged() }
|
||||||
|
}
|
||||||
|
?.collectAsState(initial = null)
|
||||||
|
?: remember { androidx.compose.runtime.mutableStateOf<BatteryEstimate?>(null) }
|
||||||
val profileLabel = config.profileId?.let { pid ->
|
val profileLabel = config.profileId?.let { pid ->
|
||||||
runCatching {
|
runCatching {
|
||||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||||
@@ -117,6 +130,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
hasConfiguredProfile = config.profileId != null,
|
hasConfiguredProfile = config.profileId != null,
|
||||||
profileLabel = profileLabel,
|
profileLabel = profileLabel,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
|
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
WidgetRenderState.Message(
|
WidgetRenderState.Message(
|
||||||
@@ -138,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
|||||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||||
)
|
).withLocalizedPreviewEstimates(context)
|
||||||
|
|
||||||
provideContent {
|
provideContent {
|
||||||
GlanceWidgetContent(state = previewState, context = context)
|
GlanceWidgetContent(state = previewState, context = context)
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ private fun DualPodPreview(
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
|
||||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
|
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
|
||||||
}
|
}
|
||||||
DeviceLabel(
|
DeviceLabel(
|
||||||
label = state.deviceLabel,
|
label = state.deviceLabel,
|
||||||
@@ -111,8 +111,8 @@ private fun DualPodPreview(
|
|||||||
|
|
||||||
BatteryLayout.NARROW -> {
|
BatteryLayout.NARROW -> {
|
||||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
|
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
|
||||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
|
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
|
||||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
|
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
|
||||||
DeviceLabel(
|
DeviceLabel(
|
||||||
label = state.deviceLabel,
|
label = state.deviceLabel,
|
||||||
@@ -160,9 +160,10 @@ private fun SinglePodPreview(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(state.percent),
|
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
if (state.charging) {
|
if (state.charging) {
|
||||||
@@ -297,39 +298,54 @@ private fun PodItemRow(
|
|||||||
textColor: Color,
|
textColor: Color,
|
||||||
iconTint: ColorFilter,
|
iconTint: ColorFilter,
|
||||||
iconSize: Int = 20,
|
iconSize: Int = 20,
|
||||||
|
estimate: String? = null,
|
||||||
|
estimateBelow: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
Image(
|
Row(
|
||||||
painter = painterResource(icon),
|
horizontalArrangement = Arrangement.Center,
|
||||||
contentDescription = null,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.size(iconSize.dp),
|
) {
|
||||||
colorFilter = iconTint,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatPercent(percent),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor,
|
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
|
||||||
)
|
|
||||||
if (charging) {
|
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
painter = painterResource(icon),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(iconSize.dp),
|
||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
if (charging) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (inEar) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (inEar) {
|
if (estimateBelow && estimate != null) {
|
||||||
Image(
|
Text(
|
||||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
text = estimate,
|
||||||
contentDescription = null,
|
fontSize = 12.sp,
|
||||||
modifier = Modifier.size(20.dp),
|
color = textColor,
|
||||||
colorFilter = iconTint,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,6 +401,8 @@ private fun DeviceLabel(
|
|||||||
private fun formatPercent(percent: Float): String =
|
private fun formatPercent(percent: Float): String =
|
||||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
|
|
||||||
|
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewDualTiny11() = PreviewWrapper {
|
private fun PreviewDualTiny11() = PreviewWrapper {
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ private fun GlanceDualPod(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = GlanceModifier.padding(end = 12.dp))
|
||||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
|
||||||
}
|
}
|
||||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||||
}
|
}
|
||||||
@@ -76,8 +76,8 @@ private fun GlanceDualPod(
|
|||||||
BatteryLayout.NARROW -> {
|
BatteryLayout.NARROW -> {
|
||||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
|
||||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
|
||||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||||
}
|
}
|
||||||
@@ -122,8 +122,9 @@ private fun GlanceSinglePod(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(state.percent),
|
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
|
maxLines = 1,
|
||||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
if (state.charging) {
|
if (state.charging) {
|
||||||
@@ -257,37 +258,49 @@ private fun GlancePodItem(
|
|||||||
textStyle: TextStyle,
|
textStyle: TextStyle,
|
||||||
iconTint: ColorFilter,
|
iconTint: ColorFilter,
|
||||||
iconSize: Int = 20,
|
iconSize: Int = 20,
|
||||||
|
estimate: String? = null,
|
||||||
|
estimateBelow: Boolean = false,
|
||||||
modifier: GlanceModifier = GlanceModifier,
|
modifier: GlanceModifier = GlanceModifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Image(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
provider = ImageProvider(icon),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = GlanceModifier.size(iconSize.dp),
|
|
||||||
colorFilter = iconTint,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatGlancePercent(percent),
|
|
||||||
style = textStyle,
|
|
||||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
|
||||||
)
|
|
||||||
if (charging) {
|
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
provider = ImageProvider(icon),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = GlanceModifier.size(20.dp),
|
modifier = GlanceModifier.size(iconSize.dp),
|
||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||||
|
style = textStyle,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
if (charging) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = GlanceModifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (inEar) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = GlanceModifier.size(20.dp),
|
||||||
|
colorFilter = iconTint,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (inEar) {
|
if (estimateBelow && estimate != null) {
|
||||||
Image(
|
Text(
|
||||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
text = estimate,
|
||||||
contentDescription = null,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.size(20.dp),
|
maxLines = 1,
|
||||||
colorFilter = iconTint,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,3 +356,5 @@ private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
|||||||
|
|
||||||
private fun formatGlancePercent(percent: Float): String =
|
private fun formatGlancePercent(percent: Float): String =
|
||||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
|
|
||||||
|
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
|
|||||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
|
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
|
||||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
|
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
|
||||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
|
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
|
||||||
).copy(deviceLabel = deviceLabel)
|
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
|
||||||
}
|
}
|
||||||
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
|
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ internal data class WidgetDeviceKey(
|
|||||||
val isLeftInEar: Boolean?,
|
val isLeftInEar: Boolean?,
|
||||||
val isRightInEar: Boolean?,
|
val isRightInEar: Boolean?,
|
||||||
val isBeingWorn: Boolean?,
|
val isBeingWorn: Boolean?,
|
||||||
|
val batteryEstimateEnabled: Boolean,
|
||||||
val isAapConnected: Boolean,
|
val isAapConnected: Boolean,
|
||||||
val isAapReady: Boolean,
|
val isAapReady: Boolean,
|
||||||
val hasBleAdvertisement: Boolean,
|
val hasBleAdvertisement: Boolean,
|
||||||
@@ -48,6 +49,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
|||||||
isLeftInEar = isLeftInEar,
|
isLeftInEar = isLeftInEar,
|
||||||
isRightInEar = isRightInEar,
|
isRightInEar = isRightInEar,
|
||||||
isBeingWorn = isBeingWorn,
|
isBeingWorn = isBeingWorn,
|
||||||
|
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||||
isAapConnected = isAapConnected,
|
isAapConnected = isAapConnected,
|
||||||
isAapReady = isAapReady,
|
isAapReady = isAapReady,
|
||||||
hasBleAdvertisement = ble != null,
|
hasBleAdvertisement = ble != null,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package eu.darken.capod.main.ui.widget
|
package eu.darken.capod.main.ui.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.annotation.ColorInt
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||||
|
|
||||||
enum class BatteryLayout {
|
enum class BatteryLayout {
|
||||||
TINY_COLUMN,
|
TINY_COLUMN,
|
||||||
@@ -36,10 +38,12 @@ sealed class WidgetRenderState {
|
|||||||
val leftPercent: Float,
|
val leftPercent: Float,
|
||||||
val leftCharging: Boolean,
|
val leftCharging: Boolean,
|
||||||
val leftInEar: Boolean,
|
val leftInEar: Boolean,
|
||||||
|
val leftEstimate: String?,
|
||||||
@DrawableRes val rightIcon: Int,
|
@DrawableRes val rightIcon: Int,
|
||||||
val rightPercent: Float,
|
val rightPercent: Float,
|
||||||
val rightCharging: Boolean,
|
val rightCharging: Boolean,
|
||||||
val rightInEar: Boolean,
|
val rightInEar: Boolean,
|
||||||
|
val rightEstimate: String?,
|
||||||
@DrawableRes val caseIcon: Int,
|
@DrawableRes val caseIcon: Int,
|
||||||
val casePercent: Float,
|
val casePercent: Float,
|
||||||
val caseCharging: Boolean,
|
val caseCharging: Boolean,
|
||||||
@@ -57,6 +61,7 @@ sealed class WidgetRenderState {
|
|||||||
@DrawableRes val batteryIcon: Int,
|
@DrawableRes val batteryIcon: Int,
|
||||||
val charging: Boolean,
|
val charging: Boolean,
|
||||||
val worn: Boolean,
|
val worn: Boolean,
|
||||||
|
val estimate: String?,
|
||||||
) : WidgetRenderState()
|
) : WidgetRenderState()
|
||||||
|
|
||||||
data class Message(
|
data class Message(
|
||||||
@@ -95,10 +100,12 @@ sealed class WidgetRenderState {
|
|||||||
leftPercent = 0.85f,
|
leftPercent = 0.85f,
|
||||||
leftCharging = false,
|
leftCharging = false,
|
||||||
leftInEar = true,
|
leftInEar = true,
|
||||||
|
leftEstimate = "4h 55m",
|
||||||
rightIcon = R.drawable.device_airpods_pro2_right,
|
rightIcon = R.drawable.device_airpods_pro2_right,
|
||||||
rightPercent = 0.92f,
|
rightPercent = 0.92f,
|
||||||
rightCharging = true,
|
rightCharging = true,
|
||||||
rightInEar = false,
|
rightInEar = false,
|
||||||
|
rightEstimate = "25m",
|
||||||
caseIcon = R.drawable.device_airpods_pro2_case,
|
caseIcon = R.drawable.device_airpods_pro2_case,
|
||||||
casePercent = 1.0f,
|
casePercent = 1.0f,
|
||||||
caseCharging = false,
|
caseCharging = false,
|
||||||
@@ -122,6 +129,16 @@ sealed class WidgetRenderState {
|
|||||||
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
|
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
|
||||||
charging = false,
|
charging = false,
|
||||||
worn = true,
|
worn = true,
|
||||||
|
estimate = "14h 30m",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the preview factory's hardcoded sample estimates with properly localized ones for
|
||||||
|
* user-facing preview surfaces (widget picker, configuration screen, store screenshots).
|
||||||
|
*/
|
||||||
|
fun WidgetRenderState.DualPod.withLocalizedPreviewEstimates(context: Context): WidgetRenderState.DualPod = copy(
|
||||||
|
leftEstimate = formatBatteryDurationShort(context, 295),
|
||||||
|
rightEstimate = formatBatteryDurationShort(context, 25),
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import androidx.annotation.ColorInt
|
|||||||
import androidx.appcompat.view.ContextThemeWrapper
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
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.monitor.core.battery.BatteryEstimate
|
||||||
|
import eu.darken.capod.monitor.core.battery.displayMinutes
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
|
|
||||||
object WidgetRenderStateMapper {
|
object WidgetRenderStateMapper {
|
||||||
@@ -18,6 +21,7 @@ object WidgetRenderStateMapper {
|
|||||||
hasConfiguredProfile: Boolean,
|
hasConfiguredProfile: Boolean,
|
||||||
profileLabel: String?,
|
profileLabel: String?,
|
||||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||||
|
estimate: BatteryEstimate? = null,
|
||||||
): WidgetRenderState {
|
): WidgetRenderState {
|
||||||
val bgColor = resolvedBgColor(context, theme)
|
val bgColor = resolvedBgColor(context, theme)
|
||||||
val textColor = resolvedTextColor(context, theme)
|
val textColor = resolvedTextColor(context, theme)
|
||||||
@@ -45,10 +49,12 @@ object WidgetRenderStateMapper {
|
|||||||
leftPercent = device.batteryLeft,
|
leftPercent = device.batteryLeft,
|
||||||
leftCharging = device.isLeftPodCharging == true,
|
leftCharging = device.isLeftPodCharging == true,
|
||||||
leftInEar = device.isLeftInEar == true,
|
leftInEar = device.isLeftInEar == true,
|
||||||
|
leftEstimate = estimate?.left.estimateText(context, device.isLeftPodCharging == true),
|
||||||
rightIcon = device.rightPodIcon,
|
rightIcon = device.rightPodIcon,
|
||||||
rightPercent = device.batteryRight,
|
rightPercent = device.batteryRight,
|
||||||
rightCharging = device.isRightPodCharging == true,
|
rightCharging = device.isRightPodCharging == true,
|
||||||
rightInEar = device.isRightInEar == true,
|
rightInEar = device.isRightInEar == true,
|
||||||
|
rightEstimate = estimate?.right.estimateText(context, device.isRightPodCharging == true),
|
||||||
caseIcon = device.caseIcon,
|
caseIcon = device.caseIcon,
|
||||||
casePercent = device.batteryCase,
|
casePercent = device.batteryCase,
|
||||||
caseCharging = device.isCaseCharging == true,
|
caseCharging = device.isCaseCharging == true,
|
||||||
@@ -66,6 +72,7 @@ object WidgetRenderStateMapper {
|
|||||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||||
charging = device.isHeadsetBeingCharged == true,
|
charging = device.isHeadsetBeingCharged == true,
|
||||||
worn = device.isBeingWorn == true,
|
worn = device.isBeingWorn == true,
|
||||||
|
estimate = estimate?.headset.estimateText(context, device.isHeadsetBeingCharged == true),
|
||||||
)
|
)
|
||||||
|
|
||||||
device != null -> WidgetRenderState.Message(
|
device != null -> WidgetRenderState.Message(
|
||||||
@@ -95,6 +102,9 @@ object WidgetRenderStateMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun BatteryEstimate.Pod?.estimateText(context: Context, charging: Boolean): String? =
|
||||||
|
this?.displayMinutes(charging)?.let { formatBatteryDurationShort(context, it) }
|
||||||
|
|
||||||
@ColorInt
|
@ColorInt
|
||||||
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
|
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
|
||||||
val bgColor = theme.backgroundColor
|
val bgColor = theme.backgroundColor
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class DeviceMonitor @Inject constructor(
|
|||||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||||
reactions = profile.toReactionConfig(),
|
reactions = profile.toReactionConfig(),
|
||||||
|
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||||
isSystemConnected = profile?.address in connectedAddresses,
|
isSystemConnected = profile?.address in connectedAddresses,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,7 @@ class DeviceMonitor @Inject constructor(
|
|||||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||||
reactions = profile.toReactionConfig(),
|
reactions = profile.toReactionConfig(),
|
||||||
|
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||||
isSystemConnected = profile.address in state.connectedAddresses,
|
isSystemConnected = profile.address in state.connectedAddresses,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -181,6 +183,7 @@ class DeviceMonitor @Inject constructor(
|
|||||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||||
reactions = profile.toReactionConfig(),
|
reactions = profile.toReactionConfig(),
|
||||||
|
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||||
isSystemConnected = profile.address in connectedAddresses,
|
isSystemConnected = profile.address in connectedAddresses,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,21 @@ class DeviceMonitor @Inject constructor(
|
|||||||
dedupedLiveDevices + nonLiveDevices
|
dedupedLiveDevices + nonLiveDevices
|
||||||
}.replayingShare(appScope)
|
}.replayingShare(appScope)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection-aware "primary" device for display surfaces (ongoing notification, case popup):
|
||||||
|
* ranks by [PodDevice.tierRank] (system-connected > live > offline) with the user's
|
||||||
|
* profile-list order as the tiebreaker. Unlike [primaryDevice], this does NOT blindly pick the
|
||||||
|
* first profile, so a worn/connected pod is surfaced even when a different profile sits higher
|
||||||
|
* in the list. Shared so multiple display collectors don't each re-run the combine.
|
||||||
|
*/
|
||||||
|
val primaryDeviceByTier: Flow<PodDevice?> = combine(
|
||||||
|
devices,
|
||||||
|
profilesRepo.profiles,
|
||||||
|
) { devices, profiles ->
|
||||||
|
val profileOrder = profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
||||||
|
devices.primaryByTier(profileOrder)
|
||||||
|
}.replayingShare(appScope)
|
||||||
|
|
||||||
private val reportedPersistFailures = mutableSetOf<String>()
|
private val reportedPersistFailures = mutableSetOf<String>()
|
||||||
|
|
||||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||||
@@ -273,6 +291,7 @@ class DeviceMonitor @Inject constructor(
|
|||||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||||
reactions = profile.toReactionConfig(),
|
reactions = profile.toReactionConfig(),
|
||||||
|
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.app.ApplicationExitInfo
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Process
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
|
import eu.darken.capod.common.TimeSource
|
||||||
|
import eu.darken.capod.common.datastore.value
|
||||||
|
import eu.darken.capod.common.datastore.valueBlocking
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
|
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.hasApiLevel
|
||||||
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity of a monitoring session, persisted while the monitor runs (see
|
||||||
|
* [GeneralSettings.monitorSessionMark]). [pid] ties exit records to the exact process that was
|
||||||
|
* monitoring, [versionCode] filters out app-update kills (which report REASON_USER_REQUESTED
|
||||||
|
* before API 34).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MonitorSessionMark(
|
||||||
|
val startedAt: Long,
|
||||||
|
val pid: Int,
|
||||||
|
val versionCode: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects that the OS force-stopped us while the monitor was running (e.g. MIUI/HyperOS killing
|
||||||
|
* the app when the user clears recents), so the UI can point the user at vendor settings
|
||||||
|
* (autostart, battery restrictions, lock-in-recents).
|
||||||
|
*
|
||||||
|
* A force-stop can't be observed from inside the dying process: `onDestroy()`/`onTaskRemoved()`
|
||||||
|
* are skipped and `START_STICKY` restarts are suppressed for force-stopped apps. Instead,
|
||||||
|
* [GeneralSettings.monitorSessionMark] acts as a breadcrumb — set when monitoring starts, cleared
|
||||||
|
* on clean service destroy — and on the next monitor start the previous mark is checked against
|
||||||
|
* the system's [ApplicationExitInfo] records.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class MonitorKillDetector @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val generalSettings: GeneralSettings,
|
||||||
|
private val timeSource: TimeSource,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the monitor as running and evaluates whether the previous monitoring session was
|
||||||
|
* killed by the OS.
|
||||||
|
*
|
||||||
|
* The atomic swap makes this race-free against concurrent starts: a session that already
|
||||||
|
* restarted leaves a mark *newer* than any exit record, which never qualifies — a kill the
|
||||||
|
* system recovered from by itself needs no user-facing hint.
|
||||||
|
*/
|
||||||
|
suspend fun onMonitorStart() {
|
||||||
|
val current = MonitorSessionMark(
|
||||||
|
startedAt = timeSource.currentTimeMillis(),
|
||||||
|
pid = Process.myPid(),
|
||||||
|
versionCode = BuildConfigWrap.VERSION_CODE,
|
||||||
|
)
|
||||||
|
val previous = generalSettings.monitorSessionMark.update { current }.old
|
||||||
|
|
||||||
|
try {
|
||||||
|
evaluate(previous = previous, current = current)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, WARN) { "Exit record evaluation failed: ${e.asLog()}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the breadcrumb — call on clean service destroy. Skipped by OS force-stops. */
|
||||||
|
fun onMonitorCleanStopBlocking() {
|
||||||
|
generalSettings.monitorSessionMark.valueBlocking = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApi: lint can't follow the hasApiLevel(30) guard below.
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
private suspend fun evaluate(previous: MonitorSessionMark?, current: MonitorSessionMark) {
|
||||||
|
if (!hasApiLevel(30)) return
|
||||||
|
|
||||||
|
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
val records = activityManager.getHistoricalProcessExitReasons(null, 0, MAX_RECORDS)
|
||||||
|
if (records.isEmpty()) return
|
||||||
|
|
||||||
|
val watermark = generalSettings.exitInfoWatermark.value()
|
||||||
|
val newRecords = records.filter { it.timestamp > watermark }
|
||||||
|
// Full record dumps end up in user debug logs — field data to verify/extend the
|
||||||
|
// vendor-specific reason codes we match on.
|
||||||
|
newRecords.forEach { log(TAG, INFO) { "New exit record: $it" } }
|
||||||
|
|
||||||
|
val killedAt = findOsKill(
|
||||||
|
records = newRecords.map { ExitRecord(reason = it.reason, timestamp = it.timestamp, pid = it.pid) },
|
||||||
|
previous = previous,
|
||||||
|
currentVersionCode = current.versionCode,
|
||||||
|
)
|
||||||
|
if (killedAt != null) {
|
||||||
|
log(TAG, WARN) { "OS killed the monitor at $killedAt (session was $previous)" }
|
||||||
|
generalSettings.lastOsKillAt.value(killedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
val newest = records.maxOf { it.timestamp }
|
||||||
|
if (newest > watermark) generalSettings.exitInfoWatermark.value(newest)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ExitRecord(val reason: Int, val timestamp: Long, val pid: Int)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_RECORDS = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest exit record showing the OS killed us while the monitor was running, or null.
|
||||||
|
*
|
||||||
|
* Only [ApplicationExitInfo.REASON_USER_REQUESTED] counts: MIUI-style force-stops
|
||||||
|
* (recents-clear, security-app cleaners, settings force-stop) report it, while crashes,
|
||||||
|
* low-memory kills and self-stops must not trigger the hint. REASON_SIGNALED is deliberately
|
||||||
|
* excluded — LMKD SIGKILLs on ordinary low-memory devices would be false positives.
|
||||||
|
*
|
||||||
|
* A record must match the previous session's [MonitorSessionMark.pid] (so a swipe-kill of a
|
||||||
|
* later UI-only process isn't blamed on an old monitoring session) and postdate its start
|
||||||
|
* (which also dedups: once a new session's mark is written, older records can never match
|
||||||
|
* again). Records are ignored entirely when the app version changed, because before API 34
|
||||||
|
* package updates also killed the old process with REASON_USER_REQUESTED.
|
||||||
|
*/
|
||||||
|
internal fun findOsKill(
|
||||||
|
records: List<ExitRecord>,
|
||||||
|
previous: MonitorSessionMark?,
|
||||||
|
currentVersionCode: Long,
|
||||||
|
): Long? {
|
||||||
|
if (previous == null) return null
|
||||||
|
if (previous.versionCode != currentVersionCode) return null
|
||||||
|
return records
|
||||||
|
.filter { it.reason == ApplicationExitInfo.REASON_USER_REQUESTED }
|
||||||
|
.filter { it.pid == previous.pid }
|
||||||
|
.filter { it.timestamp > previous.startedAt }
|
||||||
|
.maxOfOrNull { it.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TAG = logTag("Monitor", "KillDetector")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import dagger.Reusable
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.darken.capod.common.WebpageTool
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
|
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 javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remedies for OS kills detected by [MonitorKillDetector]: vendor-specific "don't kill my app"
|
||||||
|
* instructions and, where the device has one (MIUI/HyperOS), the autostart permission screen.
|
||||||
|
*/
|
||||||
|
@Reusable
|
||||||
|
class MonitorKillGuidance @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val webpageTool: WebpageTool,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the MIUI/HyperOS autostart settings screen exists on this device.
|
||||||
|
* Requires the `com.miui.securitycenter` `<queries>` entry in the manifest — package
|
||||||
|
* visibility filtering makes this resolve to null otherwise on API 30+.
|
||||||
|
*/
|
||||||
|
val hasAutostartSettings: Boolean by lazy {
|
||||||
|
autostartIntent.resolveActivity(context.packageManager) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openKillInstructions() {
|
||||||
|
webpageTool.open(dontKillMyAppUrl(Build.MANUFACTURER))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openAutostartSettings() {
|
||||||
|
try {
|
||||||
|
context.startActivity(autostartIntent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ActivityNotFoundException on most builds, SecurityException on some MIUI versions.
|
||||||
|
log(TAG, WARN) { "Failed to open autostart settings: ${e.asLog()}" }
|
||||||
|
openKillInstructions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val autostartIntent: Intent
|
||||||
|
get() = Intent().apply {
|
||||||
|
component = ComponentName(
|
||||||
|
"com.miui.securitycenter",
|
||||||
|
"com.miui.permcenter.autostart.AutoStartManagementActivity",
|
||||||
|
)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal fun dontKillMyAppUrl(manufacturer: String): String {
|
||||||
|
val slug = when (manufacturer.lowercase()) {
|
||||||
|
"xiaomi", "redmi", "poco" -> "xiaomi"
|
||||||
|
"huawei", "honor" -> "huawei"
|
||||||
|
"samsung" -> "samsung"
|
||||||
|
"oneplus" -> "oneplus"
|
||||||
|
"oppo" -> "oppo"
|
||||||
|
"realme" -> "realme"
|
||||||
|
"vivo", "iqoo" -> "vivo"
|
||||||
|
"meizu" -> "meizu"
|
||||||
|
"asus" -> "asus"
|
||||||
|
"sony" -> "sony"
|
||||||
|
"lenovo" -> "lenovo"
|
||||||
|
"motorola" -> "motorola"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return if (slug != null) "https://dontkillmyapp.com/$slug" else "https://dontkillmyapp.com/"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TAG = logTag("Monitor", "KillGuidance")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,14 @@ class MonitorModeResolver @Inject constructor(
|
|||||||
profilesRepo.profiles,
|
profilesRepo.profiles,
|
||||||
nudgeCapabilityStore.availability,
|
nudgeCapabilityStore.availability,
|
||||||
) { profiles, nudge ->
|
) { profiles, nudge ->
|
||||||
val primary = profiles.firstOrNull() ?: return@combine MonitorMode.MANUAL
|
// The mode is driven by the first *addressed* profile, not just the first profile. An
|
||||||
primary.requiredMode(nudge)
|
// address-less profile can neither host an AAP session (AapAutoConnect requires an address)
|
||||||
|
// nor auto-connect, so it can only ever resolve to MANUAL. Letting such a profile sit at the
|
||||||
|
// top of the list and force MANUAL would silently stop the foreground service that keeps the
|
||||||
|
// process — and thus background AAP/stem controls — alive for an addressed profile below it.
|
||||||
|
// No addressed profile at all => genuinely nothing to monitor => MANUAL.
|
||||||
|
profiles.firstOrNull { !it.address.isNullOrBlank() }?.requiredMode(nudge)
|
||||||
|
?: MonitorMode.MANUAL
|
||||||
}
|
}
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
|
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ data class PodDevice(
|
|||||||
internal val profileLastRequestedListeningModeCycleMask: Int? = null,
|
internal val profileLastRequestedListeningModeCycleMask: Int? = null,
|
||||||
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
|
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
|
||||||
val reactions: ReactionConfig = ReactionConfig(),
|
val reactions: ReactionConfig = ReactionConfig(),
|
||||||
|
/** Whether the dashboard battery time-remaining estimate is enabled for this device (per-profile). */
|
||||||
|
val batteryEstimateEnabled: Boolean = true,
|
||||||
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
|
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
|
||||||
val isSystemConnected: Boolean = false,
|
val isSystemConnected: Boolean = false,
|
||||||
) {
|
) {
|
||||||
@@ -181,18 +183,23 @@ data class PodDevice(
|
|||||||
).minOrNull()
|
).minOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charging — AAP preferred, BLE fallback, then cached
|
// Charging — AAP preferred, BLE fallback, then cached. The cached fallback only applies while
|
||||||
|
// the device is live (connected): charging is a real-time claim ("power is flowing now") that
|
||||||
|
// we can't stand behind once the device is out of range, so we suppress it after full
|
||||||
|
// disconnect rather than showing a frozen charging state on the dimmed "cached N ago" card.
|
||||||
|
// The raw charging bits are still persisted in `cached` for callers that want the last-known
|
||||||
|
// value directly (device.cached?.is…Charging).
|
||||||
val isLeftPodCharging: Boolean?
|
val isLeftPodCharging: Boolean?
|
||||||
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
|
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging?.takeIf { isLive }
|
||||||
|
|
||||||
val isRightPodCharging: Boolean?
|
val isRightPodCharging: Boolean?
|
||||||
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
|
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging?.takeIf { isLive }
|
||||||
|
|
||||||
val isCaseCharging: Boolean?
|
val isCaseCharging: Boolean?
|
||||||
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
|
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging?.takeIf { isLive }
|
||||||
|
|
||||||
val isHeadsetBeingCharged: Boolean?
|
val isHeadsetBeingCharged: Boolean?
|
||||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
|
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging?.takeIf { isLive }
|
||||||
|
|
||||||
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
|
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
|
||||||
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
|
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ package eu.darken.capod.monitor.core
|
|||||||
* lower rank = higher priority. System-connected devices come first, then
|
* lower rank = higher priority. System-connected devices come first, then
|
||||||
* any live device (BLE or AAP), then profiled-but-offline.
|
* any live device (BLE or AAP), then profiled-but-offline.
|
||||||
*
|
*
|
||||||
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
|
* Backs [DeviceMonitor.primaryDeviceByTier], used by display surfaces (ongoing notification,
|
||||||
* non-tier-ranked for reaction flows that want "any profiled device".
|
* case popup) that should follow the worn/connected pod.
|
||||||
|
*
|
||||||
|
* Distinct from [DeviceMonitor.primaryDevice], which is intentionally non-tier-ranked: address-
|
||||||
|
* and eligibility-gated reaction flows (auto-connect targets a not-yet-connected device; sleep and
|
||||||
|
* conversation match by the event's source address) must keep "first profiled device" semantics,
|
||||||
|
* which tier ranking would break.
|
||||||
*/
|
*/
|
||||||
fun PodDevice.tierRank(): Int = when {
|
fun PodDevice.tierRank(): Int = when {
|
||||||
isSystemConnected -> 0
|
isSystemConnected -> 0
|
||||||
|
|||||||
@@ -9,20 +9,22 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|||||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapDisconnectEvent
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -37,6 +39,21 @@ class AapAutoConnect @Inject constructor(
|
|||||||
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||||
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-address count of consecutive connection attempts that never reached READY (failed socket
|
||||||
|
* connects and handshake-timeout disconnects). Drives the escalating reconnect cooldown so a
|
||||||
|
* device that keeps stalling its handshake in a crowded RF environment doesn't churn a tight
|
||||||
|
* reconnect loop. Reset to 0 once a session works (a was-ready disconnect) or the device leaves.
|
||||||
|
*/
|
||||||
|
private val preReadyFailures = ConcurrentHashMap<String, Int>()
|
||||||
|
|
||||||
|
/** Cooldown (ms) to wait before the next attempt, given how many consecutive pre-READY failures we've seen. */
|
||||||
|
private fun cooldownFor(address: String): Long {
|
||||||
|
val failures = preReadyFailures[address] ?: 0
|
||||||
|
if (failures <= 0) return 0L
|
||||||
|
return HANDSHAKE_BACKOFF[minOf(failures - 1, HANDSHAKE_BACKOFF.lastIndex)]
|
||||||
|
}
|
||||||
|
|
||||||
fun monitor(): Flow<Unit> = merge(
|
fun monitor(): Flow<Unit> = merge(
|
||||||
initialConnect(),
|
initialConnect(),
|
||||||
reconnectOnDisconnect(),
|
reconnectOnDisconnect(),
|
||||||
@@ -74,104 +91,150 @@ class AapAutoConnect @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escalating cooldown carried over from prior failed attempts (socket failures or handshake
|
||||||
|
// stalls). 0 on the first attempt so a healthy device connects without delay.
|
||||||
|
val cooldown = cooldownFor(address)
|
||||||
|
if (cooldown > 0) {
|
||||||
|
log(TAG) { "AAP initial connect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
|
||||||
|
delay(cooldown)
|
||||||
|
}
|
||||||
|
|
||||||
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" }
|
||||||
|
return
|
||||||
} 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}" }
|
||||||
|
|
||||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
|
|
||||||
// Bail out if classic BT disconnected
|
// Bail out if classic BT disconnected — device left, clear the penalty
|
||||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||||
if (address !in currentConnected) {
|
if (address !in currentConnected) {
|
||||||
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
|
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
|
||||||
break
|
preReadyFailures.remove(address)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val retryState = aapManager.allStates.value[address]
|
val retryState = aapManager.allStates.value[address]
|
||||||
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||||
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
|
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
return
|
||||||
} catch (retryException: Exception) {
|
} catch (retryException: Exception) {
|
||||||
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
|
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every attempt this episode failed at the socket level — escalate the next round's cooldown.
|
||||||
|
preReadyFailures.merge(address, 1, Int::plus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
|
// Process each disconnect on its own child coroutine so a long per-address backoff never blocks
|
||||||
.onEach { address ->
|
// another device's reconnect (the collector would otherwise serialise events). channelFlow gives
|
||||||
if (!activeReconnects.add(address)) {
|
// us a scope to launch into; it intentionally emits nothing — it stays subscribed for its lifetime.
|
||||||
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
|
private fun reconnectOnDisconnect(): Flow<Unit> = channelFlow<Unit> {
|
||||||
return@onEach
|
aapManager.disconnectEvents.collect { event ->
|
||||||
}
|
launch { handleReconnect(event) }
|
||||||
|
|
||||||
try {
|
|
||||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
|
||||||
delay(delayMs)
|
|
||||||
|
|
||||||
// Check if still profiled
|
|
||||||
val profile = profilesRepo.profiles.first()
|
|
||||||
.firstOrNull { it.address == address }
|
|
||||||
if (profile == null) {
|
|
||||||
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if still bonded
|
|
||||||
val bonded = bluetoothManager.bondedDevices().first()
|
|
||||||
.firstOrNull { it.address == address }
|
|
||||||
if (bonded == null) {
|
|
||||||
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if still classically connected
|
|
||||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
|
||||||
if (address !in currentConnected) {
|
|
||||||
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if still visible in BLE
|
|
||||||
val bleDevices = blePodMonitor.devices.first()
|
|
||||||
if (bleDevices.none { it.meta?.profile?.address == address }) {
|
|
||||||
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already reconnected (e.g., by initialConnect)
|
|
||||||
val currentState = aapManager.allStates.value[address]
|
|
||||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
|
||||||
log(TAG) { "AAP reconnect: $address already reconnected" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
|
||||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
|
||||||
log(TAG) { "AAP reconnected to $address" }
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
activeReconnects.remove(address)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
|
}.setupCommonEventHandlers(TAG) { "reconnect" }
|
||||||
.setupCommonEventHandlers(TAG) { "reconnect" }
|
|
||||||
|
private suspend fun handleReconnect(event: AapDisconnectEvent) {
|
||||||
|
val address = event.address
|
||||||
|
|
||||||
|
// A session that actually worked resets the penalty (prompt reconnect). One that never reached
|
||||||
|
// READY is a failed handshake/short session — escalate so a persistent stall backs off.
|
||||||
|
if (event.wasEverReady) {
|
||||||
|
preReadyFailures.remove(address)
|
||||||
|
} else {
|
||||||
|
preReadyFailures.merge(address, 1, Int::plus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeReconnects.add(address)) {
|
||||||
|
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Escalating cooldown before the normal retry cadence when handshakes keep stalling.
|
||||||
|
val cooldown = cooldownFor(address)
|
||||||
|
if (cooldown > 0) {
|
||||||
|
log(TAG) { "AAP reconnect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
|
||||||
|
delay(cooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||||
|
delay(delayMs)
|
||||||
|
|
||||||
|
// Check if still profiled
|
||||||
|
val profile = profilesRepo.profiles.first()
|
||||||
|
.firstOrNull { it.address == address }
|
||||||
|
if (profile == null) {
|
||||||
|
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
|
||||||
|
preReadyFailures.remove(address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if still bonded
|
||||||
|
val bonded = bluetoothManager.bondedDevices().first()
|
||||||
|
.firstOrNull { it.address == address }
|
||||||
|
if (bonded == null) {
|
||||||
|
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
|
||||||
|
preReadyFailures.remove(address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if still classically connected
|
||||||
|
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||||
|
if (address !in currentConnected) {
|
||||||
|
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
|
||||||
|
preReadyFailures.remove(address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if still visible in BLE
|
||||||
|
val bleDevices = blePodMonitor.devices.first()
|
||||||
|
if (bleDevices.none { it.meta?.profile?.address == address }) {
|
||||||
|
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
|
||||||
|
preReadyFailures.remove(address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already reconnected (e.g., by initialConnect)
|
||||||
|
val currentState = aapManager.allStates.value[address]
|
||||||
|
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||||
|
log(TAG) { "AAP reconnect: $address already reconnected" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||||
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
|
log(TAG) { "AAP reconnected to $address" }
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every attempt threw at the socket level (the device is still present but won't accept a
|
||||||
|
// socket) — escalate so we don't hammer it. Mirrors connectWithRetries. Handshake stalls
|
||||||
|
// don't reach here: their reconnect socket succeeds and we return above; they escalate via
|
||||||
|
// the never-ready disconnect event instead.
|
||||||
|
preReadyFailures.merge(address, 1, Int::plus)
|
||||||
|
} finally {
|
||||||
|
activeReconnects.remove(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
|
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
|
||||||
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
|
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
|
||||||
@@ -224,5 +287,12 @@ class AapAutoConnect @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Monitor", "AapAutoConnect")
|
private val TAG = logTag("Monitor", "AapAutoConnect")
|
||||||
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalating cooldown (ms) applied before a reconnect/connect attempt, indexed by
|
||||||
|
* consecutive pre-READY failures minus one. Caps at 60s so a device that perpetually stalls
|
||||||
|
* its handshake in crowded RF settles into a slow ~minute cadence instead of a tight loop.
|
||||||
|
*/
|
||||||
|
internal val HANDSHAKE_BACKOFF = longArrayOf(5_000, 15_000, 30_000, 60_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package eu.darken.capod.monitor.core.battery
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
|
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.serialization.SerializationCapod
|
||||||
|
import eu.darken.capod.profiles.core.ProfileId
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists learned battery drain rates per profile so the time-remaining estimate survives app
|
||||||
|
* restarts and is available immediately on reconnect. Mirrors [eu.darken.capod.monitor.core.cache.DeviceStateCache]:
|
||||||
|
* one small JSON file per profile, guarded by a [Mutex], read/written on IO, broadcast via a
|
||||||
|
* [StateFlow]. Per-profile files mean one corrupt write can only lose a single device's history.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class BatteryDrainStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
|
@SerializationCapod private val json: Json,
|
||||||
|
) {
|
||||||
|
private val storeDir by lazy {
|
||||||
|
File(context.filesDir, "battery_drain_rates").apply { mkdirs() }
|
||||||
|
}
|
||||||
|
private val lock = Mutex()
|
||||||
|
|
||||||
|
private val _profiles = MutableStateFlow<Map<ProfileId, DrainProfile>>(emptyMap())
|
||||||
|
val profiles: StateFlow<Map<ProfileId, DrainProfile>> = _profiles
|
||||||
|
|
||||||
|
init {
|
||||||
|
appScope.launch { loadAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
|
||||||
|
lock.withLock {
|
||||||
|
val dir = storeDir
|
||||||
|
if (!dir.exists()) return@withLock
|
||||||
|
|
||||||
|
val loaded = mutableMapOf<ProfileId, DrainProfile>()
|
||||||
|
dir.listFiles()
|
||||||
|
?.filter { it.name.startsWith(PREFIX) && it.name.endsWith(SUFFIX) }
|
||||||
|
?.forEach { file ->
|
||||||
|
val profileId = file.name.removePrefix(PREFIX).removeSuffix(SUFFIX)
|
||||||
|
try {
|
||||||
|
loaded[profileId] = json.decodeFromString<DrainProfile>(file.readText())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, Logging.Priority.ERROR) { "Failed to load ${file.name}: ${e.asLog()}, deleting" }
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
|
||||||
|
_profiles.value = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProfileId.toFile(): File = File(storeDir, "$PREFIX$this$SUFFIX")
|
||||||
|
|
||||||
|
suspend fun save(id: ProfileId, profile: DrainProfile) = withContext(dispatcherProvider.IO) {
|
||||||
|
lock.withLock {
|
||||||
|
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id, rates=${profile.rates.keys})" }
|
||||||
|
val file = id.toFile()
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
try {
|
||||||
|
tmp.writeText(json.encodeToString(DrainProfile.serializer(), profile))
|
||||||
|
if (!tmp.renameTo(file)) {
|
||||||
|
// renameTo can fail across some filesystems; fall back to a direct write.
|
||||||
|
file.writeText(tmp.readText())
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
_profiles.value += (id to profile)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, Logging.Priority.ERROR) { "Failed to save $id: ${e.asLog()}" }
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
|
||||||
|
lock.withLock {
|
||||||
|
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
|
||||||
|
val file = id.toFile()
|
||||||
|
if (file.exists() && !file.delete()) {
|
||||||
|
// In-memory is cleared regardless; warn so a failed delete (which would resurrect the
|
||||||
|
// rate on the next loadAll()) is at least visible rather than silently undoing a reset.
|
||||||
|
log(TAG, Logging.Priority.ERROR) { "delete($id): failed to remove $file" }
|
||||||
|
}
|
||||||
|
_profiles.value -= id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
|
||||||
|
lock.withLock {
|
||||||
|
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
|
||||||
|
storeDir.listFiles()?.forEach { it.delete() }
|
||||||
|
_profiles.value = emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Monitor", "BatteryDrainStore")
|
||||||
|
private const val PREFIX = "drainrate_"
|
||||||
|
private const val SUFFIX = ".json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package eu.darken.capod.monitor.core.battery
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-pod time-remaining estimates for one device. Each earbud is measured and shown independently
|
||||||
|
* (the mic pod, for example, drains faster). [headset] is used for single-headset devices; [left] /
|
||||||
|
* [right] for dual-pod devices. Slots with no usable estimate are null.
|
||||||
|
*/
|
||||||
|
data class BatteryEstimate(
|
||||||
|
val left: Pod? = null,
|
||||||
|
val right: Pod? = null,
|
||||||
|
val headset: Pod? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* @property minutesRemaining smoothed estimate of minutes until this pod empties
|
||||||
|
* @property fractionPerHour the drain rate it was derived from (fraction/hour)
|
||||||
|
* @property source how the drain was determined (see [Source]) — reflects measurement provenance,
|
||||||
|
* NOT whether the model rating capped the shown value; a LIVE/LEARNED estimate can still be
|
||||||
|
* bounded by the model's rated life
|
||||||
|
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
|
||||||
|
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
|
||||||
|
* or the final trickle phase). Shown inside the charging chip; [minutesRemaining] stays on
|
||||||
|
* the gauge line as the runtime projection.
|
||||||
|
*/
|
||||||
|
data class Pod(
|
||||||
|
val minutesRemaining: Int,
|
||||||
|
val fractionPerHour: Float,
|
||||||
|
val source: Source,
|
||||||
|
val minutesUntilCharged: Int? = null,
|
||||||
|
) {
|
||||||
|
/** True while the estimate rests on the model's rated spec, before any drain has been measured. */
|
||||||
|
val isProvisional: Boolean get() = source == Source.SPEC
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a pod's drain rate was derived from, in order of increasing confidence. */
|
||||||
|
enum class Source {
|
||||||
|
/** Apple's published rating — shown immediately on connect, before any drain is observed. */
|
||||||
|
SPEC,
|
||||||
|
|
||||||
|
/** Persisted rate from earlier sessions with this device. */
|
||||||
|
LEARNED,
|
||||||
|
|
||||||
|
/** Measured from the current session's observed drain. */
|
||||||
|
LIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package eu.darken.capod.monitor.core.battery
|
||||||
|
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns this estimate only if [device] should display it: the per-device toggle is on, the
|
||||||
|
* device is live (no estimates for cached/offline cards), and it maps to a profile.
|
||||||
|
*/
|
||||||
|
fun BatteryEstimate.takeFor(device: PodDevice): BatteryEstimate? = takeIf {
|
||||||
|
device.batteryEstimateEnabled && device.isLive && device.profileId != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up and gates the estimate to show for [device]. Shared by all surfaces (dashboard,
|
||||||
|
* widget, notification) so the display rules can't drift apart.
|
||||||
|
*/
|
||||||
|
fun Map<String, BatteryEstimate>.estimateFor(device: PodDevice): BatteryEstimate? =
|
||||||
|
device.profileId?.let { this[it] }?.takeFor(device)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes to display for one pod: the time-until-full while [charging] (null when no usable
|
||||||
|
* charge ETA exists, e.g. during an Optimized Battery Charging hold), otherwise the runtime
|
||||||
|
* projection. [charging] should be the same flag that drives the visible charging indicator,
|
||||||
|
* so the shown duration never contradicts the bolt icon next to it.
|
||||||
|
*/
|
||||||
|
fun BatteryEstimate.Pod.displayMinutes(charging: Boolean): Int? = when {
|
||||||
|
charging -> minutesUntilCharged
|
||||||
|
else -> minutesRemaining
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-slot minutes a surface would actually render for [device], or null when no slot shows
|
||||||
|
* anything. Everything else on the estimate (rates, source) churns without visible effect —
|
||||||
|
* dedupe render triggers on this key so invisible changes don't refresh widgets/notifications.
|
||||||
|
*/
|
||||||
|
fun BatteryEstimate.displayKey(device: PodDevice): List<Int?>? = listOf(
|
||||||
|
left?.displayMinutes(device.isLeftPodCharging == true),
|
||||||
|
right?.displayMinutes(device.isRightPodCharging == true),
|
||||||
|
headset?.displayMinutes(device.isHeadsetBeingCharged == true),
|
||||||
|
).takeUnless { key -> key.all { it == null } }
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
package eu.darken.capod.monitor.core.battery
|
||||||
|
|
||||||
|
import android.media.AudioManager
|
||||||
|
import eu.darken.capod.common.TimeSource
|
||||||
|
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 eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
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.ble.DualBlePodSnapshot
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
|
import eu.darken.capod.profiles.core.ProfileId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Learns each device's battery drain rate from observed levels over time and turns it into a
|
||||||
|
* time-remaining estimate for the earbuds. Rates are learned per ANC mode (drain differs sharply
|
||||||
|
* between OFF / ON / Transparency / Adaptive) and persisted via [BatteryDrainStore] so an estimate
|
||||||
|
* is available immediately on reconnect.
|
||||||
|
*
|
||||||
|
* Lifecycle: [monitor] is launched by the foreground monitor service (see
|
||||||
|
* `MonitorService.doMonitor`), NOT eagerly in `init`. `DeviceMonitor.devices` starts BLE scanning
|
||||||
|
* while it has a subscriber, so a permanent subscription would keep scanning alive forever; gating
|
||||||
|
* on the service keeps sampling tied to active monitoring. All mutable state below is touched only
|
||||||
|
* from the single `monitor()` collector; [estimates] is the read-only output other components observe.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class BatteryEstimator @Inject constructor(
|
||||||
|
private val deviceMonitor: DeviceMonitor,
|
||||||
|
private val drainStore: BatteryDrainStore,
|
||||||
|
private val timeSource: TimeSource,
|
||||||
|
private val audioManager: AudioManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
|
||||||
|
val estimates: StateFlow<Map<ProfileId, BatteryEstimate>> = _estimates
|
||||||
|
|
||||||
|
private enum class Slot { LEFT, RIGHT, HEADSET }
|
||||||
|
|
||||||
|
/** Which transport a battery reading came from — AAP is 1% granularity, BLE 10%. */
|
||||||
|
private enum class DataSource { AAP, BLE }
|
||||||
|
|
||||||
|
private class SlotHistory {
|
||||||
|
enum class Direction { DRAIN, CHARGE }
|
||||||
|
|
||||||
|
var direction: Direction = Direction.DRAIN
|
||||||
|
private set
|
||||||
|
private var source: DataSource? = null
|
||||||
|
private val samples = ArrayDeque<DrainSample>()
|
||||||
|
|
||||||
|
val lastFraction: Float? get() = samples.lastOrNull()?.fraction
|
||||||
|
val size: Int get() = samples.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the window only while it still describes the same thing: a direction flip
|
||||||
|
* (drain <-> charge) obviously invalidates it, and so does an AAP <-> BLE source change —
|
||||||
|
* the granularity jump (1% vs 10%) between transports would read as a fake level step.
|
||||||
|
*/
|
||||||
|
fun matches(direction: Direction, source: DataSource): Boolean =
|
||||||
|
this.direction == direction && this.source == source
|
||||||
|
|
||||||
|
fun realign(direction: Direction, source: DataSource) {
|
||||||
|
if (!matches(direction, source)) samples.clear()
|
||||||
|
this.direction = direction
|
||||||
|
this.source = source
|
||||||
|
}
|
||||||
|
|
||||||
|
fun record(sample: DrainSample) {
|
||||||
|
samples.addLast(sample)
|
||||||
|
while (samples.size > RING_SIZE) samples.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() = samples.clear()
|
||||||
|
|
||||||
|
fun toList(): List<DrainSample> = samples.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DeviceTracker {
|
||||||
|
var modeBucket: String = MODE_UNKNOWN
|
||||||
|
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parallel drain windows fed ONLY while the pod is worn, audio is playing, and this device
|
||||||
|
* is the system's audio sink — pure listening segments, the basis for battery health.
|
||||||
|
*/
|
||||||
|
val listeningSlots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
|
||||||
|
|
||||||
|
/** Fit + sample count of a just-closed listening segment, persisted on the next pass. */
|
||||||
|
val pendingListeningFits: MutableMap<Slot, Pair<Float, Int>> = mutableMapOf()
|
||||||
|
|
||||||
|
/** Smoothed displayed minutes, per pod. */
|
||||||
|
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
|
||||||
|
var lastUpdateMs: Long? = null
|
||||||
|
|
||||||
|
/** When each slot's level last visibly ROSE while charging — drives stall suppression. */
|
||||||
|
val lastRiseMs: MutableMap<Slot, Long> = mutableMapOf()
|
||||||
|
|
||||||
|
// Keyed by "<bucket>/<slot>" for drain rates, "CHARGE/<slot>" for charge rates.
|
||||||
|
val lastPersistAtMs: MutableMap<String, Long> = mutableMapOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-session stored rate captured once per (bucket, slot), so repeated periodic persists
|
||||||
|
* during a single session blend against a fixed baseline instead of runaway-converging.
|
||||||
|
* [sessionBaselineCounts] captures the matching pre-session updateCount, so repeated persists
|
||||||
|
* within one session count as ONE update, not many.
|
||||||
|
*/
|
||||||
|
val sessionBaseline: MutableMap<String, Float?> = mutableMapOf()
|
||||||
|
val sessionBaselineCounts: MutableMap<String, Int> = mutableMapOf()
|
||||||
|
|
||||||
|
fun clearSlots() = slots.values.forEach { it.clear() }
|
||||||
|
|
||||||
|
fun resetWindow() {
|
||||||
|
clearSlots()
|
||||||
|
listeningSlots.values.forEach { it.clear() }
|
||||||
|
pendingListeningFits.clear()
|
||||||
|
lastMinutes.clear()
|
||||||
|
lastRiseMs.clear()
|
||||||
|
sessionBaseline.clear()
|
||||||
|
sessionBaselineCounts.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
|
||||||
|
|
||||||
|
// Serialises process() against reset() so an in-flight persist can't resurrect a just-wiped rate.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipes ALL learned state for [profileId] — the in-memory tracker, the current estimate, and the
|
||||||
|
* persisted rates — under the same lock as [process], so it's atomic w.r.t. sampling/persisting.
|
||||||
|
* The next live emission re-seeds the device from its model rating. Safe to call when the monitor
|
||||||
|
* isn't running (trackers/estimates are already empty; only the store delete has effect).
|
||||||
|
*/
|
||||||
|
suspend fun reset(profileId: ProfileId) = withContext(NonCancellable) {
|
||||||
|
// NonCancellable so a cancelled caller (e.g. the settings screen closing) can't leave the
|
||||||
|
// wipe half-applied — memory cleared but persisted rates surviving to resurrect later.
|
||||||
|
mutex.withLock {
|
||||||
|
log(TAG) { "reset($profileId)" }
|
||||||
|
trackers.remove(profileId)
|
||||||
|
_estimates.value = _estimates.value - profileId
|
||||||
|
drainStore.delete(profileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun monitor(): Flow<Unit> = deviceMonitor.devices
|
||||||
|
.onEach { devices -> process(devices) }
|
||||||
|
.onCompletion {
|
||||||
|
// The collector stops with the monitor service; drop session state so the UI can't
|
||||||
|
// keep showing a stale estimate and the next session re-seeds from persistence.
|
||||||
|
trackers.clear()
|
||||||
|
_estimates.value = emptyMap()
|
||||||
|
}
|
||||||
|
.map { }
|
||||||
|
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
|
||||||
|
|
||||||
|
private suspend fun process(devices: List<PodDevice>) = mutex.withLock {
|
||||||
|
// Only profiles with a single, unambiguous live candidate that has the estimate enabled.
|
||||||
|
// DeviceMonitor keeps multiple same-profile devices when there's no IRK-verified match, and
|
||||||
|
// blending two physical devices' levels would be garbage — skip those. A device with the
|
||||||
|
// feature disabled is skipped entirely (not sampled, not persisted).
|
||||||
|
val unambiguous = devices
|
||||||
|
.filter { it.profileId != null && it.isLive && it.batteryEstimateEnabled }
|
||||||
|
.groupBy { it.profileId!! }
|
||||||
|
.filter { (_, group) -> group.size == 1 }
|
||||||
|
.mapValues { (_, group) -> group.single() }
|
||||||
|
|
||||||
|
val next = _estimates.value.toMutableMap()
|
||||||
|
// Drop estimates for profiles no longer live/unambiguous/enabled this emission (offline gating).
|
||||||
|
next.keys.retainAll(unambiguous.keys)
|
||||||
|
|
||||||
|
// Sampled once per emission — the gate for health-grade "listening" segments.
|
||||||
|
val musicActive = audioManager.isMusicActive
|
||||||
|
|
||||||
|
for ((profileId, device) in unambiguous) {
|
||||||
|
val estimate = updateTracker(profileId, device, musicActive)
|
||||||
|
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
_estimates.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice, musicActive: Boolean): BatteryEstimate? {
|
||||||
|
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
|
||||||
|
val nowMs = timeSource.elapsedRealtime()
|
||||||
|
val bucket = device.modeBucket()
|
||||||
|
|
||||||
|
// A long gap since the last update means the device was out of range / reconnected — the
|
||||||
|
// prior window is stale and must not be extended across the absence.
|
||||||
|
val gap = tracker.lastUpdateMs?.let { nowMs - it }
|
||||||
|
if (gap != null && gap > STALE_GAP_MS) tracker.resetWindow()
|
||||||
|
tracker.lastUpdateMs = nowMs
|
||||||
|
|
||||||
|
// A mode change invalidates the current window — flush what we learned to the OLD bucket,
|
||||||
|
// then start fresh so OFF-rate samples never blend into ON-rate.
|
||||||
|
if (tracker.modeBucket != bucket) {
|
||||||
|
persistFromWindow(profileId, tracker, device, tracker.modeBucket, nowMs, force = true)
|
||||||
|
tracker.resetWindow()
|
||||||
|
tracker.modeBucket = bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slot in Slot.entries) {
|
||||||
|
val history = tracker.slots.getValue(slot)
|
||||||
|
val charging = device.liveCharging(slot)
|
||||||
|
val reading = device.liveReading(slot)
|
||||||
|
|
||||||
|
when {
|
||||||
|
reading == null -> { // unavailable reading → just drop this slot's window
|
||||||
|
history.clear()
|
||||||
|
tracker.lastRiseMs.remove(slot)
|
||||||
|
// A charging jump breaks discharge continuity even when the level is unreadable.
|
||||||
|
if (charging == true) tracker.lastMinutes.remove(slot)
|
||||||
|
}
|
||||||
|
charging == true -> { // battery rising → sample the CHARGE, never a drain
|
||||||
|
val (fraction, source) = reading
|
||||||
|
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop discharge smoothing
|
||||||
|
if (device.liveChargingOptimized(slot)) {
|
||||||
|
// Optimized Battery Charging parks the level below full for hours while
|
||||||
|
// still flagged charging — fitting that plateau would learn garbage.
|
||||||
|
history.clear()
|
||||||
|
tracker.lastRiseMs.remove(slot)
|
||||||
|
} else {
|
||||||
|
history.realign(SlotHistory.Direction.CHARGE, source)
|
||||||
|
val last = history.lastFraction
|
||||||
|
when {
|
||||||
|
last == null -> { // charge session starts (or resumes) for this slot
|
||||||
|
history.record(DrainSample(nowMs, fraction))
|
||||||
|
tracker.lastRiseMs[slot] = nowMs
|
||||||
|
}
|
||||||
|
fraction > last + EPSILON -> {
|
||||||
|
history.record(DrainSample(nowMs, fraction))
|
||||||
|
tracker.lastRiseMs[slot] = nowMs
|
||||||
|
}
|
||||||
|
fraction < last - EPSILON -> { // level DROPPED while charging → reseat/swap
|
||||||
|
history.clear()
|
||||||
|
history.record(DrainSample(nowMs, fraction))
|
||||||
|
tracker.lastRiseMs[slot] = nowMs
|
||||||
|
}
|
||||||
|
else -> Unit // ~unchanged; stall detection judges the silence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> { // draining (or unknown charging state — treated as draining, as before)
|
||||||
|
val (fraction, source) = reading
|
||||||
|
tracker.lastRiseMs.remove(slot)
|
||||||
|
history.realign(SlotHistory.Direction.DRAIN, source)
|
||||||
|
val last = history.lastFraction
|
||||||
|
when {
|
||||||
|
last == null -> history.record(DrainSample(nowMs, fraction))
|
||||||
|
fraction > last + EPSILON -> { // level went UP (reseat/swap) → reset
|
||||||
|
history.clear()
|
||||||
|
history.record(DrainSample(nowMs, fraction))
|
||||||
|
tracker.lastMinutes.remove(slot)
|
||||||
|
}
|
||||||
|
fraction < last - EPSILON -> history.record(DrainSample(nowMs, fraction)) // a drop
|
||||||
|
else -> Unit // ~unchanged, no new information
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health-grade listening window: only pure segments count — the pod worn, audio playing,
|
||||||
|
// and this device the audio sink (isMusicActive alone would count phone-speaker
|
||||||
|
// playback). The moment the gate breaks, the finished segment's fit is captured for
|
||||||
|
// persistence and the window cleared; mixed idle/listening samples would flatten the
|
||||||
|
// slope and re-dilute health.
|
||||||
|
val listening = charging != true && reading != null &&
|
||||||
|
musicActive && device.isSystemConnected && device.wornForSlot(slot)
|
||||||
|
val listeningHistory = tracker.listeningSlots.getValue(slot)
|
||||||
|
if (listening) {
|
||||||
|
val (fraction, source) = reading!!
|
||||||
|
// An AAP<->BLE flip mid-listening still ends a PURE segment — flush it rather
|
||||||
|
// than letting realign silently discard it.
|
||||||
|
if (!listeningHistory.matches(SlotHistory.Direction.DRAIN, source)) {
|
||||||
|
captureListeningSegment(tracker, slot)
|
||||||
|
}
|
||||||
|
listeningHistory.realign(SlotHistory.Direction.DRAIN, source)
|
||||||
|
val last = listeningHistory.lastFraction
|
||||||
|
when {
|
||||||
|
last == null -> listeningHistory.record(DrainSample(nowMs, fraction))
|
||||||
|
fraction > last + EPSILON -> { // reseat mid-listening → fresh segment
|
||||||
|
listeningHistory.clear()
|
||||||
|
listeningHistory.record(DrainSample(nowMs, fraction))
|
||||||
|
}
|
||||||
|
fraction < last - EPSILON -> listeningHistory.record(DrainSample(nowMs, fraction))
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
captureListeningSegment(tracker, slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
|
||||||
|
return computeEstimate(profileId, tracker, device, bucket, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Computes an independent estimate for each pod (left / right / headset). */
|
||||||
|
private fun computeEstimate(
|
||||||
|
profileId: ProfileId,
|
||||||
|
tracker: DeviceTracker,
|
||||||
|
device: PodDevice,
|
||||||
|
bucket: String,
|
||||||
|
nowMs: Long,
|
||||||
|
): BatteryEstimate? {
|
||||||
|
val estimate = BatteryEstimate(
|
||||||
|
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT, nowMs),
|
||||||
|
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT, nowMs),
|
||||||
|
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET, nowMs),
|
||||||
|
)
|
||||||
|
return estimate.takeIf { it.hasAny }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun slotEstimate(
|
||||||
|
profileId: ProfileId,
|
||||||
|
tracker: DeviceTracker,
|
||||||
|
device: PodDevice,
|
||||||
|
bucket: String,
|
||||||
|
slot: Slot,
|
||||||
|
nowMs: Long,
|
||||||
|
): BatteryEstimate.Pod? {
|
||||||
|
val fraction = device.liveFraction(slot) ?: return null
|
||||||
|
|
||||||
|
val spec = device.specRate(bucket)
|
||||||
|
// While charging the battery is rising, so there's no live drain to fit — project the runtime
|
||||||
|
// "if used now" from the learned rate or the model rating instead. Live sampling is skipped
|
||||||
|
// here (and updateTracker already clears the window while charging), so a rising level is
|
||||||
|
// never learned as a drain — we only surface a projection so the estimate stays visible in
|
||||||
|
// the case, climbing as the pod charges.
|
||||||
|
val charging = device.liveCharging(slot) == true
|
||||||
|
val live = if (charging) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
|
||||||
|
?.takeIf { plausibleForModel(it, spec) }
|
||||||
|
}
|
||||||
|
val learned = learnedRate(profileId, device, bucket, slot)
|
||||||
|
val displayRate = live ?: learned ?: spec ?: return null
|
||||||
|
|
||||||
|
// Apple's rating is a hard ceiling on remaining life (a floor on the drain rate) for every
|
||||||
|
// source: a degraded battery only ever drains FASTER than new-condition spec, so a measured
|
||||||
|
// rate normally wins — spec only bites when a source implausibly implies MORE life than Apple.
|
||||||
|
val effectiveRate = spec?.let { maxOf(displayRate, it) } ?: displayRate
|
||||||
|
|
||||||
|
val minutes = DrainModel.minutesRemaining(fraction, effectiveRate) ?: return null
|
||||||
|
// Smooth against the last displayed value — but while charging neither read nor write that
|
||||||
|
// state: the in-case projection is shown raw and must not pollute the discharge history, else
|
||||||
|
// the first estimate after undocking would blend against a stale charging projection.
|
||||||
|
var smoothed = DrainModel.blendMinutes(if (charging) null else tracker.lastMinutes[slot], minutes)
|
||||||
|
// Smoothing lags, so just after a drop it can sit above the ceiling — clamp the shown value to
|
||||||
|
// the spec cap too, so we never DISPLAY more life than Apple rates even mid-transition.
|
||||||
|
spec?.let { DrainModel.minutesRemaining(fraction, it) }?.let { smoothed = minOf(smoothed, it) }
|
||||||
|
if (!charging) tracker.lastMinutes[slot] = smoothed
|
||||||
|
|
||||||
|
val source = when {
|
||||||
|
live != null -> BatteryEstimate.Source.LIVE
|
||||||
|
learned != null -> BatteryEstimate.Source.LEARNED
|
||||||
|
else -> BatteryEstimate.Source.SPEC
|
||||||
|
}
|
||||||
|
return BatteryEstimate.Pod(
|
||||||
|
minutesRemaining = smoothed,
|
||||||
|
fractionPerHour = effectiveRate,
|
||||||
|
source = source,
|
||||||
|
minutesUntilCharged = if (charging) chargeEstimate(profileId, tracker, device, slot, fraction, nowMs) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes until [slot] is full, or null when no usable charge rate exists, the pod is in an
|
||||||
|
* Optimized Battery Charging hold, or the level has sat still longer than one visible step
|
||||||
|
* should take (stall — trickle phase or an unreported hold; a linear ETA would just freeze).
|
||||||
|
*/
|
||||||
|
private fun chargeEstimate(
|
||||||
|
profileId: ProfileId,
|
||||||
|
tracker: DeviceTracker,
|
||||||
|
device: PodDevice,
|
||||||
|
slot: Slot,
|
||||||
|
fraction: Float,
|
||||||
|
nowMs: Long,
|
||||||
|
): Int? {
|
||||||
|
if (device.liveChargingOptimized(slot)) return null // held below full — an ETA would mislead
|
||||||
|
val history = tracker.slots.getValue(slot)
|
||||||
|
val ring = if (history.direction == SlotHistory.Direction.CHARGE) history.toList() else emptyList()
|
||||||
|
val liveScalar = if (ring.isNotEmpty()) DrainModel.chargeSlopeFractionPerHour(ring) else null
|
||||||
|
val learnedScalar = learnedChargeRate(profileId, device, slot)
|
||||||
|
val specRate = device.model.batterySpec?.chargeFractionPerHour
|
||||||
|
|
||||||
|
// Per band: this session's in-band fit, then the learned band rate, then the scalar
|
||||||
|
// fallbacks, then Apple's quick-charge claim with the band's taper haircut (the claim
|
||||||
|
// measures the bulk phase; scalars already average what was actually observed).
|
||||||
|
fun rateFor(band: DrainModel.ChargeBand): Float? =
|
||||||
|
(if (ring.isNotEmpty()) DrainModel.chargeBandSlopeFractionPerHour(ring, band) else null)
|
||||||
|
?: learnedChargeBand(profileId, device, slot, band)
|
||||||
|
?: liveScalar
|
||||||
|
?: learnedScalar
|
||||||
|
?: specRate?.let { it * band.specMultiplier }
|
||||||
|
|
||||||
|
val currentBand = DrainModel.ChargeBand.entries.firstOrNull { fraction < it.to }
|
||||||
|
?: DrainModel.ChargeBand.TRICKLE
|
||||||
|
val stallRate = rateFor(currentBand) ?: return null
|
||||||
|
val lastRise = tracker.lastRiseMs[slot] ?: return null
|
||||||
|
val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE
|
||||||
|
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(stallRate, step)) return null
|
||||||
|
|
||||||
|
return DrainModel.minutesUntilFull(fraction, ::rateFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun learnedChargeBand(
|
||||||
|
profileId: ProfileId,
|
||||||
|
device: PodDevice,
|
||||||
|
slot: Slot,
|
||||||
|
band: DrainModel.ChargeBand,
|
||||||
|
): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next
|
||||||
|
* persist pass writes it, bypassing cadence) and the window cleared either way.
|
||||||
|
*/
|
||||||
|
private fun captureListeningSegment(tracker: DeviceTracker, slot: Slot) {
|
||||||
|
val history = tracker.listeningSlots.getValue(slot)
|
||||||
|
if (history.size == 0) return
|
||||||
|
DrainModel.slopeFractionPerHour(history.toList())?.let {
|
||||||
|
tracker.pendingListeningFits[slot] = it to history.size
|
||||||
|
}
|
||||||
|
history.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the pod in [slot] is being worn — per-pod for buds, whole-device for headsets. */
|
||||||
|
private fun PodDevice.wornForSlot(slot: Slot): Boolean = when (slot) {
|
||||||
|
Slot.LEFT -> isLeftInEar
|
||||||
|
Slot.RIGHT -> isRightInEar
|
||||||
|
Slot.HEADSET -> isBeingWorn
|
||||||
|
} == true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists each pod's live drain or charge rate (whichever direction its window currently
|
||||||
|
* tracks), at most once per [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence)
|
||||||
|
* unless [force]d (mode change). Drain rates are keyed per (bucket, slot); charge rates per slot
|
||||||
|
* only — the ANC mode doesn't apply inside the case.
|
||||||
|
*/
|
||||||
|
private suspend fun persistFromWindow(
|
||||||
|
profileId: ProfileId,
|
||||||
|
tracker: DeviceTracker,
|
||||||
|
device: PodDevice,
|
||||||
|
bucket: String,
|
||||||
|
nowMs: Long,
|
||||||
|
force: Boolean,
|
||||||
|
) {
|
||||||
|
// A profile tagged with DIFFERENT hardware means the user re-pointed it — its rates don't
|
||||||
|
// describe this device, so learning starts over instead of blending into foreign history.
|
||||||
|
val existing = drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
|
||||||
|
?: DrainProfile()
|
||||||
|
val spec = device.specRate(bucket)
|
||||||
|
var rates = existing.rates
|
||||||
|
var chargeRates = existing.chargeRates
|
||||||
|
var changed = false
|
||||||
|
|
||||||
|
var chargeBands = existing.chargeBands
|
||||||
|
var listeningRates = existing.listeningRates
|
||||||
|
|
||||||
|
// Blend against the rate stored when this session began, captured once per key, so a single
|
||||||
|
// long session's repeated writes can't dominate prior history by re-blending their own
|
||||||
|
// output. The captured updateCount keeps a whole session counting as ONE accumulated update.
|
||||||
|
fun blended(cadenceKey: String, stored: DrainProfile.LearnedRate?, fit: Float, samples: Int): DrainProfile.LearnedRate {
|
||||||
|
if (!tracker.sessionBaseline.containsKey(cadenceKey)) {
|
||||||
|
tracker.sessionBaseline[cadenceKey] = stored?.fractionPerHour
|
||||||
|
tracker.sessionBaselineCounts[cadenceKey] = stored?.updateCount ?: 0
|
||||||
|
}
|
||||||
|
return DrainProfile.LearnedRate(
|
||||||
|
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[cadenceKey], fit),
|
||||||
|
sampleCount = samples,
|
||||||
|
updateCount = (tracker.sessionBaselineCounts[cadenceKey] ?: 0) + 1,
|
||||||
|
updatedAt = timeSource.now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// True at most once per PERSIST_INTERVAL_MS per key (bypassed on [force] or [always]).
|
||||||
|
fun cadenceOk(cadenceKey: String, always: Boolean = false): Boolean {
|
||||||
|
val last = tracker.lastPersistAtMs[cadenceKey]
|
||||||
|
if (!always && !force && last != null && nowMs - last < PERSIST_INTERVAL_MS) return false
|
||||||
|
tracker.lastPersistAtMs[cadenceKey] = nowMs
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slot in Slot.entries) {
|
||||||
|
val history = tracker.slots.getValue(slot)
|
||||||
|
|
||||||
|
if (history.direction == SlotHistory.Direction.CHARGE) {
|
||||||
|
// Whole-session scalar — the fallback basis and the stall-threshold reference.
|
||||||
|
DrainModel.chargeSlopeFractionPerHour(history.toList())?.let { fit ->
|
||||||
|
val key = chargeRateKey(slot)
|
||||||
|
if (cadenceOk(key)) {
|
||||||
|
chargeRates = chargeRates + (slot.name to blended(key, chargeRates[slot.name], fit, history.size))
|
||||||
|
changed = true
|
||||||
|
log(TAG, VERBOSE) { "Persisting charge rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Per-band rates — charging is CC/CV, each regime learns its own speed.
|
||||||
|
for (band in DrainModel.ChargeBand.entries) {
|
||||||
|
val fit = DrainModel.chargeBandSlopeFractionPerHour(history.toList(), band) ?: continue
|
||||||
|
val key = "${chargeRateKey(slot)}/${band.name}"
|
||||||
|
if (!cadenceOk(key)) continue
|
||||||
|
val stored = chargeBands[slot.name]?.get(band.name)
|
||||||
|
val slotBands = chargeBands[slot.name].orEmpty() + (band.name to blended(key, stored, fit, history.size))
|
||||||
|
chargeBands = chargeBands + (slot.name to slotBands)
|
||||||
|
changed = true
|
||||||
|
log(TAG, VERBOSE) { "Persisting charge band for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
|
||||||
|
DrainModel.slopeFractionPerHour(history.toList())
|
||||||
|
?.takeIf { plausibleForModel(it, spec) }
|
||||||
|
?.let { fit ->
|
||||||
|
val key = rateKey(bucket, slot)
|
||||||
|
if (cadenceOk(key)) {
|
||||||
|
rates = rates + (key to blended(key, rates[key], fit, history.size))
|
||||||
|
changed = true
|
||||||
|
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listening rates (health basis): a just-closed segment persists immediately — it would
|
||||||
|
// be lost otherwise, the window is already cleared. An ongoing window follows cadence.
|
||||||
|
val pending = tracker.pendingListeningFits.remove(slot)
|
||||||
|
val (fit, samples) = when {
|
||||||
|
pending != null -> pending
|
||||||
|
else -> DrainModel.slopeFractionPerHour(tracker.listeningSlots.getValue(slot).toList())
|
||||||
|
?.let { it to tracker.listeningSlots.getValue(slot).size } ?: (null to 0)
|
||||||
|
}
|
||||||
|
if (fit != null && plausibleForModel(fit, spec)) {
|
||||||
|
val key = rateKey(bucket, slot)
|
||||||
|
val cadenceKey = "LISTEN/$key"
|
||||||
|
if (cadenceOk(cadenceKey, always = pending != null)) {
|
||||||
|
listeningRates = listeningRates + (key to blended(cadenceKey, listeningRates[key], fit, samples))
|
||||||
|
changed = true
|
||||||
|
log(TAG, VERBOSE) { "Persisting listening rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
drainStore.save(
|
||||||
|
profileId,
|
||||||
|
existing.copy(
|
||||||
|
model = device.model.name,
|
||||||
|
rates = rates,
|
||||||
|
chargeRates = chargeRates,
|
||||||
|
chargeBands = chargeBands,
|
||||||
|
listeningRates = listeningRates,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
|
||||||
|
val profile = storedProfileFor(profileId, device) ?: return null
|
||||||
|
// Exact per-mode learning always wins — a real measurement for THIS mode.
|
||||||
|
profile.rates[rateKey(bucket, slot)]?.validFractionPerHour()?.let { return it }
|
||||||
|
// Empty bucket: fill it conservatively with the less-optimistic (faster-draining) of the
|
||||||
|
// mode-agnostic UNKNOWN reading and the spec-scaled sibling reading, so toggling ANC into an
|
||||||
|
// unlearned mode never inflates the estimate past a real sibling measurement.
|
||||||
|
val unknown = profile.rates[rateKey(MODE_UNKNOWN, slot)]?.validFractionPerHour()
|
||||||
|
val sibling = siblingScaledRate(profile, device, bucket, slot)
|
||||||
|
return listOfNotNull(unknown, sibling).maxOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills an empty ANC bucket by borrowing another mode's learned rate, scaled to this mode by the
|
||||||
|
* ratio of the two modes' rated drain (`predicted = sibling × specRate(current)/specRate(sibling)`).
|
||||||
|
* Keeps the estimate continuous across an ANC toggle instead of jumping to the optimistic spec.
|
||||||
|
*
|
||||||
|
* Only fires when:
|
||||||
|
* - the current bucket is a real ANC mode (an UNKNOWN / mode-not-known reading keeps its
|
||||||
|
* conservative spec-min behaviour), and
|
||||||
|
* - the model publishes ratings for both modes (without a rating there's no [effectiveRate] ceiling
|
||||||
|
* or display clamp, so a borrowed rate could over-promise unbounded), and
|
||||||
|
* - the sibling mode is one the device reports as supported (ignore stale keys for modes this
|
||||||
|
* hardware can't use), falling back to all modes only when the supported list is unavailable.
|
||||||
|
*
|
||||||
|
* Among the candidates the best-evidenced one wins, tie-broken by closest rated drain (best
|
||||||
|
* physical predictor) then recency. Returns null when nothing trustworthy is available; the caller
|
||||||
|
* merges the result conservatively with the UNKNOWN reading.
|
||||||
|
*/
|
||||||
|
private fun siblingScaledRate(profile: DrainProfile, device: PodDevice, bucket: String, slot: Slot): Float? {
|
||||||
|
if (bucket == MODE_UNKNOWN) return null
|
||||||
|
val targetSpec = device.specRate(bucket) ?: return null
|
||||||
|
val supported = device.ancMode?.supported?.map { it.name }?.takeIf { it.isNotEmpty() }
|
||||||
|
val best = AapSetting.AncMode.Value.entries
|
||||||
|
.map { it.name }
|
||||||
|
.filter { it != bucket && (supported == null || it in supported) }
|
||||||
|
.mapNotNull { sib ->
|
||||||
|
val siblingSpec = device.specRate(sib) ?: return@mapNotNull null
|
||||||
|
val learned = profile.rates[rateKey(sib, slot)]
|
||||||
|
?.takeIf { it.fractionPerHour.isFinite() && it.fractionPerHour > 0f }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
learned to siblingSpec
|
||||||
|
}
|
||||||
|
.maxWithOrNull(
|
||||||
|
compareBy<Pair<DrainProfile.LearnedRate, Float>> { it.first.updateCount }
|
||||||
|
.thenBy { -abs(targetSpec - it.second) }
|
||||||
|
.thenBy { it.first.updatedAt },
|
||||||
|
) ?: return null
|
||||||
|
return (best.first.fractionPerHour * (targetSpec / best.second)).takeIf { it.isFinite() && it > 0f }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrainProfile.LearnedRate.validFractionPerHour(): Float? =
|
||||||
|
fractionPerHour.takeIf { it.isFinite() && it > 0f }
|
||||||
|
|
||||||
|
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
|
||||||
|
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
|
||||||
|
|
||||||
|
/** The stored profile, ignored entirely when its rates were learned on different hardware. */
|
||||||
|
private fun storedProfileFor(profileId: ProfileId, device: PodDevice): DrainProfile? =
|
||||||
|
drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
|
||||||
|
|
||||||
|
private fun rateKey(bucket: String, slot: Slot): String = "$bucket/${slot.name}"
|
||||||
|
|
||||||
|
/** Session-state key for charge windows — namespaced so it can't collide with an ANC bucket. */
|
||||||
|
private fun chargeRateKey(slot: Slot): String = "CHARGE/${slot.name}"
|
||||||
|
|
||||||
|
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple's rated drain (fraction/hour) for this model in the given ANC bucket, or null when the
|
||||||
|
* model has no published rating (Beats / unknown). Used to seed an estimate before any drain is
|
||||||
|
* observed and as the upper bound on remaining life. When the mode isn't known yet (BLE-only, or
|
||||||
|
* a just-connected AAP session) the shorter of the two ratings is used, so an unknown mode can't
|
||||||
|
* over-promise.
|
||||||
|
*/
|
||||||
|
private fun PodDevice.specRate(bucket: String): Float? {
|
||||||
|
val spec = model.batterySpec ?: return null
|
||||||
|
val on = spec.listeningHoursAncOn
|
||||||
|
val off = spec.listeningHoursAncOff
|
||||||
|
val hours = when (bucket) {
|
||||||
|
AapSetting.AncMode.Value.OFF.name -> off ?: on
|
||||||
|
MODE_UNKNOWN -> listOfNotNull(on, off).minOrNull()
|
||||||
|
else -> on ?: off // ON / TRANSPARENCY / ADAPTIVE
|
||||||
|
} ?: return null
|
||||||
|
return if (hours.isFinite() && hours > 0f) 1f / hours else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A measured rate is trusted only when it isn't absurdly faster than the model's rated drain. The
|
||||||
|
* slow side is intentionally NOT bounded: a genuinely gentle drain is real data worth learning,
|
||||||
|
* and the spec ceiling already stops a slow rate from over-reporting on screen.
|
||||||
|
*/
|
||||||
|
private fun plausibleForModel(rate: Float, spec: Float?): Boolean =
|
||||||
|
spec == null || rate <= spec * SPEC_BAND_MAX
|
||||||
|
|
||||||
|
// RAW LIVE extraction — must NOT use device.batteryLeft/isLeftPodCharging (which fall back to
|
||||||
|
// cache); learning from a re-stamped stale reading would poison the rate.
|
||||||
|
private fun PodDevice.liveFraction(slot: Slot): Float? = liveReading(slot)?.first
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slot's live battery fraction plus which transport reported it. The source matters because
|
||||||
|
* the two granularities (AAP 1%, BLE 10%) can't share a fit window — see [SlotHistory.realign].
|
||||||
|
*/
|
||||||
|
private fun PodDevice.liveReading(slot: Slot): Pair<Float, DataSource>? {
|
||||||
|
val aapValue = when (slot) {
|
||||||
|
Slot.LEFT -> aap?.batteryLeft
|
||||||
|
Slot.RIGHT -> aap?.batteryRight
|
||||||
|
Slot.HEADSET -> aap?.batteryHeadset
|
||||||
|
}
|
||||||
|
val (value, source) = when {
|
||||||
|
aapValue != null -> aapValue to DataSource.AAP
|
||||||
|
else -> when (slot) {
|
||||||
|
Slot.LEFT -> (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||||
|
Slot.RIGHT -> (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||||
|
Slot.HEADSET -> (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||||
|
}?.let { it to DataSource.BLE } ?: return null
|
||||||
|
}
|
||||||
|
// coerce defends against a malformed >1 reading, which would otherwise beat the full-charge spec.
|
||||||
|
return value.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)?.let { it to source }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
|
||||||
|
Slot.LEFT -> aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
|
||||||
|
Slot.RIGHT -> aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
|
||||||
|
Slot.HEADSET -> aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only AAP reports the Optimized Battery Charging hold; BLE can't distinguish it. */
|
||||||
|
private fun PodDevice.liveChargingOptimized(slot: Slot): Boolean = when (slot) {
|
||||||
|
Slot.LEFT -> aap?.leftChargingState
|
||||||
|
Slot.RIGHT -> aap?.rightChargingState
|
||||||
|
Slot.HEADSET -> aap?.headsetChargingState
|
||||||
|
} == AapPodState.ChargingState.CHARGING_OPTIMIZED
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Monitor", "BatteryEstimator")
|
||||||
|
private const val RING_SIZE = 32
|
||||||
|
private const val EPSILON = 0.001f
|
||||||
|
private const val MODE_UNKNOWN = DrainProfile.BUCKET_UNKNOWN
|
||||||
|
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
|
||||||
|
|
||||||
|
/** Visible battery step per transport — feeds the granularity-aware stall threshold. */
|
||||||
|
private const val STEP_AAP = 0.01f
|
||||||
|
private const val STEP_BLE = 0.10f
|
||||||
|
|
||||||
|
/** A measured rate above this multiple of the model's rated drain is rejected as implausible. */
|
||||||
|
private const val SPEC_BAND_MAX = 4f
|
||||||
|
|
||||||
|
/** A gap longer than this between updates means the device was away — reset its window. */
|
||||||
|
private const val STALE_GAP_MS = 15 * 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package eu.darken.capod.monitor.core.battery
|
||||||
|
|
||||||
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
|
||||||
|
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
|
||||||
|
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
|
||||||
|
*
|
||||||
|
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
|
||||||
|
* genuinely diverge, and a combined figure would mask a failing pod. Only [DrainProfile.listeningRates]
|
||||||
|
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
|
||||||
|
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
|
||||||
|
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
|
||||||
|
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
|
||||||
|
* the UI.
|
||||||
|
*/
|
||||||
|
object BatteryHealth {
|
||||||
|
|
||||||
|
/** A learned rate must have accumulated this many separate sessions before it counts. */
|
||||||
|
const val MIN_UPDATE_COUNT = 3
|
||||||
|
|
||||||
|
data class PerPod(
|
||||||
|
val left: Int? = null,
|
||||||
|
val right: Int? = null,
|
||||||
|
val headset: Int? = null,
|
||||||
|
) {
|
||||||
|
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
|
||||||
|
if (profile == null) return null
|
||||||
|
val spec = model.batterySpec ?: return null
|
||||||
|
if (!profile.matchesModel(model)) return null
|
||||||
|
|
||||||
|
return PerPod(
|
||||||
|
left = slotPercent(profile, spec, "LEFT"),
|
||||||
|
right = slotPercent(profile, spec, "RIGHT"),
|
||||||
|
headset = slotPercent(profile, spec, "HEADSET"),
|
||||||
|
).takeIf { it.hasAny }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
|
||||||
|
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
|
||||||
|
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
|
||||||
|
// future-format data and must not feed a health figure.
|
||||||
|
val parts = key.split('/')
|
||||||
|
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
|
||||||
|
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
|
||||||
|
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
|
||||||
|
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
|
||||||
|
(1f / specHours) / rate.fractionPerHour
|
||||||
|
}
|
||||||
|
if (ratios.isEmpty()) return null
|
||||||
|
|
||||||
|
val sorted = ratios.sorted()
|
||||||
|
val median = if (sorted.size % 2 == 1) {
|
||||||
|
sorted[sorted.size / 2]
|
||||||
|
} else {
|
||||||
|
(sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2f
|
||||||
|
}
|
||||||
|
return (median * 100f).roundToInt().coerceIn(1, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rated hours a rate learned in [bucket] should be judged against. UNKNOWN-bucket usage
|
||||||
|
* can't be matched to a specific mode, so it's compared to the middle of the two ratings —
|
||||||
|
* the shorter one would systematically flatter health, the longer one would slander it.
|
||||||
|
* Malformed or unrecognized bucket keys yield null (entry is skipped).
|
||||||
|
*/
|
||||||
|
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
|
||||||
|
val on = spec.listeningHoursAncOn
|
||||||
|
val off = spec.listeningHoursAncOff
|
||||||
|
return when (bucket) {
|
||||||
|
AapSetting.AncMode.Value.OFF.name -> off ?: on
|
||||||
|
AapSetting.AncMode.Value.ON.name,
|
||||||
|
AapSetting.AncMode.Value.TRANSPARENCY.name,
|
||||||
|
AapSetting.AncMode.Value.ADAPTIVE.name,
|
||||||
|
-> on ?: off
|
||||||
|
DrainProfile.BUCKET_UNKNOWN -> listOfNotNull(on, off).takeIf { it.isNotEmpty() }?.average()?.toFloat()
|
||||||
|
else -> null
|
||||||
|
}?.takeIf { it.isFinite() && it > 0f }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package eu.darken.capod.monitor.core.battery
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single battery observation for one slot (left / right / headset).
|
||||||
|
*
|
||||||
|
* [atElapsedMs] is a monotonic timestamp ([eu.darken.capod.common.TimeSource.elapsedRealtime]) so
|
||||||
|
* wall-clock jumps can't corrupt the regression. [fraction] is a battery level in `0.0..1.0`
|
||||||
|
* (the same unit used everywhere else in the app), NOT a 0..100 percentage.
|
||||||
|
*/
|
||||||
|
data class DrainSample(
|
||||||
|
val atElapsedMs: Long,
|
||||||
|
val fraction: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure, side-effect-free drain-rate math. Kept separate from [BatteryEstimator] so the numerics can
|
||||||
|
* be unit-tested without Android, coroutines, or persistence.
|
||||||
|
*
|
||||||
|
* All rates are **fraction per hour** (e.g. `0.169` = 16.9 %/hr) to match the `0.0..1.0` battery unit.
|
||||||
|
* Mixing this up with a 0..100 percentage would produce a 100× error, so the unit is in the name.
|
||||||
|
*/
|
||||||
|
object DrainModel {
|
||||||
|
|
||||||
|
/** Minimum samples before a live regression is trusted. */
|
||||||
|
const val MIN_SAMPLES = 4
|
||||||
|
|
||||||
|
/** Minimum span between oldest and newest sample. Rejects bursts of rapid 1% ticks. */
|
||||||
|
const val MIN_SPAN_MS = 3 * 60_000L
|
||||||
|
|
||||||
|
/** Minimum total drop across the window. Rejects noise that isn't a real discharge. */
|
||||||
|
const val MIN_TOTAL_DROP = 0.03f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only samples within this window of the newest one feed the regression. Drops stale points
|
||||||
|
* from before a long gap (out of range / overnight) so a reconnect can't fit a line across
|
||||||
|
* hours of absence.
|
||||||
|
*/
|
||||||
|
const val MAX_SAMPLE_AGE_MS = 2 * 60 * 60_000L
|
||||||
|
|
||||||
|
/** Plausible drain-rate band (fraction/hour). Outside this, the estimate is rejected. */
|
||||||
|
const val RATE_MIN = 0.02f
|
||||||
|
const val RATE_MAX = 0.80f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge fits get by with fewer samples than drain fits: a charge session is short (well under
|
||||||
|
* an hour) and BLE's 10% steps would otherwise need most of the session before a fit exists.
|
||||||
|
*/
|
||||||
|
const val MIN_SAMPLES_CHARGE = 3
|
||||||
|
|
||||||
|
/** Minimum total rise across the charge window — rejects jitter that isn't a real charge. */
|
||||||
|
const val MIN_TOTAL_RISE = 0.05f
|
||||||
|
|
||||||
|
/** Plausible charge-rate band (fraction/hour): a full charge in 15 minutes .. 4 hours. */
|
||||||
|
const val CHARGE_RATE_MIN = 0.25f
|
||||||
|
const val CHARGE_RATE_MAX = 4.0f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this level the "until charged" estimate is suppressed. The trickle band models the slow
|
||||||
|
* tail, so suppression only covers the last sliver where the firmware is about to flip the
|
||||||
|
* charging flag off at 100% anyway.
|
||||||
|
*/
|
||||||
|
const val NEAR_FULL_SUPPRESS = 0.99f
|
||||||
|
|
||||||
|
/** Narrow bands (10% wide) accept a two-point fit — a full BLE band is exactly two ticks. */
|
||||||
|
const val MIN_SAMPLES_CHARGE_NARROW = 2
|
||||||
|
|
||||||
|
/** Minimum rise WITHIN a band before its fit is trusted (half a narrow band). */
|
||||||
|
const val MIN_BAND_RISE = 0.05f
|
||||||
|
|
||||||
|
/** [chargeStallThresholdMs] never goes below this, however fast the rate claims to be. */
|
||||||
|
const val CHARGE_STALL_FLOOR_MS = 10 * 60_000L
|
||||||
|
|
||||||
|
/** Estimates above this are implausible and suppressed. */
|
||||||
|
const val MAX_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoothing for the displayed minutes. Asymmetric on purpose: react quickly when the estimate
|
||||||
|
* DROPS (less time left — e.g. a degraded battery measured draining faster than its rating, or a
|
||||||
|
* first live fit undercutting the spec seed) so we don't keep showing more life than the latest
|
||||||
|
* reading supports, but ease UP slowly to swallow upward noise and stay conservative.
|
||||||
|
*/
|
||||||
|
const val MINUTES_ALPHA_DOWN = 0.6f
|
||||||
|
const val MINUTES_ALPHA_UP = 0.25f
|
||||||
|
|
||||||
|
/** Smoothing for the persisted per-mode learned rate. */
|
||||||
|
const val LEARN_ALPHA = 0.3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Least-squares slope of [samples] (fraction vs. hours) as a positive drain rate in
|
||||||
|
* fraction/hour, or null if there aren't enough samples, the window is too short/small, the
|
||||||
|
* battery isn't actually draining, or the result is outside [RATE_MIN]..[RATE_MAX].
|
||||||
|
*/
|
||||||
|
fun slopeFractionPerHour(samples: List<DrainSample>): Float? {
|
||||||
|
val recent = recentWindow(samples, MIN_SAMPLES) ?: return null
|
||||||
|
if (recent.first().fraction - recent.last().fraction < MIN_TOTAL_DROP) return null
|
||||||
|
|
||||||
|
// Negative slope == draining; flip to a positive drain rate.
|
||||||
|
val rate = regressionSlopePerHour(recent)?.let { -it } ?: return null
|
||||||
|
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Least-squares slope of RISING [samples] (a charging pod) as a positive charge rate in
|
||||||
|
* fraction/hour, with the same window guards as the drain fit but charge-tuned thresholds.
|
||||||
|
*/
|
||||||
|
fun chargeSlopeFractionPerHour(samples: List<DrainSample>): Float? {
|
||||||
|
val recent = recentWindow(samples, MIN_SAMPLES_CHARGE) ?: return null
|
||||||
|
if (recent.last().fraction - recent.first().fraction < MIN_TOTAL_RISE) return null
|
||||||
|
|
||||||
|
val rate = regressionSlopePerHour(recent) ?: return null
|
||||||
|
if (!rate.isFinite() || rate < CHARGE_RATE_MIN || rate > CHARGE_RATE_MAX) return null
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regimes of a lithium CC/CV charge. Constant-current bulk is fast and roughly linear;
|
||||||
|
* once the cell hits its voltage ceiling the charger switches to constant-voltage and the
|
||||||
|
* current decays, ending in a slow trickle. One linear rate over-promises badly above ~80%,
|
||||||
|
* so each band learns its own rate.
|
||||||
|
*
|
||||||
|
* Band boundaries and seeds are grounded rather than guessed:
|
||||||
|
* - The 80% bulk boundary is Apple's own documented behavior for their devices ("uses fast
|
||||||
|
* charging to quickly reach 80% ... then switches to slower trickle charging",
|
||||||
|
* apple.com/batteries/why-lithium-ion, mirrored in the iPhone user guide).
|
||||||
|
* - Battery-charging literature places the CC->CV transition at ~75-85% SoC (lower at high
|
||||||
|
* charge currents) and describes the CV current as decaying until cutoff, with the
|
||||||
|
* saturation stage taking a large share of total charge time (Battery University BU-409,
|
||||||
|
* "Charging Lithium-ion").
|
||||||
|
* - Two CV bands, not more: the CV current decays continuously, so finer bands would model
|
||||||
|
* noise while multiplying the state to learn. With the 0.5/0.3 seeds below, the last 20%
|
||||||
|
* takes ~40% of the seeded total charge time — inside BU-409's reported envelope.
|
||||||
|
*
|
||||||
|
* [specMultiplier] scales the spec-derived seed for the band: Apple's quick-charge claims
|
||||||
|
* ("5 minutes = ~1 hour of listening") measure the bulk phase, so seeding the taper/trickle
|
||||||
|
* bands from them needs the CV haircut. Applied ONLY to the spec seed — measured or learned
|
||||||
|
* rates already reflect where they were observed.
|
||||||
|
*/
|
||||||
|
enum class ChargeBand(val from: Float, val to: Float, val specMultiplier: Float) {
|
||||||
|
BULK(0.0f, 0.8f, 1.0f),
|
||||||
|
TAPER(0.8f, 0.9f, 0.5f),
|
||||||
|
TRICKLE(0.9f, 1.0f, 0.3f),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Least-squares charge rate fitted ONLY to the recent samples inside [band], or null when the
|
||||||
|
* band lacks coverage. Narrow bands accept two points (a full BLE band is exactly two ticks);
|
||||||
|
* the wide bulk band keeps the regular sample requirement.
|
||||||
|
*/
|
||||||
|
fun chargeBandSlopeFractionPerHour(samples: List<DrainSample>, band: ChargeBand): Float? {
|
||||||
|
val newestMs = samples.lastOrNull()?.atElapsedMs ?: return null
|
||||||
|
val recent = samples.filter {
|
||||||
|
newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS && it.fraction in band.from..band.to
|
||||||
|
}
|
||||||
|
val minSamples = if (band == ChargeBand.BULK) MIN_SAMPLES_CHARGE else MIN_SAMPLES_CHARGE_NARROW
|
||||||
|
if (recent.size < minSamples) return null
|
||||||
|
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
|
||||||
|
if (recent.last().fraction - recent.first().fraction < MIN_BAND_RISE) return null
|
||||||
|
|
||||||
|
val rate = regressionSlopePerHour(recent) ?: return null
|
||||||
|
// The taper/trickle bands are legitimately slower than any plausible bulk rate.
|
||||||
|
val floor = CHARGE_RATE_MIN * band.specMultiplier
|
||||||
|
return rate.takeIf { it.isFinite() && it >= floor && it <= CHARGE_RATE_MAX }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes until [levelFraction] reaches full, walking the remaining [ChargeBand]s at
|
||||||
|
* [rateForBand]'s per-band rates (partial current band + all bands above it). Null when the
|
||||||
|
* level is already in the suppression sliver ([NEAR_FULL_SUPPRESS]), any needed band has no
|
||||||
|
* usable rate, or the result is implausible.
|
||||||
|
*/
|
||||||
|
fun minutesUntilFull(levelFraction: Float, rateForBand: (ChargeBand) -> Float?): Int? {
|
||||||
|
if (!levelFraction.isFinite() || levelFraction < 0f) return null
|
||||||
|
if (levelFraction >= NEAR_FULL_SUPPRESS) return null
|
||||||
|
|
||||||
|
var totalMinutes = 0.0
|
||||||
|
for (band in ChargeBand.entries) {
|
||||||
|
val start = maxOf(levelFraction, band.from)
|
||||||
|
val missing = band.to - start
|
||||||
|
if (missing <= 0f) continue
|
||||||
|
val rate = rateForBand(band) ?: return null
|
||||||
|
if (rate <= 0f) return null
|
||||||
|
totalMinutes += missing / rate * 60.0
|
||||||
|
}
|
||||||
|
return totalMinutes.roundToInt().takeIf { it in 1..MAX_MINUTES }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the level may sit unchanged while charging before the "until charged" estimate is
|
||||||
|
* considered stalled (Optimized Battery Charging hold, trickle phase, or a dead reading) and
|
||||||
|
* suppressed. Granularity-aware: at [chargeFractionPerHour] a single visible step of
|
||||||
|
* [stepFraction] (1% on AAP, 10% on BLE) takes `step/rate` hours — a slow BLE charge legitimately
|
||||||
|
* shows no change for ~20 minutes, so a fixed timeout would falsely suppress it.
|
||||||
|
*/
|
||||||
|
fun chargeStallThresholdMs(chargeFractionPerHour: Float, stepFraction: Float): Long {
|
||||||
|
if (chargeFractionPerHour <= 0f) return CHARGE_STALL_FLOOR_MS
|
||||||
|
val stepMs = (stepFraction.toDouble() / chargeFractionPerHour * 3_600_000).roundToLong()
|
||||||
|
return maxOf(CHARGE_STALL_FLOOR_MS, stepMs * 3 / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samples within [MAX_SAMPLE_AGE_MS] of the newest, or null when count/span guards fail. */
|
||||||
|
private fun recentWindow(samples: List<DrainSample>, minSamples: Int): List<DrainSample>? {
|
||||||
|
if (samples.size < minSamples) return null
|
||||||
|
|
||||||
|
// Restrict to the recent window so a gap before the newest sample can't span the fit.
|
||||||
|
val newestMs = samples.last().atElapsedMs
|
||||||
|
val recent = samples.filter { newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS }
|
||||||
|
if (recent.size < minSamples) return null
|
||||||
|
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
|
||||||
|
return recent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed least-squares slope (fraction per hour) over [recent]; negative when draining. */
|
||||||
|
private fun regressionSlopePerHour(recent: List<DrainSample>): Float? {
|
||||||
|
val first = recent.first()
|
||||||
|
val n = recent.size.toDouble()
|
||||||
|
var sumX = 0.0
|
||||||
|
var sumY = 0.0
|
||||||
|
var sumXY = 0.0
|
||||||
|
var sumXX = 0.0
|
||||||
|
for (s in recent) {
|
||||||
|
val x = (s.atElapsedMs - first.atElapsedMs) / 3_600_000.0 // hours since first
|
||||||
|
val y = s.fraction.toDouble()
|
||||||
|
sumX += x
|
||||||
|
sumY += y
|
||||||
|
sumXY += x * y
|
||||||
|
sumXX += x * x
|
||||||
|
}
|
||||||
|
val denominator = n * sumXX - sumX * sumX
|
||||||
|
if (abs(denominator) < 1e-9) return null
|
||||||
|
return ((n * sumXY - sumX * sumY) / denominator).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes until [levelFraction] reaches empty at [fractionPerHour], or null if the rate is
|
||||||
|
* non-positive or the result is implausible (<=0 or above [MAX_MINUTES]).
|
||||||
|
*/
|
||||||
|
fun minutesRemaining(levelFraction: Float, fractionPerHour: Float): Int? {
|
||||||
|
if (fractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction <= 0f) return null
|
||||||
|
val minutes = (levelFraction / fractionPerHour * 60.0).roundToInt()
|
||||||
|
return minutes.takeIf { it in 1..MAX_MINUTES }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential moving average over the displayed minutes, to avoid a jumpy number. Uses the
|
||||||
|
* asymmetric [MINUTES_ALPHA_DOWN]/[MINUTES_ALPHA_UP] factors by default; pass an explicit [alpha]
|
||||||
|
* to force a symmetric factor (used by tests).
|
||||||
|
*/
|
||||||
|
fun blendMinutes(previous: Int?, next: Int, alpha: Float? = null): Int {
|
||||||
|
if (previous == null) return next
|
||||||
|
val a = alpha ?: if (next < previous) MINUTES_ALPHA_DOWN else MINUTES_ALPHA_UP
|
||||||
|
return (next * a + previous * (1f - a)).roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exponential moving average over the persisted learned rate across sessions. */
|
||||||
|
fun blendRate(previous: Float?, next: Float, alpha: Float = LEARN_ALPHA): Float =
|
||||||
|
if (previous == null) next else next * alpha + previous * (1f - alpha)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package eu.darken.capod.monitor.core.battery
|
||||||
|
|
||||||
|
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||||
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted drain-rate knowledge for a single device profile, keyed per ANC-mode bucket AND per pod
|
||||||
|
* — map key is `"<bucket>/<slot>"`, e.g. `"ON/LEFT"`, `"UNKNOWN/HEADSET"`. Drain differs sharply by
|
||||||
|
* both mode and pod (the mic pod drains faster), so each is learned independently. Seeds the
|
||||||
|
* time-remaining estimate immediately on reconnect, before the live session has enough samples.
|
||||||
|
*
|
||||||
|
* [chargeRates] is the charging counterpart, keyed per slot only (`"LEFT"` / `"RIGHT"` /
|
||||||
|
* `"HEADSET"`) — the ANC mode is irrelevant while a pod sits in the case.
|
||||||
|
*
|
||||||
|
* [model] tags which [PodModel] the rates were learned on, so a profile re-assigned to different
|
||||||
|
* hardware doesn't inherit the old device's rates (see [matchesModel]).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DrainProfile(
|
||||||
|
@SerialName("model") val model: String? = null,
|
||||||
|
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
|
||||||
|
@SerialName("chargeRates") val chargeRates: Map<String, LearnedRate> = emptyMap(),
|
||||||
|
/**
|
||||||
|
* Per-band charge rates, `"<slot>" -> "<band>" -> rate` with band names from
|
||||||
|
* [DrainModel.ChargeBand]. Charging is nonlinear (fast bulk, slow taper/trickle), so each
|
||||||
|
* regime learns its own rate; [chargeRates] stays as the whole-session scalar fallback.
|
||||||
|
*/
|
||||||
|
@SerialName("chargeBands") val chargeBands: Map<String, Map<String, LearnedRate>> = emptyMap(),
|
||||||
|
/**
|
||||||
|
* Drain rates learned ONLY while the pod was worn and audio was actually playing on this
|
||||||
|
* device — same `"<bucket>/<slot>"` keys as [rates]. Apple's battery ratings are listening
|
||||||
|
* figures, so the battery-health estimate compares against these; the general [rates]
|
||||||
|
* (which include idle wear) keep powering the time-remaining estimate.
|
||||||
|
*/
|
||||||
|
@SerialName("listeningRates") val listeningRates: Map<String, LearnedRate> = emptyMap(),
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class LearnedRate(
|
||||||
|
/** Drain (or charge) rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
|
||||||
|
@SerialName("fractionPerHour") val fractionPerHour: Float,
|
||||||
|
@SerialName("sampleCount") val sampleCount: Int,
|
||||||
|
/**
|
||||||
|
* How many distinct sessions have blended into this rate. [sampleCount] is only the window
|
||||||
|
* size at the LAST save, so this is the actual accumulated-evidence signal (used e.g. to
|
||||||
|
* gate the derived battery-health figure).
|
||||||
|
*/
|
||||||
|
@SerialName("updateCount") val updateCount: Int = 1,
|
||||||
|
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||||
|
@SerialName("updatedAt") val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether these learned rates apply to [model]. An untagged profile or an UNKNOWN model on
|
||||||
|
* either side is treated as matching — only a definite known-A vs known-B mismatch (the user
|
||||||
|
* re-pointed the profile at different hardware) disqualifies the data.
|
||||||
|
*/
|
||||||
|
fun matchesModel(model: PodModel): Boolean =
|
||||||
|
this.model == null ||
|
||||||
|
model == PodModel.UNKNOWN ||
|
||||||
|
this.model == PodModel.UNKNOWN.name ||
|
||||||
|
this.model == model.name
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Bucket key for rates learned while the ANC mode wasn't known (BLE-only sessions). */
|
||||||
|
const val BUCKET_UNKNOWN = "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,10 +29,14 @@ import eu.darken.capod.main.core.MonitorMode
|
|||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||||
|
import eu.darken.capod.monitor.core.MonitorKillDetector
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||||
|
import eu.darken.capod.monitor.core.battery.displayKey
|
||||||
|
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||||
import eu.darken.capod.monitor.core.primaryDevice
|
|
||||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
@@ -88,12 +92,15 @@ class MonitorService : Service() {
|
|||||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||||
@Inject lateinit var monitorModeResolver: MonitorModeResolver
|
@Inject lateinit var monitorModeResolver: MonitorModeResolver
|
||||||
|
@Inject lateinit var batteryEstimator: BatteryEstimator
|
||||||
|
@Inject lateinit var killDetector: MonitorKillDetector
|
||||||
|
|
||||||
private val monitorScope = MonitorCoroutineScope()
|
private val monitorScope = MonitorCoroutineScope()
|
||||||
private var monitoringJob: Job? = null
|
private var monitoringJob: Job? = null
|
||||||
@Volatile private var monitorGeneration = 0
|
@Volatile private var monitorGeneration = 0
|
||||||
private var foregroundStartFailed = false
|
private var foregroundStartFailed = false
|
||||||
private var injectionComplete = false
|
private var injectionComplete = false
|
||||||
|
@Volatile private var monitorMarkedActive = false
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var latestNotificationSettings: NotificationSettings =
|
private var latestNotificationSettings: NotificationSettings =
|
||||||
@@ -211,7 +218,10 @@ class MonitorService : Service() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val deviceFlow = deviceMonitor.primaryDevice()
|
monitorMarkedActive = true
|
||||||
|
killDetector.onMonitorStart()
|
||||||
|
|
||||||
|
val deviceFlow = deviceMonitor.primaryDeviceByTier
|
||||||
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||||
.distinctUntilChangedBy { it?.toNotificationKey() }
|
.distinctUntilChangedBy { it?.toNotificationKey() }
|
||||||
.throttleLatest(1000)
|
.throttleLatest(1000)
|
||||||
@@ -223,16 +233,34 @@ class MonitorService : Service() {
|
|||||||
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
|
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
|
||||||
}
|
}
|
||||||
|
|
||||||
val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings ->
|
val monitorJob = combine(
|
||||||
currentDevice to settings
|
deviceFlow,
|
||||||
|
notificationSettingsFlow,
|
||||||
|
batteryEstimator.estimates,
|
||||||
|
) { currentDevice, settings, estimates ->
|
||||||
|
NotificationInput(
|
||||||
|
device = currentDevice,
|
||||||
|
settings = settings,
|
||||||
|
estimate = currentDevice?.let { estimates.estimateFor(it) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.onEach { (currentDevice, settings) ->
|
// The estimates map churns on fields the notification doesn't display (and for other
|
||||||
|
// profiles' devices) — only re-notify when something visible changed.
|
||||||
|
.distinctUntilChangedBy { input ->
|
||||||
|
Triple(
|
||||||
|
input.device?.toNotificationKey(),
|
||||||
|
input.settings,
|
||||||
|
input.device?.let { input.estimate?.displayKey(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onEach { (currentDevice, settings, estimate) ->
|
||||||
latestNotificationSettings = settings
|
latestNotificationSettings = settings
|
||||||
|
|
||||||
notificationManager.notify(
|
notificationManager.notify(
|
||||||
MonitorNotifications.NOTIFICATION_ID,
|
MonitorNotifications.NOTIFICATION_ID,
|
||||||
notifications.getNotification(
|
notifications.getNotification(
|
||||||
currentDevice,
|
currentDevice,
|
||||||
|
estimate = estimate,
|
||||||
showHint = settings.useExtraNotification,
|
showHint = settings.useExtraNotification,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -240,7 +268,7 @@ class MonitorService : Service() {
|
|||||||
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
|
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
|
||||||
is ExtraNotificationAction.Post -> notificationManager.notify(
|
is ExtraNotificationAction.Post -> notificationManager.notify(
|
||||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
|
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
|
||||||
notifications.getNotificationConnected(action.device),
|
notifications.getNotificationConnected(action.device, estimate),
|
||||||
)
|
)
|
||||||
ExtraNotificationAction.Cancel -> notificationManager.cancel(
|
ExtraNotificationAction.Cancel -> notificationManager.cancel(
|
||||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED
|
MonitorNotifications.NOTIFICATION_ID_CONNECTED
|
||||||
@@ -359,6 +387,11 @@ class MonitorService : Service() {
|
|||||||
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
|
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
|
||||||
.launchIn(monitorScope)
|
.launchIn(monitorScope)
|
||||||
|
|
||||||
|
batteryEstimator.monitor()
|
||||||
|
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
|
||||||
|
.catch { log(TAG, WARN) { "batteryEstimator failed:\n${it.asLog()}" } }
|
||||||
|
.launchIn(monitorScope)
|
||||||
|
|
||||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||||
monitorJob.join()
|
monitorJob.join()
|
||||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||||
@@ -369,6 +402,16 @@ class MonitorService : Service() {
|
|||||||
monitorScope.cancel("Service destroyed")
|
monitorScope.cancel("Service destroyed")
|
||||||
|
|
||||||
if (injectionComplete) {
|
if (injectionComplete) {
|
||||||
|
// Clean shutdown — a force-stop skips onDestroy(), leaving the breadcrumb set. Only
|
||||||
|
// clear it if this instance claimed it: an aborted start (missing permissions) must
|
||||||
|
// not erase evidence left behind by an earlier killed session.
|
||||||
|
if (monitorMarkedActive) {
|
||||||
|
try {
|
||||||
|
killDetector.onMonitorCleanStopBlocking()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, WARN) { "Failed to clear monitor breadcrumb: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
chargedReactionNotifications.cancelAll()
|
chargedReactionNotifications.cancelAll()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -424,6 +467,12 @@ internal fun buildMonitorModeState(
|
|||||||
hasAapSession = aapStates.isNotEmpty(),
|
hasAapSession = aapStates.isNotEmpty(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal data class NotificationInput(
|
||||||
|
val device: PodDevice?,
|
||||||
|
val settings: NotificationSettings,
|
||||||
|
val estimate: BatteryEstimate?,
|
||||||
|
)
|
||||||
|
|
||||||
private data class NotificationDeviceKey(
|
private data class NotificationDeviceKey(
|
||||||
val profileId: String?,
|
val profileId: String?,
|
||||||
val label: String?,
|
val label: String?,
|
||||||
@@ -443,6 +492,7 @@ private data class NotificationDeviceKey(
|
|||||||
val isLeftInEar: Boolean?,
|
val isLeftInEar: Boolean?,
|
||||||
val isRightInEar: Boolean?,
|
val isRightInEar: Boolean?,
|
||||||
val isBeingWorn: Boolean?,
|
val isBeingWorn: Boolean?,
|
||||||
|
val batteryEstimateEnabled: Boolean,
|
||||||
val iconRes: Int,
|
val iconRes: Int,
|
||||||
val leftPodIcon: Int,
|
val leftPodIcon: Int,
|
||||||
val rightPodIcon: Int,
|
val rightPodIcon: Int,
|
||||||
@@ -468,6 +518,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
|||||||
isLeftInEar = isLeftInEar,
|
isLeftInEar = isLeftInEar,
|
||||||
isRightInEar = isRightInEar,
|
isRightInEar = isRightInEar,
|
||||||
isBeingWorn = isBeingWorn,
|
isBeingWorn = isBeingWorn,
|
||||||
|
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||||
iconRes = iconRes,
|
iconRes = iconRes,
|
||||||
leftPodIcon = leftPodIcon,
|
leftPodIcon = leftPodIcon,
|
||||||
rightPodIcon = rightPodIcon,
|
rightPodIcon = rightPodIcon,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import android.widget.RemoteViews
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
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.monitor.core.battery.BatteryEstimate
|
||||||
|
import eu.darken.capod.monitor.core.battery.displayMinutes
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||||
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 eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
@@ -68,12 +71,10 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
if (device.hasEarDetection) {
|
if (device.hasEarDetection) {
|
||||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||||
}
|
}
|
||||||
if (device.isHeadsetBeingCharged != null) {
|
setViewVisibility(
|
||||||
setViewVisibility(
|
R.id.headphones_charging,
|
||||||
R.id.headphones_charging,
|
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
|
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
|
||||||
@@ -83,13 +84,13 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
setTextViewText(R.id.device, device.getLabel(context))
|
setTextViewText(R.id.device, device.getLabel(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createBigContentView(device: PodDevice): RemoteViews = when {
|
fun createBigContentView(device: PodDevice, estimate: BatteryEstimate? = null): RemoteViews = when {
|
||||||
device.hasDualPods -> createDualPodsBig(device)
|
device.hasDualPods -> createDualPodsBig(device, estimate)
|
||||||
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
|
device.model != PodModel.UNKNOWN -> createSinglePodBig(device, estimate)
|
||||||
else -> createUnknownDeviceBig(device)
|
else -> createUnknownDeviceBig(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
|
private fun createDualPodsBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
|
||||||
context.packageName,
|
context.packageName,
|
||||||
R.layout.monitor_notification_dual_pods_big
|
R.layout.monitor_notification_dual_pods_big
|
||||||
).apply {
|
).apply {
|
||||||
@@ -97,7 +98,11 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
val leftPercent = device.batteryLeft
|
val leftPercent = device.batteryLeft
|
||||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||||
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
|
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
|
||||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
setTextViewText(
|
||||||
|
R.id.pod_left_label,
|
||||||
|
formatBatteryPercent(context, leftPercent) +
|
||||||
|
estimateSuffix(estimate?.left, device.isLeftPodCharging == true)
|
||||||
|
)
|
||||||
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
||||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||||
val isLeftPodInEar = device.isLeftInEar ?: false
|
val isLeftPodInEar = device.isLeftInEar ?: false
|
||||||
@@ -117,14 +122,18 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
val rightPercent = device.batteryRight
|
val rightPercent = device.batteryRight
|
||||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||||
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
|
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
|
||||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
setTextViewText(
|
||||||
|
R.id.pod_right_label,
|
||||||
|
formatBatteryPercent(context, rightPercent) +
|
||||||
|
estimateSuffix(estimate?.right, device.isRightPodCharging == true)
|
||||||
|
)
|
||||||
val isRightPodCharging = device.isRightPodCharging ?: false
|
val isRightPodCharging = device.isRightPodCharging ?: false
|
||||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||||
val isRightPodInEar = device.isRightInEar ?: false
|
val isRightPodInEar = device.isRightInEar ?: false
|
||||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
|
private fun createSinglePodBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
|
||||||
context.packageName,
|
context.packageName,
|
||||||
R.layout.monitor_notification_single_pods_big
|
R.layout.monitor_notification_single_pods_big
|
||||||
).apply {
|
).apply {
|
||||||
@@ -132,16 +141,18 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||||
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
||||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
setTextViewText(
|
||||||
|
R.id.headphones_battery_label,
|
||||||
|
formatBatteryPercent(context, headsetPercent) +
|
||||||
|
estimateSuffix(estimate?.headset, device.isHeadsetBeingCharged == true)
|
||||||
|
)
|
||||||
if (device.hasEarDetection) {
|
if (device.hasEarDetection) {
|
||||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||||
}
|
}
|
||||||
if (device.isHeadsetBeingCharged != null) {
|
setViewVisibility(
|
||||||
setViewVisibility(
|
R.id.headphones_charging,
|
||||||
R.id.headphones_charging,
|
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createUnknownDeviceBig(device: PodDevice): RemoteViews = RemoteViews(
|
private fun createUnknownDeviceBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||||
@@ -154,4 +165,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
private fun percentToInt(percent: Float): Int =
|
private fun percentToInt(percent: Float): Int =
|
||||||
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||||
|
|
||||||
|
private fun estimateSuffix(pod: BatteryEstimate.Pod?, charging: Boolean): String =
|
||||||
|
pod?.displayMinutes(charging)?.let { " · ${formatBatteryDurationShort(context, it)}" } ?: ""
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.logTag
|
|||||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||||
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 javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -56,7 +57,8 @@ class MonitorNotifications @Inject constructor(
|
|||||||
private fun getBuilder(
|
private fun getBuilder(
|
||||||
device: PodDevice?,
|
device: PodDevice?,
|
||||||
channelId: String,
|
channelId: String,
|
||||||
showHint: Boolean = false
|
estimate: BatteryEstimate? = null,
|
||||||
|
showHint: Boolean = false,
|
||||||
): NotificationCompat.Builder {
|
): NotificationCompat.Builder {
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
return baseBuilder(channelId).apply {
|
return baseBuilder(channelId).apply {
|
||||||
@@ -119,18 +121,21 @@ class MonitorNotifications @Inject constructor(
|
|||||||
|
|
||||||
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
||||||
setCustomContentView(notificationViewFactory.createContentView(device))
|
setCustomContentView(notificationViewFactory.createContentView(device))
|
||||||
setCustomBigContentView(notificationViewFactory.createBigContentView(device))
|
setCustomBigContentView(notificationViewFactory.createBigContentView(device, estimate))
|
||||||
setContentTitle("$batteryText ~ $stateText")
|
setContentTitle("$batteryText ~ $stateText")
|
||||||
setSubText(null)
|
setSubText(null)
|
||||||
log(TAG, VERBOSE) { "updatingNotification(): $device" }
|
log(TAG, VERBOSE) { "updatingNotification(): $device" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification =
|
fun getNotification(
|
||||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build()
|
podDevice: PodDevice?,
|
||||||
|
estimate: BatteryEstimate? = null,
|
||||||
|
showHint: Boolean = false,
|
||||||
|
): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, estimate, showHint).build()
|
||||||
|
|
||||||
fun getNotificationConnected(podDevice: PodDevice?): Notification =
|
fun getNotificationConnected(podDevice: PodDevice?, estimate: BatteryEstimate? = null): Notification =
|
||||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
|
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED, estimate).build()
|
||||||
|
|
||||||
fun getStartupNotification(): Notification =
|
fun getStartupNotification(): Notification =
|
||||||
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
|
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ enum class PodModel(
|
|||||||
@DrawableRes val leftPodIconRes: Int? = null,
|
@DrawableRes val leftPodIconRes: Int? = null,
|
||||||
@DrawableRes val rightPodIconRes: Int? = null,
|
@DrawableRes val rightPodIconRes: Int? = null,
|
||||||
@DrawableRes val caseIconRes: Int? = null,
|
@DrawableRes val caseIconRes: Int? = null,
|
||||||
|
val batterySpec: BatterySpec? = null,
|
||||||
) {
|
) {
|
||||||
@SerialName("airpods.gen1")
|
@SerialName("airpods.gen1")
|
||||||
AIRPODS_GEN1(
|
AIRPODS_GEN1(
|
||||||
@@ -26,6 +27,7 @@ enum class PodModel(
|
|||||||
hasMicrophoneMode = true,
|
hasMicrophoneMode = true,
|
||||||
hasEarDetectionToggle = true,
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||||
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||||
@@ -43,6 +45,7 @@ enum class PodModel(
|
|||||||
hasMicrophoneMode = true,
|
hasMicrophoneMode = true,
|
||||||
hasEarDetectionToggle = true,
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||||
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||||
@@ -64,6 +67,7 @@ enum class PodModel(
|
|||||||
hasMicrophoneMode = true,
|
hasMicrophoneMode = true,
|
||||||
hasEarDetectionToggle = true,
|
hasEarDetectionToggle = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f),
|
||||||
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||||
@@ -86,6 +90,7 @@ enum class PodModel(
|
|||||||
hasEarDetectionToggle = true,
|
hasEarDetectionToggle = true,
|
||||||
hasSleepDetection = true,
|
hasSleepDetection = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||||
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
|
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||||
@@ -117,6 +122,7 @@ enum class PodModel(
|
|||||||
hasStemConfig = true,
|
hasStemConfig = true,
|
||||||
hasSleepDetection = true,
|
hasSleepDetection = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||||
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
|
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
|
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
|
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
|
||||||
@@ -142,6 +148,7 @@ enum class PodModel(
|
|||||||
hasListeningModeCycle = true,
|
hasListeningModeCycle = true,
|
||||||
hasAllowOffOption = true,
|
hasAllowOffOption = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||||
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
|
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||||
@@ -175,6 +182,7 @@ enum class PodModel(
|
|||||||
hasStemConfig = true,
|
hasStemConfig = true,
|
||||||
hasSleepDetection = true,
|
hasSleepDetection = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
|
||||||
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
|
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||||
@@ -208,6 +216,7 @@ enum class PodModel(
|
|||||||
hasStemConfig = true,
|
hasStemConfig = true,
|
||||||
hasSleepDetection = true,
|
hasSleepDetection = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
|
||||||
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
|
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||||
@@ -242,6 +251,7 @@ enum class PodModel(
|
|||||||
hasSleepDetection = true,
|
hasSleepDetection = true,
|
||||||
hasDynamicEndOfCharge = true,
|
hasDynamicEndOfCharge = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f),
|
||||||
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
|
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
|
||||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||||
@@ -262,6 +272,7 @@ enum class PodModel(
|
|||||||
hasListeningModeCycle = true,
|
hasListeningModeCycle = true,
|
||||||
hasAllowOffOption = true,
|
hasAllowOffOption = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
|
||||||
modelNumbers = setOf("A2096"), // headphones
|
modelNumbers = setOf("A2096"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -279,6 +290,7 @@ enum class PodModel(
|
|||||||
hasListeningModeCycle = true,
|
hasListeningModeCycle = true,
|
||||||
hasAllowOffOption = true,
|
hasAllowOffOption = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
|
||||||
modelNumbers = setOf("A3184"), // headphones
|
modelNumbers = setOf("A3184"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -300,6 +312,7 @@ enum class PodModel(
|
|||||||
hasListeningModeCycle = true,
|
hasListeningModeCycle = true,
|
||||||
hasAllowOffOption = true,
|
hasAllowOffOption = true,
|
||||||
),
|
),
|
||||||
|
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
|
||||||
modelNumbers = setOf("A3454"), // headphones
|
modelNumbers = setOf("A3454"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -580,4 +593,27 @@ enum class PodModel(
|
|||||||
*/
|
*/
|
||||||
val hasDynamicEndOfCharge: Boolean = false,
|
val hasDynamicEndOfCharge: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple's published single-charge listening-time ratings, in hours, used to seed the battery
|
||||||
|
* time-remaining estimate before observed drain is available and as an upper bound on it. These
|
||||||
|
* are the POD-ONLY listening figures from Apple's tech-spec pages — NOT the "with charging case"
|
||||||
|
* aggregate. Left null for models Apple doesn't publish figures for (Beats, unknown), which simply
|
||||||
|
* fall back to purely observed drain. Figures verified against apple.com tech specs (2026-07).
|
||||||
|
*/
|
||||||
|
data class BatterySpec(
|
||||||
|
/** Listening hours with Active Noise Cancellation / Transparency active. */
|
||||||
|
val listeningHoursAncOn: Float? = null,
|
||||||
|
/** Listening hours with noise control off, when Apple publishes a distinct figure; else null. */
|
||||||
|
val listeningHoursAncOff: Float? = null,
|
||||||
|
/**
|
||||||
|
* Nominal charge rate (battery fraction gained per hour of charging), derived from Apple's
|
||||||
|
* published quick-charge claim: e.g. "5 minutes in the case provides around 1 hour of
|
||||||
|
* listening time" against a 6 h rating is (1/6) / (5/60) = 2.0/hr. Where the model has two
|
||||||
|
* listening ratings it's normalized against the LONGER one, so the seeded "until charged"
|
||||||
|
* never under-promises. Seeds the charge ETA on the very first charge, before any charge
|
||||||
|
* rate has been measured; a live fit takes over within minutes.
|
||||||
|
*/
|
||||||
|
val chargeFractionPerHour: Float? = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ class AapConnectionManager @Inject constructor(
|
|||||||
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
|
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
|
||||||
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
|
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
|
||||||
|
|
||||||
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
private val _disconnectEvents = MutableSharedFlow<AapDisconnectEvent>(extraBufferCapacity = 16)
|
||||||
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
|
val disconnectEvents: SharedFlow<AapDisconnectEvent> = _disconnectEvents.asSharedFlow()
|
||||||
|
|
||||||
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
|
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
|
||||||
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
|
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
|
||||||
@@ -75,10 +75,10 @@ class AapConnectionManager @Inject constructor(
|
|||||||
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits when a connected device reports a Conversational Awareness speaking transition
|
* Emits when a connected device reports a Conversational Awareness transition (START / RESUME /
|
||||||
* (START/STOP, AAP command 0x4B). Paired with the origin address so the conversation
|
* HOLD / STOP, AAP command 0x4B). Paired with the origin address so the conversation reaction
|
||||||
* reaction can gate on the primary device. Only the known 0x01/0x04 markers reach here —
|
* can gate on the primary device. Every well-formed frame is classified and emitted (unknown
|
||||||
* the engine drops unknown raw values (see [AapSessionEngine]).
|
* status bytes map to HOLD); only structurally malformed frames are dropped (see [AapSessionEngine]).
|
||||||
*/
|
*/
|
||||||
private val _conversationalAwarenessEvents =
|
private val _conversationalAwarenessEvents =
|
||||||
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
|
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
|
||||||
@@ -188,7 +188,7 @@ class AapConnectionManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!wasIntentional) {
|
if (!wasIntentional) {
|
||||||
_disconnectEvents.tryEmit(address)
|
_disconnectEvents.tryEmit(AapDisconnectEvent(address, connection.wasEverReady))
|
||||||
}
|
}
|
||||||
|
|
||||||
// End this collector coroutine (also cancels child key-forwarding coroutine)
|
// End this collector coroutine (also cancels child key-forwarding coroutine)
|
||||||
@@ -217,3 +217,13 @@ class AapConnectionManager @Inject constructor(
|
|||||||
connection.send(command)
|
connection.send(command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted on an unintentional AAP disconnect. [wasEverReady] tells the reconnect layer whether this
|
||||||
|
* session ever completed the handshake (reached READY) — a never-ready disconnect is a failed
|
||||||
|
* handshake/short session and feeds the escalating reconnect backoff; a was-ready drop reconnects promptly.
|
||||||
|
*/
|
||||||
|
data class AapDisconnectEvent(
|
||||||
|
val address: BluetoothAddress,
|
||||||
|
val wasEverReady: Boolean,
|
||||||
|
)
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -46,10 +48,14 @@ internal class AapConnection(
|
|||||||
private val socketFactory: L2capSocketFactory,
|
private val socketFactory: L2capSocketFactory,
|
||||||
timeSource: TimeSource,
|
timeSource: TimeSource,
|
||||||
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
private val handshakeTimeout: Duration = DEFAULT_HANDSHAKE_TIMEOUT,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val engine = AapSessionEngine(profile, timeSource)
|
private val engine = AapSessionEngine(profile, timeSource)
|
||||||
|
|
||||||
|
/** True once this connection's session reached READY at least once. Survives the engine reset. */
|
||||||
|
val wasEverReady: Boolean get() = engine.wasEverReady
|
||||||
|
|
||||||
val state: StateFlow<AapPodState> get() = engine.state
|
val state: StateFlow<AapPodState> get() = engine.state
|
||||||
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
||||||
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
||||||
@@ -60,6 +66,7 @@ internal class AapConnection(
|
|||||||
|
|
||||||
private var socket: BluetoothSocket? = null
|
private var socket: BluetoothSocket? = null
|
||||||
private var readerJob: Job? = null
|
private var readerJob: Job? = null
|
||||||
|
private val disconnected = AtomicBoolean(false)
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
private val framer = AapFramer()
|
private val framer = AapFramer()
|
||||||
|
|
||||||
@@ -109,6 +116,27 @@ internal class AapConnection(
|
|||||||
|
|
||||||
// Launch read loop in the provided scope — connect() returns immediately
|
// Launch read loop in the provided scope — connect() returns immediately
|
||||||
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
|
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
|
||||||
|
|
||||||
|
// Handshake watchdog: the socket can connect and the handshake be sent, but if the peer
|
||||||
|
// never replies the engine sits in HANDSHAKING forever (blocking read, no deadline).
|
||||||
|
// Bound it: if neither READY nor DISCONNECTED is reached in time, tear the socket down so
|
||||||
|
// the reconnect path can recover. READY / an earlier DISCONNECTED end the wait with no action.
|
||||||
|
scope.launch {
|
||||||
|
val settled = withTimeoutOrNull(handshakeTimeout) {
|
||||||
|
state.first {
|
||||||
|
it.connectionState == AapPodState.ConnectionState.READY ||
|
||||||
|
it.connectionState == AapPodState.ConnectionState.DISCONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-check after the timeout: READY may have landed in the boundary race between
|
||||||
|
// withTimeoutOrNull cancelling and us getting here — don't tear down a live session.
|
||||||
|
if (settled == null && state.value.connectionState != AapPodState.ConnectionState.READY) {
|
||||||
|
log(TAG, Logging.Priority.WARN) {
|
||||||
|
"Handshake timed out after $handshakeTimeout for ${device.address} — disconnecting"
|
||||||
|
}
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
|
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
|
||||||
cleanupSocket()
|
cleanupSocket()
|
||||||
@@ -118,6 +146,9 @@ internal class AapConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
||||||
|
// Idempotent: the handshake watchdog and the manager's DISCONNECTED observer can both reach
|
||||||
|
// here for the same dying session. Run the teardown exactly once.
|
||||||
|
if (!disconnected.compareAndSet(false, true)) return@withContext
|
||||||
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
|
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
|
||||||
readerJob?.cancel()
|
readerJob?.cancel()
|
||||||
readerJob = null
|
readerJob = null
|
||||||
@@ -261,6 +292,13 @@ internal class AapConnection(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val PSM = 0x1001
|
private const val PSM = 0x1001
|
||||||
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on the post-handshake wait for the first sign of life (READY). Normal
|
||||||
|
* handshakes complete in well under 2s; 10s tolerates a congested 2.4 GHz band before
|
||||||
|
* giving up so the reconnect path can recover instead of wedging in HANDSHAKING forever.
|
||||||
|
*/
|
||||||
|
internal val DEFAULT_HANDSHAKE_TIMEOUT = 10.seconds
|
||||||
private val TAG = logTag("AAP", "Connection")
|
private val TAG = logTag("AAP", "Connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ internal class AapSessionEngine(
|
|||||||
private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null
|
private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null
|
||||||
private var runtimeState = EngineRuntimeState()
|
private var runtimeState = EngineRuntimeState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once this session has reached [AapPodState.ConnectionState.READY] at least once.
|
||||||
|
* Deliberately NOT cleared by [reset] — consumers read it at disconnect time to tell a
|
||||||
|
* working session that dropped apart from one that never completed the handshake (drives
|
||||||
|
* the reconnect backoff). Cleared only when a fresh session starts.
|
||||||
|
*/
|
||||||
|
private var _wasEverReady: Boolean = false
|
||||||
|
val wasEverReady: Boolean get() = _wasEverReady
|
||||||
|
|
||||||
fun start(scope: CoroutineScope) {
|
fun start(scope: CoroutineScope) {
|
||||||
dispatch(AapEngineEvent.SessionStarted(scope))
|
dispatch(AapEngineEvent.SessionStarted(scope))
|
||||||
}
|
}
|
||||||
@@ -130,6 +139,7 @@ internal class AapSessionEngine(
|
|||||||
when (event) {
|
when (event) {
|
||||||
is AapEngineEvent.SessionStarted -> {
|
is AapEngineEvent.SessionStarted -> {
|
||||||
scope = event.scope
|
scope = event.scope
|
||||||
|
_wasEverReady = false
|
||||||
runtimeState = runtimeState.copy(handshakeResponseReceived = false)
|
runtimeState = runtimeState.copy(handshakeResponseReceived = false)
|
||||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
|
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
|
||||||
}
|
}
|
||||||
@@ -159,11 +169,14 @@ internal class AapSessionEngine(
|
|||||||
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
|
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
|
||||||
if (packet.status != 0) {
|
if (packet.status != 0) {
|
||||||
log(TAG, ERROR) {
|
log(TAG, ERROR) {
|
||||||
"ConnectResponse failed: status=0x${"%04X".format(packet.status)} " +
|
"ConnectResponse rejected (status=0x${"%04X".format(packet.status)}) — tearing down: " +
|
||||||
"major=${packet.major} minor=${packet.minor} " +
|
"major=${packet.major} minor=${packet.minor} " +
|
||||||
"features=0x${"%016X".format(packet.features.toLong())}"
|
"features=0x${"%016X".format(packet.features.toLong())}"
|
||||||
}
|
}
|
||||||
_state.value = _state.value.copy(connectResponseStatus = packet.status)
|
// Hard protocol rejection: don't wait out the handshake watchdog — disconnect now so
|
||||||
|
// the reconnect path can recover (or back off). Nested dispatch mirrors the existing
|
||||||
|
// inbound-update re-dispatch pattern.
|
||||||
|
dispatch(AapEngineEvent.ResetRequested)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +205,7 @@ internal class AapSessionEngine(
|
|||||||
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
|
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
|
||||||
) {
|
) {
|
||||||
runtimeState = runtimeState.copy(handshakeResponseReceived = true)
|
runtimeState = runtimeState.copy(handshakeResponseReceived = true)
|
||||||
|
_wasEverReady = true
|
||||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
||||||
log(TAG) { "Connection READY" }
|
log(TAG) { "Connection READY" }
|
||||||
}
|
}
|
||||||
@@ -309,8 +323,8 @@ internal class AapSessionEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
|
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
|
||||||
// conversation reaction: START / STOP / HOLD (keep-alive). The decoder already dropped
|
// conversation reaction: START / RESUME / HOLD / STOP. The decoder already dropped malformed
|
||||||
// malformed frames (rawValue stays null only in that case). The raw payload remains logged.
|
// frames (rawValue stays null only in that case). The raw payload remains logged.
|
||||||
if (value is AapSetting.ConversationalAwarenessState) {
|
if (value is AapSetting.ConversationalAwarenessState) {
|
||||||
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
|
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,9 +103,10 @@ sealed class AapSetting {
|
|||||||
* Push-only from device — reports speaking detection state (command 0x4B).
|
* Push-only from device — reports speaking detection state (command 0x4B).
|
||||||
*
|
*
|
||||||
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
|
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
|
||||||
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` only
|
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` while
|
||||||
* for the speaking-onset statuses (`1`, `2`); every other value (`0`, `3`, `4`, `5`, `0x0B`, …)
|
* the wearer is talking — onset (`1`, `2`) or resumed after a pause (`5`); every other value
|
||||||
* is `false`. START/STOP/HOLD classification for the reaction lives in [ConversationAwarenessEvent].
|
* (`0`, `3`, `4`, `0x0B`, …) is `false`. START/RESUME/HOLD/STOP classification for the reaction
|
||||||
|
* lives in [ConversationAwarenessEvent].
|
||||||
*/
|
*/
|
||||||
data class ConversationalAwarenessState(
|
data class ConversationalAwarenessState(
|
||||||
val speaking: Boolean,
|
val speaking: Boolean,
|
||||||
|
|||||||
+25
-15
@@ -3,36 +3,46 @@ package eu.darken.capod.pods.core.apple.aap.protocol
|
|||||||
/**
|
/**
|
||||||
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
|
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
|
||||||
*
|
*
|
||||||
* Status-byte mapping (from live AirPods Pro 3 + Pro 2 USB-C captures — both models share one
|
* Status-byte mapping, derived from a labelled capture set across AirPods Pro 3 and Pro 2 USB-C
|
||||||
* firmware train and a byte-identical protocol — plus the librepods project):
|
* (`protocol-research/conversationalawareness/` — both models share one firmware train and emit
|
||||||
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
|
* byte-identical sequences). Each scenario was captured with a known action (normal talking,
|
||||||
* - `5`, `6`, `8`, `9` → [STOP] (wearer stopped → disengage). All four confirmed live on Pro 2
|
* bursty talking, single-pod, volume-up abort, pod removal/case):
|
||||||
* USB-C fw `…6814`; which one terminates a given flurry varies with how speech ended.
|
* - `1`, `2` → [START] (conversation onset / wind-up → engage the reaction). A conversation always
|
||||||
* - any other value (`0`, `3`, `4`, `7`, `0x0B`, … and anything unrecognised) → [HOLD]: a
|
* opens `1` then `2`; re-onset only ever happens after a terminal.
|
||||||
* transitional wind-down frame (`7` was only discovered on fw `…6814` — the set is open-ended,
|
* - `5` → [RESUME] (speech resumed after a pause; the wind-down was aborted, CA stays engaged). In
|
||||||
* so unknown values are deliberately classified as HOLD rather than guessed at).
|
* bursty speech the pod cycles `3,5,3,5,…`; `5` is NEVER a terminal. Misreading `5` as a stop was
|
||||||
|
* the root cause of the premature-resume bug — see [ConversationReaction].
|
||||||
|
* - `6`, `8`, `9` → [STOP] (conversation ended → disengage). The usual terminal is the `8`→`9`
|
||||||
|
* pair; `6` is a standalone terminal seen live on Pro 2 USB-C fw `…6814`. Pod removal / case-close
|
||||||
|
* also emit `8`,`9` (sometimes with no prior `1`,`2`).
|
||||||
|
* - any other value (`3` pause, `4` / `0x0B` wind-down, `7` abort, and anything unrecognised)
|
||||||
|
* → [HOLD]: a transitional "possible/real wind-down" frame. The real wind-down runs `3→0x0B→4`
|
||||||
|
* then the `8,9` terminal; `7` precedes an aborted terminal. Unknown values are deliberately
|
||||||
|
* classified as HOLD (arm the safety fuse, never resume immediately) rather than guessed at.
|
||||||
*
|
*
|
||||||
* The pod sends NO frames during active speech — it stays engaged (and silent) for as long as it
|
* The pod sends NO frames during active speech — it stays engaged (and silent) for as long as it
|
||||||
* hears nearby voices, 20-30s+ observed. So frame-silence must NOT be read as "speaking ended".
|
* hears nearby voices, 20-30s+ observed. So frame-silence must NOT be read as "speaking ended".
|
||||||
* Conversely, any non-START frame means the wind-down has begun: a short flurry of transitional
|
* A HOLD means the wind-down may have begun; with only ONE pod worn the `8,9` terminal is
|
||||||
* and terminal frames (e.g. `3,0xB,4,8,9` or `3,5,7,8,9`). With only ONE pod worn (other in
|
* deterministically dropped — the flurry ends on `4` (#608, reproduced on Pro 3 and Pro 2) — so
|
||||||
* case/disconnected) the terminal is deterministically dropped — the flurry ends on a transitional
|
* [ConversationReaction] treats a HOLD as "terminal imminent" and arms a short fuse, with a long
|
||||||
* `4` (#608; reproduced on Pro 3 and Pro 2 alike) — so [ConversationReaction] treats a HOLD as
|
* stale backstop for a fully-dropped flurry. A RESUME (`5`) cancels that fuse and re-arms the long
|
||||||
* "terminal imminent" and arms a short fuse, with a long stale backstop for a fully-dropped flurry.
|
* backstop; trailing frames after a terminal are ignored once disengaged.
|
||||||
* A flurry's trailing frames may arrive after its terminal; they are ignored once disengaged.
|
|
||||||
*/
|
*/
|
||||||
enum class ConversationAwarenessEvent {
|
enum class ConversationAwarenessEvent {
|
||||||
START,
|
START,
|
||||||
|
RESUME,
|
||||||
HOLD,
|
HOLD,
|
||||||
STOP,
|
STOP,
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val SPEAKING_STATUSES = setOf(1, 2)
|
val SPEAKING_STATUSES = setOf(1, 2)
|
||||||
val STOPPED_STATUSES = setOf(5, 6, 8, 9)
|
val RESUME_STATUSES = setOf(5)
|
||||||
|
val STOPPED_STATUSES = setOf(6, 8, 9)
|
||||||
|
|
||||||
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
|
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
|
||||||
in SPEAKING_STATUSES -> START
|
in SPEAKING_STATUSES -> START
|
||||||
|
in RESUME_STATUSES -> RESUME
|
||||||
in STOPPED_STATUSES -> STOP
|
in STOPPED_STATUSES -> STOP
|
||||||
else -> HOLD
|
else -> HOLD
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -197,8 +197,11 @@ class DefaultAapDeviceProfile(
|
|||||||
p[3].toInt() and 0xFF
|
p[3].toInt() and 0xFF
|
||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
// speaking = the "started/active speaking" statuses (1,2); see ConversationAwarenessEvent.
|
// speaking = the wearer is currently speaking: onset (1,2) or resumed after a pause (5).
|
||||||
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES
|
// See ConversationAwarenessEvent. (Consumers read rawValue for full classification; this
|
||||||
|
// bool just exposes "is talking now", so a resume must count as speaking too.)
|
||||||
|
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES ||
|
||||||
|
status in ConversationAwarenessEvent.RESUME_STATUSES
|
||||||
return AapSetting.ConversationalAwarenessState::class to
|
return AapSetting.ConversationalAwarenessState::class to
|
||||||
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
|
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ fun formatBatteryPercent(context: Context, percent: Float): String =
|
|||||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||||
else context.getString(R.string.general_value_not_available_label)
|
else context.getString(R.string.general_value_not_available_label)
|
||||||
|
|
||||||
|
/** Formats an earbud time-remaining estimate as a compact duration, e.g. "2h 15m" / "5h" / "45m". */
|
||||||
|
fun formatBatteryDurationShort(context: Context, minutes: Int): String {
|
||||||
|
val hours = minutes / 60
|
||||||
|
val mins = minutes % 60
|
||||||
|
return when {
|
||||||
|
hours > 0 && mins > 0 -> context.getString(R.string.battery_time_remaining_format_hm, hours, mins)
|
||||||
|
hours > 0 -> context.getString(R.string.battery_time_remaining_format_h, hours)
|
||||||
|
else -> context.getString(R.string.battery_time_remaining_format_m, mins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun getBatteryDrawable(percent: Float): Int = when {
|
fun getBatteryDrawable(percent: Float): Int = when {
|
||||||
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ data class KnownDevice(
|
|||||||
val seenCounter: Int,
|
val seenCounter: Int,
|
||||||
val history: List<ApplePods>,
|
val history: List<ApplePods>,
|
||||||
val lastCaseBattery: Float?,
|
val lastCaseBattery: Float?,
|
||||||
|
/**
|
||||||
|
* Profile id this device has ever IRK-resolved to, or null if it has only ever been seen as a
|
||||||
|
* keyless device. Durable — kept even when [history] is trimmed — so the weak history-recovery
|
||||||
|
* paths can reliably refuse to hand an IRK-backed identity to a foreign same-model frame.
|
||||||
|
*/
|
||||||
|
val boundProfileId: String? = null,
|
||||||
) {
|
) {
|
||||||
val lastPayload: ProximityPayload
|
val lastPayload: ProximityPayload
|
||||||
get() = history.last().payload
|
get() = history.last().payload
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ class PodHistoryRepo @Inject constructor(
|
|||||||
val caseIgnored = if (current is DualApplePods) {
|
val caseIgnored = if (current is DualApplePods) {
|
||||||
val target = current.getCaseMatchMarkings()
|
val target = current.getCaseMatchMarkings()
|
||||||
knownDevices.values.filter { known ->
|
knownDevices.values.filter { known ->
|
||||||
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
|
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target } &&
|
||||||
|
known.isIrkConsistentWith(current)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
emptySet()
|
emptySet()
|
||||||
@@ -143,7 +144,7 @@ class PodHistoryRepo @Inject constructor(
|
|||||||
if (recognizedDevice == null) {
|
if (recognizedDevice == null) {
|
||||||
val currentMarkers = payload.getFuzzyIdentifier()
|
val currentMarkers = payload.getFuzzyIdentifier()
|
||||||
recognizedDevice = knownDevices.values
|
recognizedDevice = knownDevices.values
|
||||||
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
|
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers && it.isIrkConsistentWith(current) }
|
||||||
?.also { log(TAG, DEBUG) { "search1: Similarity match for device=${current.model}" } }
|
?.also { log(TAG, DEBUG) { "search1: Similarity match for device=${current.model}" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,24 @@ class PodHistoryRepo @Inject constructor(
|
|||||||
return recognizedDevice
|
return recognizedDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weak history paths (fuzzy markers, case-ignored markings) match on low-entropy broadcast
|
||||||
|
* data — model + battery nibbles + colour — which a stranger's same-model AirPods at a similar
|
||||||
|
* battery satisfies. Those paths must not let a foreign frame inherit the identity of one of OUR
|
||||||
|
* keyed (IRK-backed) devices, or the foreign snapshot poisons that device's cache slot and the
|
||||||
|
* dashboard card glitches (Symptom B).
|
||||||
|
*
|
||||||
|
* A [KnownDevice] is "IRK-backed" if it has ever resolved against a profile's IRK
|
||||||
|
* ([KnownDevice.boundProfileId], latched durably so history trimming can't erode it). Such a
|
||||||
|
* device may only be claimed via the strong address/IRK paths, or by a current frame that
|
||||||
|
* resolved to the SAME profile. Keyless devices (never IRK-backed) are unaffected — fuzzy
|
||||||
|
* recovery still works for them exactly as before.
|
||||||
|
*/
|
||||||
|
private fun KnownDevice.isIrkConsistentWith(current: ApplePods): Boolean {
|
||||||
|
val bound = boundProfileId ?: return true // not IRK-backed -> keyless, weak match allowed
|
||||||
|
return current.meta.profile?.id == bound
|
||||||
|
}
|
||||||
|
|
||||||
fun updateHistory(device: ApplePods) {
|
fun updateHistory(device: ApplePods) {
|
||||||
val existing = knownDevices[device.identifier]
|
val existing = knownDevices[device.identifier]
|
||||||
|
|
||||||
@@ -162,12 +181,16 @@ class PodHistoryRepo @Inject constructor(
|
|||||||
.mapNotNull { it.batteryCasePercent }
|
.mapNotNull { it.batteryCasePercent }
|
||||||
.lastOrNull()
|
.lastOrNull()
|
||||||
|
|
||||||
|
// Latch the IRK-resolved profile id (never cleared by an unbound frame), so it survives history trimming.
|
||||||
|
val boundProfileId = device.meta.profile?.id?.takeIf { device.meta.isIRKMatch }
|
||||||
|
|
||||||
knownDevices[device.identifier] = if (existing != null) {
|
knownDevices[device.identifier] = if (existing != null) {
|
||||||
val history = existing.history.plus(device)
|
val history = existing.history.plus(device)
|
||||||
existing.copy(
|
existing.copy(
|
||||||
seenCounter = existing.seenCounter + 1,
|
seenCounter = existing.seenCounter + 1,
|
||||||
history = history,
|
history = history,
|
||||||
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
|
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery,
|
||||||
|
boundProfileId = boundProfileId ?: existing.boundProfileId,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
log(TAG, DEBUG) { "Creating new history for ${device.logSummary()}" }
|
log(TAG, DEBUG) { "Creating new history for ${device.logSummary()}" }
|
||||||
@@ -177,7 +200,8 @@ class PodHistoryRepo @Inject constructor(
|
|||||||
seenFirstAt = device.seenFirstAt,
|
seenFirstAt = device.seenFirstAt,
|
||||||
seenCounter = 1,
|
seenCounter = 1,
|
||||||
history = history,
|
history = history,
|
||||||
lastCaseBattery = history.determineLatestCaseBattery()
|
lastCaseBattery = history.determineLatestCaseBattery(),
|
||||||
|
boundProfileId = boundProfileId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ data class AppleDeviceProfile(
|
|||||||
@SerialName("reactionNotifyWhenCharged") val notifyWhenCharged: Boolean = false,
|
@SerialName("reactionNotifyWhenCharged") val notifyWhenCharged: Boolean = false,
|
||||||
@SerialName("reactionChargedThreshold") val chargedThreshold: Int = ReactionConfig.DEFAULT_CHARGED_THRESHOLD,
|
@SerialName("reactionChargedThreshold") val chargedThreshold: Int = ReactionConfig.DEFAULT_CHARGED_THRESHOLD,
|
||||||
@SerialName("reactionChargedSlotScope") val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
|
@SerialName("reactionChargedSlotScope") val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
|
||||||
|
/** Whether the dashboard battery time-remaining estimate is shown for this device. */
|
||||||
|
@SerialName("batteryEstimateEnabled") val batteryEstimateEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
|
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
|
||||||
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
|
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
|
||||||
@@ -86,6 +88,7 @@ data class AppleDeviceProfile(
|
|||||||
"notifyWhenCharged=$notifyWhenCharged, " +
|
"notifyWhenCharged=$notifyWhenCharged, " +
|
||||||
"chargedThreshold=$chargedThreshold, " +
|
"chargedThreshold=$chargedThreshold, " +
|
||||||
"chargedSlotScope=$chargedSlotScope, " +
|
"chargedSlotScope=$chargedSlotScope, " +
|
||||||
|
"batteryEstimateEnabled=$batteryEstimateEnabled, " +
|
||||||
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
|
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
|
||||||
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
|
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
|
||||||
"stemActions=$stemActions" +
|
"stemActions=$stemActions" +
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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.serialization.SerializationCapod
|
import eu.darken.capod.common.serialization.SerializationCapod
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
|
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
||||||
import eu.darken.capod.monitor.core.cache.DeviceStateCache
|
import eu.darken.capod.monitor.core.cache.DeviceStateCache
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -30,6 +31,7 @@ class DeviceProfilesRepo @Inject constructor(
|
|||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
private val settings: DeviceProfilesSettings,
|
private val settings: DeviceProfilesSettings,
|
||||||
private val deviceStateCache: DeviceStateCache,
|
private val deviceStateCache: DeviceStateCache,
|
||||||
|
private val batteryDrainStore: BatteryDrainStore,
|
||||||
@SerializationCapod private val json: Json,
|
@SerializationCapod private val json: Json,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ class DeviceProfilesRepo @Inject constructor(
|
|||||||
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
|
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
|
||||||
log(VERBOSE) { "Removed device profile with ID: $profileId" }
|
log(VERBOSE) { "Removed device profile with ID: $profileId" }
|
||||||
deviceStateCache.delete(profileId)
|
deviceStateCache.delete(profileId)
|
||||||
|
batteryDrainStore.delete(profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
|
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
|
||||||
@@ -199,6 +202,7 @@ class DeviceProfilesRepo @Inject constructor(
|
|||||||
suspend fun clear() = mutex.withLock {
|
suspend fun clear() = mutex.withLock {
|
||||||
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
|
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
|
||||||
deviceStateCache.deleteAll()
|
deviceStateCache.deleteAll()
|
||||||
|
batteryDrainStore.deleteAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
|
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
|
||||||
|
|||||||
+232
-51
@@ -12,6 +12,8 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
|||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.primaryDevice
|
import eu.darken.capod.monitor.core.primaryDevice
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
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.ConversationAwarenessEvent
|
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||||
import eu.darken.capod.profiles.core.ReactionConfig
|
import eu.darken.capod.profiles.core.ReactionConfig
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -30,7 +32,6 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -40,17 +41,29 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
|
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
|
||||||
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
|
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
|
||||||
*
|
*
|
||||||
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP]).
|
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP],
|
||||||
* The pod sends NO frames during active speech — it stays engaged for as long as it hears nearby
|
* status `8,9`). The pod sends NO frames during active speech — it stays engaged for as long as it
|
||||||
* voices (observed: CA held engaged 21-32s with zero `0x4B` frames, indefinitely against ambient
|
* hears nearby voices (observed: CA held engaged 21-32s with zero `0x4B` frames, indefinitely
|
||||||
* noise). So frame-silence must NOT be read as "speaking ended"; after a START only the long
|
* against ambient noise). So frame-silence must NOT be read as "speaking ended"; after a START only
|
||||||
* [STALE_TIMEOUT] backstop applies, and a link drop is handled by the owner-disconnect revert.
|
* the long [STALE_TIMEOUT] backstop applies, and a link drop is handled by the owner-disconnect revert.
|
||||||
*
|
*
|
||||||
* Because the pod is silent while engaged, any non-START frame is evidence the wind-down has begun.
|
* A [ConversationAwarenessEvent.HOLD] frame (`3` pause, `0x0B`/`4` wind-down, `7` abort) is evidence
|
||||||
* The wind-down is a short flurry of transitional ([ConversationAwarenessEvent.HOLD]) and terminal
|
* the wind-down may have begun. The real wind-down is a short flurry `3→0x0B→4` then the `8,9`
|
||||||
* frames — but the terminal is sometimes dropped entirely (fw `…6589` emitted `3,0xB,4` then nothing,
|
* terminal — but the terminal is sometimes dropped entirely (single-pod wear: fw `…6589`/`…6861`
|
||||||
* stranding the volume low — #608). So a HOLD frame re-arms the timer with the short
|
* emitted `3,0xB,4` then nothing, stranding the volume low — #608). So a HOLD frame re-arms the
|
||||||
* [WIND_DOWN_TIMEOUT] fuse: if no terminal follows, speech is treated as ended anyway.
|
* timer with the short [WIND_DOWN_TIMEOUT] fuse: if no terminal (or resume) follows, speech is
|
||||||
|
* treated as ended anyway.
|
||||||
|
*
|
||||||
|
* A [ConversationAwarenessEvent.RESUME] frame (`5`) means speech resumed after a pause — in bursty
|
||||||
|
* talking the pod cycles `3,5,3,5,…` while CA stays engaged. It is NOT a terminal: it cancels any
|
||||||
|
* armed wind-down fuse and re-arms the long backstop, so media stays paused/ducked through the whole
|
||||||
|
* conversation. (Treating `5` as a stop was the premature-resume + stuck bug.)
|
||||||
|
*
|
||||||
|
* Removing/inserting a pod makes the firmware re-key CA — it emits a terminal then a fresh onset
|
||||||
|
* around the physical change even though the conversation continues. A terminal arriving within
|
||||||
|
* [EAR_TRANSITION_WINDOW] of an AAP ear-detection change is therefore treated as a re-key artifact:
|
||||||
|
* it defers to the wind-down fuse instead of disengaging, so a brief pod removal mid-conversation
|
||||||
|
* neither strands a pause nor causes a duck→restore→re-duck volume blip.
|
||||||
*
|
*
|
||||||
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
|
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
|
||||||
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
|
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
|
||||||
@@ -78,16 +91,42 @@ class ConversationReaction @Inject constructor(
|
|||||||
val at: Long,
|
val at: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** What the single pending [disengageJob] is currently waiting for. */
|
||||||
|
private enum class TimerPhase {
|
||||||
|
/** Long backstop while only START/RESUME seen — inferred end if it fires. */
|
||||||
|
STALE_BACKSTOP,
|
||||||
|
|
||||||
|
/** Short fuse armed by a wind-down HOLD — terminal imminent (or dropped, #608). */
|
||||||
|
WIND_DOWN_FUSE,
|
||||||
|
|
||||||
|
/** Brief wait on an uncorroborated cold terminal to see if a pod-removal ear change follows. */
|
||||||
|
STOP_SETTLE,
|
||||||
|
}
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private var active: Active? = null
|
private var active: Active? = null
|
||||||
private var staleJob: Job? = null
|
private var disengageJob: Job? = null
|
||||||
|
private var timerPhase: TimerPhase? = null
|
||||||
|
// Bumped on every (re)arm. A timer job captures its generation and only acts if still current —
|
||||||
|
// guards against a stale job that already passed its delay (so cancel() no longer stops it) being
|
||||||
|
// re-armed to the SAME phase while it blocks on the mutex. This is a runtime concurrency guard: the
|
||||||
|
// single-threaded virtual-time test harness serializes the timer callback and event handlers, so a
|
||||||
|
// re-arm there always cancels the prior job before it runs — the race is not reproducible in a unit
|
||||||
|
// test (a test would exercise cancel(), not this check). Left deliberately uncovered, not an oversight.
|
||||||
|
private var timerGeneration = 0L
|
||||||
private var idCounter = 0L
|
private var idCounter = 0L
|
||||||
|
|
||||||
|
// Per-owner AAP ear-detection tracking, so a CA terminal that lands right after a pod
|
||||||
|
// removal/insertion can be recognised as a re-key artifact rather than a real conversation end.
|
||||||
|
private val lastEarDetection = mutableMapOf<BluetoothAddress, AapSetting.EarDetection?>()
|
||||||
|
private val earTransitionAt = mutableMapOf<BluetoothAddress, Long>()
|
||||||
|
|
||||||
fun monitor(): Flow<Unit> = merge(
|
fun monitor(): Flow<Unit> = merge(
|
||||||
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
|
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
|
||||||
// Reverts a stranded duck when the owning device leaves the AAP state map for any reason
|
// Tracks ear-detection transitions (for terminal-artifact suppression) and reverts a stranded
|
||||||
// (intentional or not) — disconnectEvents only fires for unintentional drops.
|
// duck when the owning device leaves the AAP state map for any reason (intentional or not) —
|
||||||
aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) },
|
// disconnectEvents only fires for unintentional drops.
|
||||||
|
aapManager.allStates.onEach { states -> onStatesUpdated(states) },
|
||||||
)
|
)
|
||||||
.map { }
|
.map { }
|
||||||
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
|
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
|
||||||
@@ -96,6 +135,7 @@ class ConversationReaction @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
|
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
|
||||||
ConversationAwarenessEvent.START -> onSpeakingStart(address)
|
ConversationAwarenessEvent.START -> onSpeakingStart(address)
|
||||||
|
ConversationAwarenessEvent.RESUME -> onSpeakingResume(address)
|
||||||
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
|
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
|
||||||
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
|
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
|
||||||
}
|
}
|
||||||
@@ -113,13 +153,17 @@ class ConversationReaction @Inject constructor(
|
|||||||
val current = active
|
val current = active
|
||||||
if (current != null && current.owner == address) {
|
if (current != null && current.owner == address) {
|
||||||
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
||||||
// A START also cancels a pending wind-down fuse: the wearer is speaking again.
|
// A START also cancels a pending wind-down fuse / settle: the wearer is speaking again.
|
||||||
armDisengageTimer(current, STALE_TIMEOUT)
|
armTimer(current, TimerPhase.STALE_BACKSTOP)
|
||||||
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A different device started speaking while we were active — undo the old one first.
|
// A different device started speaking while we were active — undo the old one first and
|
||||||
if (current != null) revert(current, "superseded by $address")
|
// cancel its pending timer (the no-op engage paths below would otherwise leak it).
|
||||||
|
if (current != null) {
|
||||||
|
revert(current, "superseded by $address")
|
||||||
|
clearActive()
|
||||||
|
}
|
||||||
|
|
||||||
when (action) {
|
when (action) {
|
||||||
ConversationAction.PAUSE -> {
|
ConversationAction.PAUSE -> {
|
||||||
@@ -127,7 +171,7 @@ class ConversationReaction @Inject constructor(
|
|||||||
if (paused) {
|
if (paused) {
|
||||||
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
|
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
|
||||||
active = record
|
active = record
|
||||||
armDisengageTimer(record, STALE_TIMEOUT)
|
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||||
log(TAG, INFO) { "START on $address → paused media" }
|
log(TAG, INFO) { "START on $address → paused media" }
|
||||||
} else {
|
} else {
|
||||||
active = null
|
active = null
|
||||||
@@ -150,7 +194,7 @@ class ConversationReaction @Inject constructor(
|
|||||||
timeSource.elapsedRealtime(),
|
timeSource.elapsedRealtime(),
|
||||||
)
|
)
|
||||||
active = record
|
active = record
|
||||||
armDisengageTimer(record, STALE_TIMEOUT)
|
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||||
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" }
|
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" }
|
||||||
} else {
|
} else {
|
||||||
active = null
|
active = null
|
||||||
@@ -164,14 +208,29 @@ class ConversationReaction @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transitional wind-down frame. The pod is silent during active speech, so this frame means the
|
* Speech resumed (status `5`) after a pause — the wind-down was aborted, the wearer is talking
|
||||||
* wind-down has begun and a terminal frame is imminent — but firmware sometimes drops it (#608).
|
* again. Cancel any armed wind-down fuse and re-arm the long [STALE_TIMEOUT] backstop, exactly
|
||||||
* Arm the short fuse: if no terminal (or fresh START) follows, disengage anyway.
|
* like a duplicate-START keep-alive, so media stays paused/ducked through the conversation. Does
|
||||||
|
* NOT engage from scratch: a RESUME with no active session is ignored (a conversation always
|
||||||
|
* opens with a START, and a stray `5` should never start a pause/duck on its own).
|
||||||
|
*/
|
||||||
|
private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock {
|
||||||
|
val current = active ?: return
|
||||||
|
if (current.owner != address) return
|
||||||
|
armTimer(current, TimerPhase.STALE_BACKSTOP)
|
||||||
|
log(TAG) { "RESUME from $address — speech resumed, keep-alive" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transitional wind-down frame (`3` pause, `0x0B`/`4` wind-down, `7` abort). The pod is silent
|
||||||
|
* during active speech, so this frame means the wind-down may have begun and a terminal frame is
|
||||||
|
* imminent — but firmware sometimes drops it (#608, single-pod wear). Arm the short fuse: if no
|
||||||
|
* terminal (or a fresh START / RESUME) follows, disengage anyway.
|
||||||
*/
|
*/
|
||||||
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
|
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
|
||||||
val current = active ?: return
|
val current = active ?: return
|
||||||
if (current.owner != address) return
|
if (current.owner != address) return
|
||||||
armDisengageTimer(current, WIND_DOWN_TIMEOUT)
|
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onSpeakingStop(address: BluetoothAddress) {
|
private suspend fun onSpeakingStop(address: BluetoothAddress) {
|
||||||
@@ -182,13 +241,42 @@ class ConversationReaction @Inject constructor(
|
|||||||
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
|
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearActive()
|
// The 0x06 ear-detection frame may have arrived but not yet been processed by the
|
||||||
disengage(current, primary, "STOP on $address")
|
// allStates collector — fold the latest snapshot in before deciding (in-app ordering race).
|
||||||
|
refreshEarTransition(address)
|
||||||
|
|
||||||
|
// Pulling/inserting a pod makes the firmware re-key CA — a terminal (8,9) then a fresh
|
||||||
|
// onset (1,2) around the physical change, even though the conversation continues.
|
||||||
|
when {
|
||||||
|
// Ear change already seen → re-key artifact. Defer to the fuse; a re-onset cancels it.
|
||||||
|
isRecentEarTransition(address) -> {
|
||||||
|
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
|
||||||
|
log(TAG) { "STOP from $address during ear transition — deferring to fuse" }
|
||||||
|
}
|
||||||
|
// Corroborated by a preceding wind-down (3,0xB,4 / abort 7): a real end → resume now.
|
||||||
|
timerPhase == TimerPhase.WIND_DOWN_FUSE -> {
|
||||||
|
clearActive()
|
||||||
|
disengage(current, primary, "STOP on $address", applyAgeGuard = false)
|
||||||
|
}
|
||||||
|
// Cold terminal with no wind-down and no ear change yet. On primary-pod removal the
|
||||||
|
// firmware sends the terminal tens of ms BEFORE the ear-detection frame (measured ~30ms
|
||||||
|
// on Pro 3), so wait a short settle to see if an ear change lands before treating it as
|
||||||
|
// a real end.
|
||||||
|
else -> {
|
||||||
|
armTimer(current, TimerPhase.STOP_SETTLE)
|
||||||
|
log(TAG) { "STOP from $address — uncorroborated, settling" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Graceful disengage (STOP event or stale timeout). Must be called under [mutex]. */
|
/**
|
||||||
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String) {
|
* Graceful disengage (STOP event or timer expiry). Must be called under [mutex]. [applyAgeGuard]
|
||||||
|
* gates resume on [PAUSE_RESUME_WINDOW]: `true` only for the inferred stale-backstop end (we lost
|
||||||
|
* track and timed out — surprise-resuming long after is worse than a stranded pause); `false` for
|
||||||
|
* an explicit terminal or the wind-down fuse, where a recent/real end signal makes resume correct.
|
||||||
|
*/
|
||||||
|
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String, applyAgeGuard: Boolean) {
|
||||||
when (val kind = record.kind) {
|
when (val kind = record.kind) {
|
||||||
is Kind.Paused -> {
|
is Kind.Paused -> {
|
||||||
// Resume the pause WE caused, regardless of the current action setting. Gating on
|
// Resume the pause WE caused, regardless of the current action setting. Gating on
|
||||||
@@ -197,11 +285,11 @@ class ConversationReaction @Inject constructor(
|
|||||||
// surprising behaviour. The remaining guards are about real device/playback state.
|
// surprising behaviour. The remaining guards are about real device/playback state.
|
||||||
val age = timeSource.elapsedRealtime() - record.at
|
val age = timeSource.elapsedRealtime() - record.at
|
||||||
when {
|
when {
|
||||||
age.milliseconds > PAUSE_RESUME_WINDOW ->
|
applyAgeGuard && age.milliseconds > PAUSE_RESUME_WINDOW ->
|
||||||
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
|
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
|
||||||
primary?.address != record.owner ->
|
primary?.address != record.owner ->
|
||||||
log(TAG) { "$reason — resume skipped (primary switched)" }
|
log(TAG) { "$reason — resume skipped (primary switched)" }
|
||||||
primary.isBeingWorn == false ->
|
wornForResume(primary) == false ->
|
||||||
log(TAG) { "$reason — resume skipped (not worn)" }
|
log(TAG) { "$reason — resume skipped (not worn)" }
|
||||||
mediaControl.isPlaying ->
|
mediaControl.isPlaying ->
|
||||||
log(TAG) { "$reason — resume skipped (already playing)" }
|
log(TAG) { "$reason — resume skipped (already playing)" }
|
||||||
@@ -216,14 +304,35 @@ class ConversationReaction @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onActiveDevicesChanged(addresses: Set<BluetoothAddress>) = mutex.withLock {
|
private suspend fun onStatesUpdated(states: Map<BluetoothAddress, AapPodState>) = mutex.withLock {
|
||||||
|
// Record when each device's AAP ear-detection last changed (pod removed/inserted/cased).
|
||||||
|
for ((address, state) in states) recordEarIfChanged(address, state.aapEarDetection)
|
||||||
|
lastEarDetection.keys.retainAll(states.keys)
|
||||||
|
earTransitionAt.keys.retainAll(states.keys)
|
||||||
|
|
||||||
val current = active ?: return
|
val current = active ?: return
|
||||||
if (current.owner !in addresses) {
|
if (current.owner !in states.keys) {
|
||||||
clearActive()
|
clearActive()
|
||||||
revert(current, "owner ${current.owner} gone")
|
revert(current, "owner ${current.owner} gone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be called under [mutex]. Notes an AAP ear-detection transition for [address]. The very
|
||||||
|
* first observation of a device only seeds the baseline — it is not counted as a transition.
|
||||||
|
*/
|
||||||
|
private fun recordEarIfChanged(address: BluetoothAddress, ear: AapSetting.EarDetection?) {
|
||||||
|
if (lastEarDetection.containsKey(address) && lastEarDetection[address] != ear) {
|
||||||
|
earTransitionAt[address] = timeSource.elapsedRealtime()
|
||||||
|
}
|
||||||
|
lastEarDetection[address] = ear
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must be called under [mutex]. Folds the latest allStates snapshot in for [address]. */
|
||||||
|
private fun refreshEarTransition(address: BluetoothAddress) {
|
||||||
|
recordEarIfChanged(address, aapManager.allStates.value[address]?.aapEarDetection)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun onMonitorCompleted() = mutex.withLock {
|
private suspend fun onMonitorCompleted() = mutex.withLock {
|
||||||
val current = active ?: return
|
val current = active ?: return
|
||||||
clearActive()
|
clearActive()
|
||||||
@@ -249,41 +358,86 @@ class ConversationReaction @Inject constructor(
|
|||||||
mediaControl.restoreMusicVolume(kind.priorVolume)
|
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Must be called under [mutex]. Clears the active slot and cancels its stale timer. */
|
/** Must be called under [mutex]. Clears the active slot and cancels its pending timer. */
|
||||||
private fun clearActive() {
|
private fun clearActive() {
|
||||||
staleJob?.cancel()
|
disengageJob?.cancel()
|
||||||
staleJob = null
|
disengageJob = null
|
||||||
|
timerPhase = null
|
||||||
active = null
|
active = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be called under [mutex]. (Re)arms the disengage timer for [record] — every frame picks
|
* Must be called under [mutex]. (Re)arms the single disengage timer for [record] in [phase],
|
||||||
* the fuse matching its meaning: START → [STALE_TIMEOUT] (active speech, frames cease for its
|
* replacing whatever was pending. On expiry [onTimerExpired] decides per phase. Guarded on both
|
||||||
* whole duration), HOLD → [WIND_DOWN_TIMEOUT] (wind-down begun, terminal imminent). On expiry
|
* [Active.id] and [phase] so a late timer can't act on a newer session or a re-armed phase.
|
||||||
* the session is force-ended. Identity-checked so a late timer can't disengage a newer session.
|
|
||||||
*/
|
*/
|
||||||
private fun armDisengageTimer(record: Active, timeout: Duration) {
|
private fun armTimer(record: Active, phase: TimerPhase) {
|
||||||
staleJob?.cancel()
|
disengageJob?.cancel()
|
||||||
staleJob = appScope.launch {
|
timerPhase = phase
|
||||||
|
val generation = ++timerGeneration
|
||||||
|
val timeout = when (phase) {
|
||||||
|
TimerPhase.STALE_BACKSTOP -> STALE_TIMEOUT
|
||||||
|
TimerPhase.WIND_DOWN_FUSE -> WIND_DOWN_TIMEOUT
|
||||||
|
TimerPhase.STOP_SETTLE -> STOP_SETTLE_DELAY
|
||||||
|
}
|
||||||
|
disengageJob = appScope.launch {
|
||||||
delay(timeout)
|
delay(timeout)
|
||||||
val primary = deviceMonitor.primaryDevice().first()
|
val primary = deviceMonitor.primaryDevice().first()
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (active?.id == record.id) {
|
// generation guards against a stale job re-armed to the same phase for the same record.
|
||||||
val current = active!!
|
if (active?.id == record.id && timerGeneration == generation) onTimerExpired(record, phase, primary)
|
||||||
clearActive()
|
|
||||||
disengage(current, primary, "no terminal frame within $timeout")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be called under [mutex] from inside the firing timer job. Detaches the slot WITHOUT
|
||||||
|
* cancelling (we ARE that job — self-cancel could abort a following suspension), then acts:
|
||||||
|
* - STOP_SETTLE + an ear change appeared meanwhile → re-key artifact, hand off to the fuse.
|
||||||
|
* - otherwise force-end the session. The age guard applies only to the inferred STALE backstop;
|
||||||
|
* the wind-down fuse and a settled cold terminal carry real/recent end evidence → resume regardless.
|
||||||
|
*/
|
||||||
|
private suspend fun onTimerExpired(record: Active, phase: TimerPhase, primary: PodDevice?) {
|
||||||
|
disengageJob = null
|
||||||
|
timerPhase = null
|
||||||
|
if (phase == TimerPhase.STOP_SETTLE && isRecentEarTransition(record.owner)) {
|
||||||
|
armTimer(record, TimerPhase.WIND_DOWN_FUSE)
|
||||||
|
log(TAG) { "STOP settle elapsed for ${record.owner} — ear transition appeared, deferring to fuse" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active = null
|
||||||
|
val reason = when (phase) {
|
||||||
|
TimerPhase.STALE_BACKSTOP -> "no terminal within backstop"
|
||||||
|
TimerPhase.WIND_DOWN_FUSE -> "no terminal within wind-down fuse"
|
||||||
|
TimerPhase.STOP_SETTLE -> "cold terminal settled"
|
||||||
|
}
|
||||||
|
disengage(record, primary, reason, applyAgeGuard = phase == TimerPhase.STALE_BACKSTOP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the pod is worn enough to resume into, honoring One-Pod Mode (mirrors
|
||||||
|
* [eu.darken.capod.reaction.core.autoconnect.AutoConnect]): with One-Pod Mode on, a single pod
|
||||||
|
* in ear counts as worn, falling back to the both-pods reading only when the single-pod signal is
|
||||||
|
* unknown. Returns `null` when ear state is genuinely unknown — the caller treats only an explicit
|
||||||
|
* `false` as not-worn, so an unknown stays lenient (resumes).
|
||||||
|
*/
|
||||||
|
private fun wornForResume(device: PodDevice): Boolean? =
|
||||||
|
if (device.reactions.onePodMode) device.isEitherPodInEar ?: device.isBeingWorn
|
||||||
|
else device.isBeingWorn
|
||||||
|
|
||||||
|
/** Must be called under [mutex]. True if [address] had an AAP ear-detection change very recently. */
|
||||||
|
private fun isRecentEarTransition(address: BluetoothAddress): Boolean {
|
||||||
|
val at = earTransitionAt[address] ?: return false
|
||||||
|
return (timeSource.elapsedRealtime() - at).milliseconds <= EAR_TRANSITION_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
private fun nextId(): Long = ++idCounter
|
private fun nextId(): Long = ++idCounter
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Reaction", "Conversation")
|
private val TAG = logTag("Reaction", "Conversation")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backstop fuse while engaged with no wind-down evidence yet (only START frames seen).
|
* Backstop fuse while engaged with no wind-down evidence yet (START/RESUME frames seen).
|
||||||
* Must stay LONG: the pod sends zero frames during active speech and stays engaged against
|
* Must stay LONG: the pod sends zero frames during active speech and stays engaged against
|
||||||
* ambient noise, so a short timeout here resumes media mid-conversation (the original 12s
|
* ambient noise, so a short timeout here resumes media mid-conversation (the original 12s
|
||||||
* value did exactly that). Only recovers a session whose entire wind-down flurry was lost
|
* value did exactly that). Only recovers a session whose entire wind-down flurry was lost
|
||||||
@@ -299,11 +453,38 @@ class ConversationReaction @Inject constructor(
|
|||||||
* pod deterministically drops the terminal (#608, reproduced on Pro 3 and Pro 2) — this
|
* pod deterministically drops the terminal (#608, reproduced on Pro 3 and Pro 2) — this
|
||||||
* fuse disengages instead of stranding the volume low for [STALE_TIMEOUT]. Must be ≥ ~5s:
|
* fuse disengages instead of stranding the volume low for [STALE_TIMEOUT]. Must be ≥ ~5s:
|
||||||
* gaps up to 2.8s were observed between consecutive wind-down frames, and each HOLD re-arms
|
* gaps up to 2.8s were observed between consecutive wind-down frames, and each HOLD re-arms
|
||||||
* this fuse. A fresh START re-arms the long fuse (speaking resumed).
|
* this fuse. A fresh START or RESUME (`5`, speech resumed) re-arms the long fuse instead.
|
||||||
*/
|
*/
|
||||||
private val WIND_DOWN_TIMEOUT = 6.seconds
|
private val WIND_DOWN_TIMEOUT = 6.seconds
|
||||||
|
|
||||||
/** A pause older than this no longer auto-resumes — unexpected late playback is worse. */
|
/**
|
||||||
|
* For an *inferred* end only (the [STALE_TIMEOUT] backstop fired — we lost track of the
|
||||||
|
* conversation), a pause older than this no longer auto-resumes: surprise playback long after
|
||||||
|
* is worse than a stranded pause. An explicit terminal frame, or the short wind-down fuse,
|
||||||
|
* resumes regardless of age — those carry real/recent evidence the conversation just ended.
|
||||||
|
*/
|
||||||
private val PAUSE_RESUME_WINDOW = 2.minutes
|
private val PAUSE_RESUME_WINDOW = 2.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CA terminal landing within this window of an AAP ear-detection change is treated as a pod
|
||||||
|
* removal/insertion re-key artifact (firmware emits 8,9 then 1,2 around the physical change),
|
||||||
|
* not a real end. The artifact terminal and the ear change land within tens of ms of each other
|
||||||
|
* (measured ~30ms on Pro 3, either side depending on which pod); a real end is seconds away.
|
||||||
|
* Kept well under that gap so genuine terminals aren't deferred to the fuse. (The ear-detection
|
||||||
|
* 0x06 frames are emitted on removal even when the EarDetectionEnabled setting is off, so this
|
||||||
|
* suppression does not depend on that setting being enabled.)
|
||||||
|
*/
|
||||||
|
private val EAR_TRANSITION_WINDOW = 2.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait on an *uncorroborated cold* terminal (no preceding wind-down HOLD, no ear
|
||||||
|
* change yet) before treating it as a real end. Covers the reverse frame-ordering on
|
||||||
|
* primary-pod removal, where the firmware sends the terminal tens of ms BEFORE the ear-detection
|
||||||
|
* frame (measured ~30ms on Pro 3) — too early for the backward-looking check. Only cold
|
||||||
|
* terminals settle; a normal end
|
||||||
|
* (`…3,0xB,4,8`) and an abort (`7,8`) are already corroborated by the fuse and resume instantly,
|
||||||
|
* so this adds no latency to genuine conversation ends.
|
||||||
|
*/
|
||||||
|
private val STOP_SETTLE_DELAY = 250.milliseconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.filter
|
|||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -223,6 +224,8 @@ class PlayPause @Inject constructor(
|
|||||||
rawDecision = confirmation.decision,
|
rawDecision = confirmation.decision,
|
||||||
currentState = currState,
|
currentState = currState,
|
||||||
autoPauseEnabled = reactions.autoPause,
|
autoPauseEnabled = reactions.autoPause,
|
||||||
|
now = current.ble?.seenLastAt,
|
||||||
|
generatedAtNanos = current.ble?.scanResult?.generatedAtNanos,
|
||||||
)
|
)
|
||||||
pendingPauseDebounce = debounceResult.pending
|
pendingPauseDebounce = debounceResult.pending
|
||||||
|
|
||||||
@@ -240,7 +243,7 @@ class PlayPause @Inject constructor(
|
|||||||
"rawShouldPlay=${confirmation.decision.shouldPlay}"
|
"rawShouldPlay=${confirmation.decision.shouldPlay}"
|
||||||
}
|
}
|
||||||
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
|
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
|
||||||
"Pause debounce committed: source=$source confirmed pause"
|
"Pause debounce committed: source=$source, ${debounceResult.decision.reason}"
|
||||||
}
|
}
|
||||||
PauseDebounceEvent.NONE -> {}
|
PauseDebounceEvent.NONE -> {}
|
||||||
}
|
}
|
||||||
@@ -438,12 +441,21 @@ class PlayPause @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sample-count debounce for pause decisions when the ear-detection source is an
|
* Hybrid sample-count + time-cap debounce for pause decisions when the ear-detection source
|
||||||
* unauthenticated BLE advertisement.
|
* is an unauthenticated BLE advertisement.
|
||||||
*
|
*
|
||||||
* RF interference can produce a single corrupt advert that decodes as not-worn,
|
* RF interference can produce a single corrupt advert that decodes as not-worn, triggering a
|
||||||
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
|
* false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires 3 consecutive not-worn
|
||||||
* 3 consecutive not-worn samples before firing.
|
* samples before firing on the *count* path.
|
||||||
|
*
|
||||||
|
* On OEM stacks that deliver scan results in slow (~1.5-2s) batches, waiting for the full
|
||||||
|
* sample count would take ~4s. To cap that, the pause also commits early once the not-worn
|
||||||
|
* condition has persisted at least [PAUSE_DEBOUNCE_TIME_CAP] — but only when the samples are
|
||||||
|
* still strictly consecutive (no tolerated rebound) and the confirming sample is a *distinct*
|
||||||
|
* radio reception ([BleScanResult.generatedAtNanos] differs from the first). The time path
|
||||||
|
* therefore never commits on fewer than 2 consecutive, distinct not-worn receptions. Elapsed
|
||||||
|
* uses [now] (the sample's `ble.seenLastAt`, a callback-receive wall-clock) clamped to ≥ 0.
|
||||||
|
* When [now]/[generatedAtNanos] are null (no live BLE sample) the time path is inactive.
|
||||||
*
|
*
|
||||||
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
|
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
|
||||||
* — subsequent samples after the initial detection are not-worn → not-worn, and
|
* — subsequent samples after the initial detection are not-worn → not-worn, and
|
||||||
@@ -462,6 +474,8 @@ class PlayPause @Inject constructor(
|
|||||||
rawDecision: PlayPauseDecision,
|
rawDecision: PlayPauseDecision,
|
||||||
currentState: EarDetectionState,
|
currentState: EarDetectionState,
|
||||||
autoPauseEnabled: Boolean,
|
autoPauseEnabled: Boolean,
|
||||||
|
now: Instant? = null,
|
||||||
|
generatedAtNanos: Long? = null,
|
||||||
): PauseDebounceResult {
|
): PauseDebounceResult {
|
||||||
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||||
source == EarDetectionSource.BLE_ANONYMOUS
|
source == EarDetectionSource.BLE_ANONYMOUS
|
||||||
@@ -525,6 +539,8 @@ class PlayPause @Inject constructor(
|
|||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
initialPodCount = currentState.podCount,
|
initialPodCount = currentState.podCount,
|
||||||
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
|
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
|
||||||
|
startedAt = now,
|
||||||
|
startedGeneratedAtNanos = generatedAtNanos,
|
||||||
),
|
),
|
||||||
event = PauseDebounceEvent.STARTED,
|
event = PauseDebounceEvent.STARTED,
|
||||||
)
|
)
|
||||||
@@ -545,7 +561,10 @@ class PlayPause @Inject constructor(
|
|||||||
shouldPause = false,
|
shouldPause = false,
|
||||||
reason = "Debouncing pause (rebound tolerated)",
|
reason = "Debouncing pause (rebound tolerated)",
|
||||||
),
|
),
|
||||||
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
|
pending = activePending.copy(
|
||||||
|
resetTolerance = activePending.resetTolerance - 1,
|
||||||
|
reboundTolerated = true,
|
||||||
|
),
|
||||||
event = PauseDebounceEvent.ADVANCED,
|
event = PauseDebounceEvent.ADVANCED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -554,12 +573,41 @@ class PlayPause @Inject constructor(
|
|||||||
|
|
||||||
// Confirmation: count <= initialPodCount, decrement remaining.
|
// Confirmation: count <= initialPodCount, decrement remaining.
|
||||||
val remaining = activePending.confirmationsRemaining - 1
|
val remaining = activePending.confirmationsRemaining - 1
|
||||||
if (remaining <= 0) {
|
val commitOnCount = remaining <= 0
|
||||||
|
|
||||||
|
// Time-cap early commit: once the not-worn condition has persisted at least
|
||||||
|
// PAUSE_DEBOUNCE_TIME_CAP, commit before the full sample count is reached — this caps the
|
||||||
|
// pause latency on OEM stacks that deliver scan results in slow batches. Three guards keep
|
||||||
|
// the "≥2 consecutive, distinct not-worn receptions" invariant:
|
||||||
|
// - reboundTolerated: a count-up rebound broke the consecutive not-worn run → the time
|
||||||
|
// path is disabled and we fall back to pure sample-count.
|
||||||
|
// - distinct generatedAtNanos: the confirming sample must be a different radio reception
|
||||||
|
// than the first, so a stack re-delivering one cached advert in a later batch (fresh
|
||||||
|
// seenLastAt, same reception) cannot early-commit on a single physical advert. A
|
||||||
|
// broken/constant OEM timebase just disables the time path (fail-safe to count).
|
||||||
|
// - elapsed clamped to >= 0: a backward seenLastAt jump (wall-clock step, or the backing
|
||||||
|
// snapshot switching to a different physical device under BLE_PROFILE_FALLBACK) can
|
||||||
|
// only delay, never prematurely fire.
|
||||||
|
val startedAt = activePending.startedAt
|
||||||
|
val startedNanos = activePending.startedGeneratedAtNanos
|
||||||
|
val elapsed = if (startedAt != null && now != null) {
|
||||||
|
Duration.between(startedAt, now).coerceAtLeast(Duration.ZERO)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val commitOnTime = !activePending.reboundTolerated &&
|
||||||
|
elapsed != null && elapsed >= PAUSE_DEBOUNCE_TIME_CAP &&
|
||||||
|
startedNanos != null && generatedAtNanos != null &&
|
||||||
|
generatedAtNanos != startedNanos
|
||||||
|
|
||||||
|
if (commitOnCount || commitOnTime) {
|
||||||
|
val mode = if (commitOnCount) "count" else "time(${elapsed?.toMillis()}ms)"
|
||||||
return PauseDebounceResult(
|
return PauseDebounceResult(
|
||||||
decision = PlayPauseDecision(
|
decision = PlayPauseDecision(
|
||||||
shouldPlay = false,
|
shouldPlay = false,
|
||||||
shouldPause = true,
|
shouldPause = true,
|
||||||
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
|
reason = "Debounced pause confirmed via $mode " +
|
||||||
|
"(initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
|
||||||
),
|
),
|
||||||
pending = null,
|
pending = null,
|
||||||
event = PauseDebounceEvent.COMMITTED,
|
event = PauseDebounceEvent.COMMITTED,
|
||||||
@@ -674,6 +722,18 @@ class PlayPause @Inject constructor(
|
|||||||
// shows a pod returning shouldn't kill the pending, since the next sample may
|
// shows a pod returning shouldn't kill the pending, since the next sample may
|
||||||
// confirm the pods are still out.
|
// confirm the pods are still out.
|
||||||
val resetTolerance: Int = 1,
|
val resetTolerance: Int = 1,
|
||||||
|
// Observation time (ble.seenLastAt, a callback-receive wall-clock) of the first
|
||||||
|
// not-worn sample. null → the time-cap early commit is inactive for this pending.
|
||||||
|
val startedAt: Instant? = null,
|
||||||
|
// Hardware radio-reception timestamp (ScanResult.timestampNanos) of the first
|
||||||
|
// not-worn sample. The time-cap commit requires the confirming sample to be a
|
||||||
|
// DISTINCT reception (different generatedAtNanos), so a janky OEM re-delivering the
|
||||||
|
// same cached advert in a later batch cannot early-commit on one physical advert.
|
||||||
|
val startedGeneratedAtNanos: Long? = null,
|
||||||
|
// Set once a count-up rebound has been tolerated. Disables the time-cap early commit
|
||||||
|
// (the not-worn samples are no longer strictly consecutive), falling back to pure
|
||||||
|
// sample-count confirmation.
|
||||||
|
val reboundTolerated: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
|
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
|
||||||
@@ -801,5 +861,19 @@ class PlayPause @Inject constructor(
|
|||||||
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
|
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
|
||||||
*/
|
*/
|
||||||
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
|
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on how long the sample-count debounce is allowed to stretch. Once the
|
||||||
|
* not-worn condition has persisted this long (measured from the first not-worn sample's
|
||||||
|
* observation time) with at least one further *distinct* not-worn reception and no
|
||||||
|
* tolerated rebound, the pause commits early — even if [PAUSE_DEBOUNCE_SAMPLES] hasn't
|
||||||
|
* been reached. This caps the delay on OEM BLE stacks (e.g. Samsung/OneUI) that deliver
|
||||||
|
* scan results in slow ~1.5-2s batches, where a pure sample count would take ~4s.
|
||||||
|
*
|
||||||
|
* Does not weaken corruption protection: an early commit still requires ≥2 consecutive,
|
||||||
|
* distinct not-worn receptions (see [applyPauseDebounce]). Cadences below ~2× this value
|
||||||
|
* see no change (count commits first). Tunable.
|
||||||
|
*/
|
||||||
|
internal val PAUSE_DEBOUNCE_TIME_CAP: Duration = Duration.ofMillis(1500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|||||||
import eu.darken.capod.common.flow.withPrevious
|
import eu.darken.capod.common.flow.withPrevious
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.primaryDevice
|
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -35,7 +34,7 @@ class PopUpReaction @Inject constructor(
|
|||||||
|
|
||||||
private val caseCoolDowns = java.util.concurrent.ConcurrentHashMap<String, Instant>()
|
private val caseCoolDowns = java.util.concurrent.ConcurrentHashMap<String, Instant>()
|
||||||
|
|
||||||
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
|
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDeviceByTier
|
||||||
.distinctUntilChangedBy {
|
.distinctUntilChangedBy {
|
||||||
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
|
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
|
||||||
// lid state. The latter is essential: caseLidState is recovered from history, so it can
|
// lid state. The latter is essential: caseLidState is recovered from history, so it can
|
||||||
@@ -122,7 +121,7 @@ class PopUpReaction @Inject constructor(
|
|||||||
|
|
||||||
private fun monitorConnection(): Flow<Event> = combine(
|
private fun monitorConnection(): Flow<Event> = combine(
|
||||||
bluetoothManager.connectedDevices,
|
bluetoothManager.connectedDevices,
|
||||||
deviceMonitor.primaryDevice().distinctUntilChangedBy {
|
deviceMonitor.primaryDeviceByTier.distinctUntilChangedBy {
|
||||||
Triple(it?.profileId, it?.reactions?.showPopUpOnConnection, it?.rawDataHex)
|
Triple(it?.profileId, it?.reactions?.showPopUpOnConnection, it?.rawDataHex)
|
||||||
},
|
},
|
||||||
) { devices, broadcast ->
|
) { devices, broadcast ->
|
||||||
@@ -208,7 +207,7 @@ class PopUpReaction @Inject constructor(
|
|||||||
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
|
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
|
||||||
*/
|
*/
|
||||||
private fun monitorCaseStaleClose(): Flow<Event> = combine(
|
private fun monitorCaseStaleClose(): Flow<Event> = combine(
|
||||||
deviceMonitor.primaryDevice(),
|
deviceMonitor.primaryDeviceByTier,
|
||||||
staleCheckTicker(),
|
staleCheckTicker(),
|
||||||
) { device, _ -> isCaseOpenBroadcastFresh(device) }
|
) { device, _ -> isCaseOpenBroadcastFresh(device) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<clip>
|
<clip>
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<corners android:radius="4dp" />
|
<corners android:radius="4dp" />
|
||||||
<solid android:color="?android:attr/colorAccent" />
|
<solid android:color="@color/brand_primary" />
|
||||||
</shape>
|
</shape>
|
||||||
</clip>
|
</clip>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
android:id="@+id/pod_left_icon"
|
android:id="@+id/pod_left_icon"
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/device_airpods_gen1_left" />
|
android:src="@drawable/device_airpods_gen1_left" />
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
tools:text="95%" />
|
tools:text="95% · 5h 40m" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="36dp"
|
android:layout_width="36dp"
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -67,8 +67,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_hearing_24" />
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
android:id="@+id/pod_case_icon"
|
android:id="@+id/pod_case_icon"
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/device_airpods_gen1_case" />
|
android:src="@drawable/device_airpods_gen1_case" />
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -142,8 +142,8 @@
|
|||||||
android:id="@+id/pod_right_icon"
|
android:id="@+id/pod_right_icon"
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/device_airpods_gen1_right" />
|
android:src="@drawable/device_airpods_gen1_right" />
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
tools:text="92%" />
|
tools:text="92% · 5h 25m" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="36dp"
|
android:layout_width="36dp"
|
||||||
@@ -176,8 +176,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -185,8 +185,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_hearing_24" />
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -46,6 +48,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_hearing_24" />
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -76,6 +80,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -105,6 +111,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -113,6 +121,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_hearing_24" />
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
android:id="@+id/headphones_icon"
|
android:id="@+id/headphones_icon"
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/twotone_headphones_24" />
|
android:src="@drawable/twotone_headphones_24" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -64,15 +64,15 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
tools:text="95%" />
|
tools:text="95% · 14h 30m" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/headphones_charging"
|
android:id="@+id/headphones_charging"
|
||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -80,8 +80,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
android:tintMode="multiply"
|
android:tint="@color/notification_icon_tint"
|
||||||
android:tint="?android:attr/colorAccent"
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_hearing_24" />
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
android:id="@+id/headphones_battery_icon"
|
android:id="@+id/headphones_battery_icon"
|
||||||
style="@style/PodInfoItemIcon.Notification"
|
style="@style/PodInfoItemIcon.Notification"
|
||||||
android:layout_marginStart="12dp"
|
android:layout_marginStart="12dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_battery_unknown_24" />
|
android:src="@drawable/ic_baseline_battery_unknown_24" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -46,6 +48,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_power_24" />
|
android:src="@drawable/ic_baseline_power_24" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -54,6 +58,8 @@
|
|||||||
android:layout_width="16dp"
|
android:layout_width="16dp"
|
||||||
android:layout_height="16dp"
|
android:layout_height="16dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="2dp"
|
||||||
|
android:tint="@color/notification_icon_tint"
|
||||||
|
android:tintMode="src_in"
|
||||||
android:src="@drawable/ic_baseline_hearing_24" />
|
android:src="@drawable/ic_baseline_hearing_24" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Algemene aanpassings wat die hele toepassing beïnvloed.</string>
|
<string name="settings_general_description">Algemene aanpassings wat die hele toepassing beïnvloed.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Versteek onooreenstemmende toestelle</string>
|
<string name="settings_overview_hide_unmatched_label">Versteek onooreenstemmende toestelle</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Moenie naby toestelle wys wat nie ooreenstem met enige van jou profiele in die oorsig nie, bv. ander mense se AirPods.</string>
|
<string name="settings_overview_hide_unmatched_description">Moenie naby toestelle wys wat nie ooreenstem met enige van jou profiele in die oorsig nie, bv. ander mense se AirPods.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Batteryskattings</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Leer hoe hierdie toestel laai en ontlaai om oorblywende tyd, laadtyd en batterygesondheid in te skat.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Geleerde gegevens herstel</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Begin opnuut van die gegradeerde batterylewensduur.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Geleerde gegevens herstel?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod sal hierdie toestel se gemete afvoer, laadsnelheid en batterygesondheid vergeet en opnuut met die gegradeerde batterylewensduur begin.</string>
|
||||||
<string name="settings_acknowledgements_label">Erkennings</string>
|
<string name="settings_acknowledgements_label">Erkennings</string>
|
||||||
<string name="settings_debug_autoreports_label">Outomatiese foutverslae</string>
|
<string name="settings_debug_autoreports_label">Outomatiese foutverslae</string>
|
||||||
<string name="settings_debug_autoreports_description">Rapporteer probleme outomaties, bv. besonderhede oor \'n toepassingstorting sodat ek kan uitvind hoe om dit reg te stel.</string>
|
<string name="settings_debug_autoreports_description">Rapporteer probleme outomaties, bv. besonderhede oor \'n toepassingstorting sodat ek kan uitvind hoe om dit reg te stel.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Agtergrond-deursigtigheid</string>
|
<string name="widget_config_transparency_label">Agtergrond-deursigtigheid</string>
|
||||||
<string name="widget_config_show_device_label">Wys toestelnaam</string>
|
<string name="widget_config_show_device_label">Wys toestelnaam</string>
|
||||||
<string name="widget_config_reset_label">Stel terug na standaarde</string>
|
<string name="widget_config_reset_label">Stel terug na standaarde</string>
|
||||||
<string name="widget_config_preview_resize_hint">U kan die widget vergroot of verklein nadat u dit op u tuisskerm geplaas het.</string>
|
<string name="widget_config_preview_resize_hint">Jy kan die widget se grootte aanpas nadat jy dit op jou tuisskerm geplaas het.</string>
|
||||||
<string name="widget_config_custom_label">Aangepas</string>
|
<string name="widget_config_custom_label">Aangepas</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Donker</string>
|
<string name="widget_config_preset_dark">Donker</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Laas gesien: %s</string>
|
<string name="last_seen_x">Laas gesien: %s</string>
|
||||||
<string name="first_seen_x">Eerste keer gesien: %s</string>
|
<string name="first_seen_x">Eerste keer gesien: %s</string>
|
||||||
<string name="battery_cached_label">Laas bekend · %s</string>
|
<string name="battery_cached_label">Laas bekend · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Wys kennisgewings</string>
|
<string name="permission_post_notifications_label">Wys kennisgewings</string>
|
||||||
<string name="permission_post_notifications_description">"Laat CAPod toe om kennisgewings oor jou AirPods te wys, bv. hul huidige status terwyl hulle gekoppel is."</string>
|
<string name="permission_post_notifications_description">"Laat CAPod toe om kennisgewings oor jou AirPods te wys, bv. hul huidige status terwyl hulle gekoppel is."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Regter Pod Serienommer</string>
|
<string name="device_settings_info_right_serial_label">Regter Pod Serienommer</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Links Gekoppel</string>
|
<string name="device_settings_info_left_bonded_label">Links Gekoppel</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Regs Gekoppel</string>
|
<string name="device_settings_info_right_bonded_label">Regs Gekoppel</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Batterygesondheid (beraamde)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Linker Batterygesondheid (beraamde)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Regter Batterygesondheid (beraamde)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Besig om te bepaal, kom later terug</string>
|
||||||
<string name="device_settings_info_details_label">Toestelbesonderhede</string>
|
<string name="device_settings_info_details_label">Toestelbesonderhede</string>
|
||||||
<string name="device_settings_info_details_action">Wys toestelbesonderhede</string>
|
<string name="device_settings_info_details_action">Wys toestelbesonderhede</string>
|
||||||
<string name="device_settings_info_status_label">Status</string>
|
<string name="device_settings_info_status_label">Status</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">ሙሉውን መተግበሪያ የሚነኩ አጠቃላይ ማስተካከያዎች።</string>
|
<string name="settings_general_description">ሙሉውን መተግበሪያ የሚነኩ አጠቃላይ ማስተካከያዎች።</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">ያልተዛመዱ መሳሪያዎችን ደብቅ</string>
|
<string name="settings_overview_hide_unmatched_label">ያልተዛመዱ መሳሪያዎችን ደብቅ</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">ከፕሮፋይሎቻቸው ጋር የማይዛመዱ አቅራቢያ ያሉ መሳሪያዎችን በዝርዝሩ ውስጥ አታሳይ፣ ለምሳሌ የሌሎች ሰዎች AirPods።</string>
|
<string name="settings_overview_hide_unmatched_description">ከፕሮፋይሎቻቸው ጋር የማይዛመዱ አቅራቢያ ያሉ መሳሪያዎችን በዝርዝሩ ውስጥ አታሳይ፣ ለምሳሌ የሌሎች ሰዎች AirPods።</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">ባትሪ ግምቶች</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">ይህ መሳሪያ እንዴት ሲሞላ እና እንደሚወድ በመማር ተቀሩትን ጊዜ፣ ሞላ ሲሳ የሚቆይ ጊዜ እና ባትሪ ጤናን ግምት ማድረግ ይችላሉ።</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">የተማረውን ዳታ ዳግም ያስጀምሩ</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">ተወሰነ ባትሪ ዕድሜ ሪጀምር።</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">የተማረውን ዳታ ዳግም ያስጀምራሉ?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod ይህ መሳሪያ የተለካው ወደዳ፣ ሙሎት ፍጥነት እና ባትሪ ጤናን ይረሳል እና ተወሰነ ባትሪ ዕድሜ ሪጀምራል።</string>
|
||||||
<string name="settings_acknowledgements_label">ምስጋናዎች</string>
|
<string name="settings_acknowledgements_label">ምስጋናዎች</string>
|
||||||
<string name="settings_debug_autoreports_label">ራስ-ሰር የሳንካ ሪፖርቶች</string>
|
<string name="settings_debug_autoreports_label">ራስ-ሰር የሳንካ ሪፖርቶች</string>
|
||||||
<string name="settings_debug_autoreports_description">ችግሮችን በራስ-ሰር ሪፖርት ያደርጋል፣ ለምሳሌ የመተግበሪያ ብልሽት ዝርዝሮችን እንዴት ማስተካከል እንዳለብኝ እንድገነዘብ።</string>
|
<string name="settings_debug_autoreports_description">ችግሮችን በራስ-ሰር ሪፖርት ያደርጋል፣ ለምሳሌ የመተግበሪያ ብልሽት ዝርዝሮችን እንዴት ማስተካከል እንዳለብኝ እንድገነዘብ።</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">የዳራ ግልጽነት</string>
|
<string name="widget_config_transparency_label">የዳራ ግልጽነት</string>
|
||||||
<string name="widget_config_show_device_label">የመሳሪያ ስም አሳይ</string>
|
<string name="widget_config_show_device_label">የመሳሪያ ስም አሳይ</string>
|
||||||
<string name="widget_config_reset_label">ወደ ነባሪ መመለስ</string>
|
<string name="widget_config_reset_label">ወደ ነባሪ መመለስ</string>
|
||||||
<string name="widget_config_preview_resize_hint">ዊጄቱን በዋናው ስክሪን ላይ ካስቀመጡት በኋላ መጠኑን መቀየር ይችላሉ።</string>
|
<string name="widget_config_preview_resize_hint">ዊጀትን በቤት ስክሪን ላይ ከአስቀመጡ በኋላ መጠንዋን መቀየር ይችላሉ።</string>
|
||||||
<string name="widget_config_custom_label">ብጁ</string>
|
<string name="widget_config_custom_label">ብጁ</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">ጨለማ</string>
|
<string name="widget_config_preset_dark">ጨለማ</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">ለመጨረሻ ጊዜ የታየው: %s</string>
|
<string name="last_seen_x">ለመጨረሻ ጊዜ የታየው: %s</string>
|
||||||
<string name="first_seen_x">ለመጀመሪያ ጊዜ የታየው: %s</string>
|
<string name="first_seen_x">ለመጀመሪያ ጊዜ የታየው: %s</string>
|
||||||
<string name="battery_cached_label">የመጨረሻ ታወቀ · %s</string>
|
<string name="battery_cached_label">የመጨረሻ ታወቀ · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dሰ %2$ደ</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dሰ</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dደ</string>
|
||||||
<string name="permission_post_notifications_label">ማሳወቂያዎችን አሳይ</string>
|
<string name="permission_post_notifications_label">ማሳወቂያዎችን አሳይ</string>
|
||||||
<string name="permission_post_notifications_description">"CAPod ስለ AirPodsዎ ማሳወቂያዎችን እንዲያሳይ ፍቀድ፣ ለምሳሌ ሲገናኙ የአሁኑ ሁኔታቸውን።"</string>
|
<string name="permission_post_notifications_description">"CAPod ስለ AirPodsዎ ማሳወቂያዎችን እንዲያሳይ ፍቀድ፣ ለምሳሌ ሲገናኙ የአሁኑ ሁኔታቸውን።"</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">የቀኝ ፖድ ተከታታይ ቅጥር</string>
|
<string name="device_settings_info_right_serial_label">የቀኝ ፖድ ተከታታይ ቅጥር</string>
|
||||||
<string name="device_settings_info_left_bonded_label">ግራ ተያይዟል</string>
|
<string name="device_settings_info_left_bonded_label">ግራ ተያይዟል</string>
|
||||||
<string name="device_settings_info_right_bonded_label">ቀኝ ተያይዟል</string>
|
<string name="device_settings_info_right_bonded_label">ቀኝ ተያይዟል</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">ባትሪ ጤና (ግምታዊ)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">ግራ ባትሪ ጤና (ግምታዊ)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">ቀኝ ባትሪ ጤና (ግምታዊ)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">ገና ይወሰናል፣ በኋላ ይመልከቱ</string>
|
||||||
<string name="device_settings_info_details_label">የመሳሪያ ዝርዝሮች</string>
|
<string name="device_settings_info_details_label">የመሳሪያ ዝርዝሮች</string>
|
||||||
<string name="device_settings_info_details_action">የመሳሪያ ዝርዝሮችን አሳይ</string>
|
<string name="device_settings_info_details_action">የመሳሪያ ዝርዝሮችን አሳይ</string>
|
||||||
<string name="device_settings_info_status_label">ሁኔታ</string>
|
<string name="device_settings_info_status_label">ሁኔታ</string>
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
<string name="general_copy_action">نسخ</string>
|
<string name="general_copy_action">نسخ</string>
|
||||||
<string name="general_thank_you_label">شكرًا لك</string>
|
<string name="general_thank_you_label">شكرًا لك</string>
|
||||||
<string name="general_upgrade_action">ترقية</string>
|
<string name="general_upgrade_action">ترقية</string>
|
||||||
<string name="common_feature_requires_pro_msg">رقّي للفتح.</string>
|
<string name="common_feature_requires_pro_msg">رقِّ للفتح.</string>
|
||||||
<string name="common_upgrade_required_label">يتطلب الترقية</string>
|
<string name="common_upgrade_required_label">يتطلب الترقية</string>
|
||||||
<string name="general_donate_action">تبرع</string>
|
<string name="general_donate_action">تبرُّع</string>
|
||||||
<string name="general_check_action">تحقق</string>
|
<string name="general_check_action">فحص</string>
|
||||||
<string name="general_close_action">إغلاق</string>
|
<string name="general_close_action">إغلاق</string>
|
||||||
<string name="general_edit_action">تعديل</string>
|
<string name="general_edit_action">تعديل</string>
|
||||||
<string name="general_save_action">حفظ</string>
|
<string name="general_save_action">حفظ</string>
|
||||||
<string name="general_guide_action">دليل</string>
|
<string name="general_guide_action">الدليل</string>
|
||||||
<string name="general_continue_action">متابعة</string>
|
<string name="general_continue_action">متابعة</string>
|
||||||
<string name="general_show_action">عرض</string>
|
<string name="general_show_action">عرض</string>
|
||||||
<string name="general_hide_action">إخفاء</string>
|
<string name="general_hide_action">إخفاء</string>
|
||||||
@@ -22,16 +22,16 @@
|
|||||||
<string name="upgrade_capod_description">احصل على ميزات إضافية وادعم المطور.</string>
|
<string name="upgrade_capod_description">احصل على ميزات إضافية وادعم المطور.</string>
|
||||||
<string name="upgrade_preamble">تم تطوير تطبيق CAPod بواسطة شخص واحد. يتيح التحديث ميزات إضافية ويساعد في استمرار عمل التطبيق.</string>
|
<string name="upgrade_preamble">تم تطوير تطبيق CAPod بواسطة شخص واحد. يتيح التحديث ميزات إضافية ويساعد في استمرار عمل التطبيق.</string>
|
||||||
<string name="upgrade_benefit_themes">تخصيص السمة</string>
|
<string name="upgrade_benefit_themes">تخصيص السمة</string>
|
||||||
<string name="upgrade_benefit_autoplay">التشغيل التلقائي & الإيقاف التلقائي</string>
|
<string name="upgrade_benefit_autoplay">التشغيل التلقائي والإيقاف التلقائي</string>
|
||||||
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح العلبة & الاتصال</string>
|
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح العلبة والاتصال</string>
|
||||||
<string name="upgrade_benefit_widgets">عناصر الشاشة الرئيسة</string>
|
<string name="upgrade_benefit_widgets">عناصر الشاشة الرئيسة</string>
|
||||||
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدّمة</string>
|
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدمة</string>
|
||||||
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز — الضوضاء، & السيقان، & المزيد</string>
|
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز — الضوضاء والسيقان والمزيد</string>
|
||||||
<string name="upgrade_benefit_support">دعم المطوّر</string>
|
<string name="upgrade_benefit_support">ادعم المطور</string>
|
||||||
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سمّاعات الرأس والجهاز المستخدَم.</string>
|
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز المُستخدَم.</string>
|
||||||
<string name="upgrade_screen_options_description">نفس الميزات، أسعار مختلفة. يشمل الاشتراك فترة تجريبية مجانية ويمكن إلغاؤه في أي وقت.</string>
|
<string name="upgrade_screen_options_description">نفس الميزات، بتسعير مختلف. يشمل الاشتراك تجربة مجانية ويمكن إلغاؤه في أي وقت.</string>
|
||||||
<string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string>
|
<string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string>
|
||||||
<string name="upgrade_screen_subscription_action">الاشتراك سنويًا</string>
|
<string name="upgrade_screen_subscription_action">الاشتراك سنويًّا</string>
|
||||||
<string name="upgrade_screen_subscription_action_hint">%s/سنة</string>
|
<string name="upgrade_screen_subscription_action_hint">%s/سنة</string>
|
||||||
<string name="upgrade_screen_iap_action">الشراء مرة واحدة</string>
|
<string name="upgrade_screen_iap_action">الشراء مرة واحدة</string>
|
||||||
<string name="upgrade_screen_iap_action_hint">الشراء لمرة واحدة: %s</string>
|
<string name="upgrade_screen_iap_action_hint">الشراء لمرة واحدة: %s</string>
|
||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">تعديلات عامة تؤثر على التطبيق بأكمله.</string>
|
<string name="settings_general_description">تعديلات عامة تؤثر على التطبيق بأكمله.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">إخفاء الأجهزة غير المتطابقة</string>
|
<string name="settings_overview_hide_unmatched_label">إخفاء الأجهزة غير المتطابقة</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">لا تعرض الأجهزة القريبة التي لا تتطابق مع أي من ملفات تعريفك في النظرة العامة، على سبيل المثال سمّاعات AirPods الخاصة بالآخرين.</string>
|
<string name="settings_overview_hide_unmatched_description">لا تعرض الأجهزة القريبة التي لا تتطابق مع أي من ملفات تعريفك في النظرة العامة، على سبيل المثال سمّاعات AirPods الخاصة بالآخرين.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">تقديرات البطارية</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">تعرّف على كيفية شحن هذا الجهاز وتفريغه لتقدير الوقت المتبقي، والوقت اللازم للشحن، وحالة البطارية.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">إعادة ضبط البيانات المُكتسَبة</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">البدء من جديد بناءً على عمر البطارية المُقدَّر.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">هل ترغب في إعادة ضبط البيانات المُكتسَبة؟</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">سيتجاهل تطبيق CAPod معدل استهلاك البطارية المقاس لهذا الجهاز، وسرعة الشحن، وحالة البطارية، وسيبدأ من جديد بناءً على عمر البطارية المُقدَّر.</string>
|
||||||
<string name="settings_acknowledgements_label">شكر وتقدير</string>
|
<string name="settings_acknowledgements_label">شكر وتقدير</string>
|
||||||
<string name="settings_debug_autoreports_label">تقارير الأخطاء التلقائية</string>
|
<string name="settings_debug_autoreports_label">تقارير الأخطاء التلقائية</string>
|
||||||
<string name="settings_debug_autoreports_description">الإبلاغ تلقائيًا عن المشكلات، على سبيل المثال تفاصيل حول تعطل التطبيق حتى أتمكن من معرفة كيفية إصلاحه.</string>
|
<string name="settings_debug_autoreports_description">الإبلاغ تلقائيًا عن المشكلات، على سبيل المثال تفاصيل حول تعطل التطبيق حتى أتمكن من معرفة كيفية إصلاحه.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">شفافية الخلفية</string>
|
<string name="widget_config_transparency_label">شفافية الخلفية</string>
|
||||||
<string name="widget_config_show_device_label">إظهار اسم الجهاز</string>
|
<string name="widget_config_show_device_label">إظهار اسم الجهاز</string>
|
||||||
<string name="widget_config_reset_label">إعادة تعيين إلى الإعدادات الافتراضية</string>
|
<string name="widget_config_reset_label">إعادة تعيين إلى الإعدادات الافتراضية</string>
|
||||||
<string name="widget_config_preview_resize_hint">يمكنك تغيير حجم الأداة بعد وضعها على شاشتك الرئيسية.</string>
|
<string name="widget_config_preview_resize_hint">يمكنك تغيير حجم الأداة بعد وضعها على شاشتك الرئيسة.</string>
|
||||||
<string name="widget_config_custom_label">مخصص</string>
|
<string name="widget_config_custom_label">مخصص</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">مظلم</string>
|
<string name="widget_config_preset_dark">مظلم</string>
|
||||||
@@ -286,6 +292,9 @@
|
|||||||
<string name="last_seen_x">آخر ظهور: %s</string>
|
<string name="last_seen_x">آخر ظهور: %s</string>
|
||||||
<string name="first_seen_x">أول ظهور: %s</string>
|
<string name="first_seen_x">أول ظهور: %s</string>
|
||||||
<string name="battery_cached_label">آخر معروف · %s</string>
|
<string name="battery_cached_label">آخر معروف · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dس و%2$dد</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dس</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dد</string>
|
||||||
<string name="permission_post_notifications_label">إظهار الإشعارات</string>
|
<string name="permission_post_notifications_label">إظهار الإشعارات</string>
|
||||||
<string name="permission_post_notifications_description">"السماح لـ CAPod بعرض إشعارات حول AirPods، مثل حالتها الحالية أثناء الاتصال."</string>
|
<string name="permission_post_notifications_description">"السماح لـ CAPod بعرض إشعارات حول AirPods، مثل حالتها الحالية أثناء الاتصال."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -443,6 +452,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">الرقم التسلسلي للسماعة اليمنى</string>
|
<string name="device_settings_info_right_serial_label">الرقم التسلسلي للسماعة اليمنى</string>
|
||||||
<string name="device_settings_info_left_bonded_label">اليسار مقترن</string>
|
<string name="device_settings_info_left_bonded_label">اليسار مقترن</string>
|
||||||
<string name="device_settings_info_right_bonded_label">اليمين مقترن</string>
|
<string name="device_settings_info_right_bonded_label">اليمين مقترن</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">صحة البطارية (مُقدَّرة)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">صحة البطارية اليسرى (مُقدَّرة)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">صحة البطارية اليمنى (مُقدَّرة)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">لا يزال الأمر قيد التحديد، راجع لاحقًا</string>
|
||||||
<string name="device_settings_info_details_label">تفاصيل الجهاز</string>
|
<string name="device_settings_info_details_label">تفاصيل الجهاز</string>
|
||||||
<string name="device_settings_info_details_action">عرض تفاصيل الجهاز</string>
|
<string name="device_settings_info_details_action">عرض تفاصيل الجهاز</string>
|
||||||
<string name="device_settings_info_status_label">الحالة</string>
|
<string name="device_settings_info_status_label">الحالة</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Bütün tətbiqə təsir edən ümumi düzəlişlər.</string>
|
<string name="settings_general_description">Bütün tətbiqə təsir edən ümumi düzəlişlər.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Uyğun olmayan cihazları gizlət</string>
|
<string name="settings_overview_hide_unmatched_label">Uyğun olmayan cihazları gizlət</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">İcmalda profillərinizə uyğun gəlməyən yaxın cihazları göstərməyin, məs. başqalarının AirPods-ları.</string>
|
<string name="settings_overview_hide_unmatched_description">İcmalda profillərinizə uyğun gəlməyən yaxın cihazları göstərməyin, məs. başqalarının AirPods-ları.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Batareya qiymətləndirmələri</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Bu cihazın necə doldurulduğunu və enerji istifadə etdiyini öyrənərək, qalan vaxtı, dolğunlaşma vaxtını və batareya sağlamlığını qiymətləndirin.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Öyrənilmiş məlumatları sıfırla</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Qiymətləndirilmiş batareya ömrünə qayıdıb yenidən başlayın.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Öyrənilmiş məlumatları sıfırla?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod bu cihazın ölçülmüş enerji istifadəsini, dolğunlaşma sürətini və batareya sağlamlığını unudacaq, qiymətləndirilmiş batareya ömrünə qayıdıb yenidən başlayacaq.</string>
|
||||||
<string name="settings_acknowledgements_label">Təşəkkürlər</string>
|
<string name="settings_acknowledgements_label">Təşəkkürlər</string>
|
||||||
<string name="settings_debug_autoreports_label">Avtomatik səhv hesabatları</string>
|
<string name="settings_debug_autoreports_label">Avtomatik səhv hesabatları</string>
|
||||||
<string name="settings_debug_autoreports_description">Problemləri avtomatik olaraq bildirir, məsələn, tətbiqin çökməsi ilə bağlı təfərrüatları, beləliklə onu necə düzəldəcəyimi anlaya bilim.</string>
|
<string name="settings_debug_autoreports_description">Problemləri avtomatik olaraq bildirir, məsələn, tətbiqin çökməsi ilə bağlı təfərrüatları, beləliklə onu necə düzəldəcəyimi anlaya bilim.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Fon şəffaflığı</string>
|
<string name="widget_config_transparency_label">Fon şəffaflığı</string>
|
||||||
<string name="widget_config_show_device_label">Cihaz adını göstər</string>
|
<string name="widget_config_show_device_label">Cihaz adını göstər</string>
|
||||||
<string name="widget_config_reset_label">Varsayılan ayarlara sıfırla</string>
|
<string name="widget_config_reset_label">Varsayılan ayarlara sıfırla</string>
|
||||||
<string name="widget_config_preview_resize_hint">Vidceti ana ekranınıza yerləşdirdikdən sonra ölçüsünü dəyişə bilərsiniz.</string>
|
<string name="widget_config_preview_resize_hint">Vidcetin ana ekrana qoyduqdan sonra ölçüsünü dəyişə bilərsiniz.</string>
|
||||||
<string name="widget_config_custom_label">Fərdi</string>
|
<string name="widget_config_custom_label">Fərdi</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Tünd</string>
|
<string name="widget_config_preset_dark">Tünd</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Son görülmə: %s</string>
|
<string name="last_seen_x">Son görülmə: %s</string>
|
||||||
<string name="first_seen_x">İlk görülmə: %s</string>
|
<string name="first_seen_x">İlk görülmə: %s</string>
|
||||||
<string name="battery_cached_label">Son məlum · %s</string>
|
<string name="battery_cached_label">Son məlum · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dsa %2$ddəq</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dsa</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$ddəq</string>
|
||||||
<string name="permission_post_notifications_label">Bildirişlər göstər</string>
|
<string name="permission_post_notifications_label">Bildirişlər göstər</string>
|
||||||
<string name="permission_post_notifications_description">"CAPod-a AirPods haqqında bildirişlər göstərməyə icazə verin, məsələn, qoşulduqda onların cari statusu."</string>
|
<string name="permission_post_notifications_description">"CAPod-a AirPods haqqında bildirişlər göstərməyə icazə verin, məsələn, qoşulduqda onların cari statusu."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Sağ Qulaqcıq Seriya Nömrəsi</string>
|
<string name="device_settings_info_right_serial_label">Sağ Qulaqcıq Seriya Nömrəsi</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Sol cütləşdirilmiş</string>
|
<string name="device_settings_info_left_bonded_label">Sol cütləşdirilmiş</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Sağ cütləşdirilmiş</string>
|
<string name="device_settings_info_right_bonded_label">Sağ cütləşdirilmiş</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Batareya Sağlığı (təxmini)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Sol Batareya Sağlığı (təx.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Sağ Batareya Sağlığı (təx.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Hələ müəyyən edilir, sonra yoxlayın</string>
|
||||||
<string name="device_settings_info_details_label">Cihaz Ǝtraflı Məlumatları</string>
|
<string name="device_settings_info_details_label">Cihaz Ǝtraflı Məlumatları</string>
|
||||||
<string name="device_settings_info_details_action">Cihaz təfsilatlarını göstər</string>
|
<string name="device_settings_info_details_action">Cihaz təfsilatlarını göstər</string>
|
||||||
<string name="device_settings_info_status_label">Vəziyyət</string>
|
<string name="device_settings_info_status_label">Vəziyyət</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Агульныя налады, якія ўплываюць на ўсю праграму.</string>
|
<string name="settings_general_description">Агульныя налады, якія ўплываюць на ўсю праграму.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Хаваць непрызнаныя прылады</string>
|
<string name="settings_overview_hide_unmatched_label">Хаваць непрызнаныя прылады</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Не паказваць у агляднічку блізкія прылады, якія не адпавядаюць ніводнаму з вашых профіляў, напр. AirPods іншых людзей.</string>
|
<string name="settings_overview_hide_unmatched_description">Не паказваць у агляднічку блізкія прылады, якія не адпавядаюць ніводнаму з вашых профіляў, напр. AirPods іншых людзей.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Ацэнкі стану батарэі</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Даведайцеся, як гэтая прылада зараджаецца і разраджаецца, каб ацаніць час, які застаўся, час да поўнай зарадкі і здароўе батарэі.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Скінуць вывучаныя даныя</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Пачаць спачатку з намінальнага рэсурсу батарэі.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Скінуць вывучаныя даныя?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod забудзе вымераныя для гэтай прылады хуткасць разраджэння, хуткасць зарадкі і здароўе батарэі, і пачне нанова з намінальнага рэсурсу батарэі.</string>
|
||||||
<string name="settings_acknowledgements_label">Падзякі</string>
|
<string name="settings_acknowledgements_label">Падзякі</string>
|
||||||
<string name="settings_debug_autoreports_label">Аўтаматычныя справаздачы пра памылкі</string>
|
<string name="settings_debug_autoreports_label">Аўтаматычныя справаздачы пра памылкі</string>
|
||||||
<string name="settings_debug_autoreports_description">Аўтаматычна паведамляе пра праблемы, напрыклад, звесткі пра збой праграмы, каб я мог зразумець, як гэта выправіць.</string>
|
<string name="settings_debug_autoreports_description">Аўтаматычна паведамляе пра праблемы, напрыклад, звесткі пра збой праграмы, каб я мог зразумець, як гэта выправіць.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Прозрачнасць фона</string>
|
<string name="widget_config_transparency_label">Прозрачнасць фона</string>
|
||||||
<string name="widget_config_show_device_label">Паказаць назву прылады</string>
|
<string name="widget_config_show_device_label">Паказаць назву прылады</string>
|
||||||
<string name="widget_config_reset_label">Скінуць да змаўчання</string>
|
<string name="widget_config_reset_label">Скінуць да змаўчання</string>
|
||||||
<string name="widget_config_preview_resize_hint">Вы можаце змяніць памер віджэта пасля размяшчэння яго на галоўным экране.</string>
|
<string name="widget_config_preview_resize_hint">Пасля размяшчэння віджэта на галоўным экране вы можаце змяніць яго памер.</string>
|
||||||
<string name="widget_config_custom_label">Адвольны</string>
|
<string name="widget_config_custom_label">Адвольны</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Цёмны</string>
|
<string name="widget_config_preset_dark">Цёмны</string>
|
||||||
@@ -282,6 +288,9 @@
|
|||||||
<string name="last_seen_x">Апошні раз бачана: %s</string>
|
<string name="last_seen_x">Апошні раз бачана: %s</string>
|
||||||
<string name="first_seen_x">Упершыню бачана: %s</string>
|
<string name="first_seen_x">Упершыню бачана: %s</string>
|
||||||
<string name="battery_cached_label">Апошняе вядомае · %s</string>
|
<string name="battery_cached_label">Апошняе вядомае · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dг %2$dхв</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dг</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dхв</string>
|
||||||
<string name="permission_post_notifications_label">Паказваць апавяшчэнні</string>
|
<string name="permission_post_notifications_label">Паказваць апавяшчэнні</string>
|
||||||
<string name="permission_post_notifications_description">"Дазвольце CAPod паказваць апавяшчэнні пра вашы AirPods, напрыклад іх бягучы стан пры падключэнні."</string>
|
<string name="permission_post_notifications_description">"Дазвольце CAPod паказваць апавяшчэнні пра вашы AirPods, напрыклад іх бягучы стан пры падключэнні."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -433,6 +442,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Сэрыйны нумар правага навушніка</string>
|
<string name="device_settings_info_right_serial_label">Сэрыйны нумар правага навушніка</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Левы спалучаны</string>
|
<string name="device_settings_info_left_bonded_label">Левы спалучаны</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Правы спалучаны</string>
|
<string name="device_settings_info_right_bonded_label">Правы спалучаны</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Здароўе батарэі (ацэнка)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Здароўе левай батарэі (ацэнка)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Здароўе правай батарэі (ацэнка)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Яшчэ вызначаецца, зайдзіце пазней</string>
|
||||||
<string name="device_settings_info_details_label">Дэталі прылады</string>
|
<string name="device_settings_info_details_label">Дэталі прылады</string>
|
||||||
<string name="device_settings_info_details_action">Паказаць дэталі прылады</string>
|
<string name="device_settings_info_details_action">Паказаць дэталі прылады</string>
|
||||||
<string name="device_settings_info_status_label">Стан</string>
|
<string name="device_settings_info_status_label">Стан</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Общи настройки, които засягат цялото приложение.</string>
|
<string name="settings_general_description">Общи настройки, които засягат цялото приложение.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Скриване на несъответстващи устройства</string>
|
<string name="settings_overview_hide_unmatched_label">Скриване на несъответстващи устройства</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Не показвай намиращи се наблизо устройства, които не съответстват на нито един от профилите ти в прегледа, напр. AirPods на други хора.</string>
|
<string name="settings_overview_hide_unmatched_description">Не показвай намиращи се наблизо устройства, които не съответстват на нито един от профилите ти в прегледа, напр. AirPods на други хора.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Оценки на батерията</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Научете как това устройство се зарежда и разходва, за да оцените оставащото време, времето до зареждане и състоянието на батерията.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Нулирай научените данни</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Начало отново от номиналния живот на батерията.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Нулирай научените данни?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod ще забрави измереното потребление, скоростта на зареждане и състоянието на батерията на това устройство и ще начне отново от номиналния живот на батерията.</string>
|
||||||
<string name="settings_acknowledgements_label">Благодарности</string>
|
<string name="settings_acknowledgements_label">Благодарности</string>
|
||||||
<string name="settings_debug_autoreports_label">Автоматични доклади за грешки</string>
|
<string name="settings_debug_autoreports_label">Автоматични доклади за грешки</string>
|
||||||
<string name="settings_debug_autoreports_description">Автоматично докладва проблеми, напр. подробности за срив на приложението, за да мога да разбера как да го поправя.</string>
|
<string name="settings_debug_autoreports_description">Автоматично докладва проблеми, напр. подробности за срив на приложението, за да мога да разбера как да го поправя.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Прозрачност на фона</string>
|
<string name="widget_config_transparency_label">Прозрачност на фона</string>
|
||||||
<string name="widget_config_show_device_label">Показване на името на устройството</string>
|
<string name="widget_config_show_device_label">Показване на името на устройството</string>
|
||||||
<string name="widget_config_reset_label">Възстановяване на стандартните настройки</string>
|
<string name="widget_config_reset_label">Възстановяване на стандартните настройки</string>
|
||||||
<string name="widget_config_preview_resize_hint">Можете да преоразмерите джаджата, след като я поставите на началния си екран.</string>
|
<string name="widget_config_preview_resize_hint">Можете да преоразмеряте виджета след поставянето му на начален екран.</string>
|
||||||
<string name="widget_config_custom_label">Персонализирано</string>
|
<string name="widget_config_custom_label">Персонализирано</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Тъмно</string>
|
<string name="widget_config_preset_dark">Тъмно</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Последно видяно: %s</string>
|
<string name="last_seen_x">Последно видяно: %s</string>
|
||||||
<string name="first_seen_x">Първо видяно: %s</string>
|
<string name="first_seen_x">Първо видяно: %s</string>
|
||||||
<string name="battery_cached_label">Последно известно · %s</string>
|
<string name="battery_cached_label">Последно известно · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dч %2$dм</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dч</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dм</string>
|
||||||
<string name="permission_post_notifications_label">Показване на известия</string>
|
<string name="permission_post_notifications_label">Показване на известия</string>
|
||||||
<string name="permission_post_notifications_description">"Позволете на CAPod да показва известия за вашите AirPods, напр. текущото им състояние, докато са свързани."</string>
|
<string name="permission_post_notifications_description">"Позволете на CAPod да показва известия за вашите AirPods, напр. текущото им състояние, докато са свързани."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Сериен номер на десния под</string>
|
<string name="device_settings_info_right_serial_label">Сериен номер на десния под</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Ляво сдвоено</string>
|
<string name="device_settings_info_left_bonded_label">Ляво сдвоено</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Дясно сдвоено</string>
|
<string name="device_settings_info_right_bonded_label">Дясно сдвоено</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Здравословно състояние на батерията (прогноза)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Здравословно състояние на лявата батерия (прогноза)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Здравословно състояние на дясната батерия (прогноза)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Все още се определя, проверете отново по-късно</string>
|
||||||
<string name="device_settings_info_details_label">Подробности за устройството</string>
|
<string name="device_settings_info_details_label">Подробности за устройството</string>
|
||||||
<string name="device_settings_info_details_action">Покажи подробности за устройството</string>
|
<string name="device_settings_info_details_action">Покажи подробности за устройството</string>
|
||||||
<string name="device_settings_info_status_label">Статус</string>
|
<string name="device_settings_info_status_label">Статус</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">পুরো অ্যাপকে প্রভাবিত করে এমন সাধারণ পরিবর্তন।</string>
|
<string name="settings_general_description">পুরো অ্যাপকে প্রভাবিত করে এমন সাধারণ পরিবর্তন।</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">অমিল ডিভাইস লুকান</string>
|
<string name="settings_overview_hide_unmatched_label">অমিল ডিভাইস লুকান</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">ওভারভিউতে কাছের এমন ডিভাইস দেখাবে না যেগুলো আপনার কোনো প্রোফাইলের সাথে মেলে না, যেমন অন্যদের AirPods।</string>
|
<string name="settings_overview_hide_unmatched_description">ওভারভিউতে কাছের এমন ডিভাইস দেখাবে না যেগুলো আপনার কোনো প্রোফাইলের সাথে মেলে না, যেমন অন্যদের AirPods।</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">ব্যাটারি অনুমান</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">এই ডিভাইসটি কীভাবে চার্জ হয় এবং কীভাবে খরচ হয় তা শিখুন যাতে অবশিষ্ট সময়, চার্জ হওয়ার সময় এবং ব্যাটারি স্বাস্থ্য অনুমান করতে পারেন।</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">শেখা ডেটা রিসেট করুন</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">রেটেড ব্যাটারি আয়ু থেকে শুরু করুন।</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">শেখা ডেটা রিসেট করুন?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod এই ডিভাইসের পরিমাপ করা ড্রেইন, চার্জিং গতি এবং ব্যাটারি স্বাস্থ্য ভুলে যাবে এবং রেটেড ব্যাটারি আয়ু থেকে শুরু করবে।</string>
|
||||||
<string name="settings_acknowledgements_label">স্বীকৃতি</string>
|
<string name="settings_acknowledgements_label">স্বীকৃতি</string>
|
||||||
<string name="settings_debug_autoreports_label">স্বয়ংক্রিয় বাগ রিপোর্ট</string>
|
<string name="settings_debug_autoreports_label">স্বয়ংক্রিয় বাগ রিপোর্ট</string>
|
||||||
<string name="settings_debug_autoreports_description">স্বয়ংক্রিয়ভাবে সমস্যাগুলি রিপোর্ট করে, যেমন অ্যাপ ক্র্যাশের বিবরণ যাতে আমি এটি কীভাবে ঠিক করতে পারি তা বের করতে পারি।</string>
|
<string name="settings_debug_autoreports_description">স্বয়ংক্রিয়ভাবে সমস্যাগুলি রিপোর্ট করে, যেমন অ্যাপ ক্র্যাশের বিবরণ যাতে আমি এটি কীভাবে ঠিক করতে পারি তা বের করতে পারি।</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">পটভূমি স্বচ্ছতা</string>
|
<string name="widget_config_transparency_label">পটভূমি স্বচ্ছতা</string>
|
||||||
<string name="widget_config_show_device_label">ডিভাইসের নাম দেখান</string>
|
<string name="widget_config_show_device_label">ডিভাইসের নাম দেখান</string>
|
||||||
<string name="widget_config_reset_label">ডিফল্টে পুনরায় সেট করুন</string>
|
<string name="widget_config_reset_label">ডিফল্টে পুনরায় সেট করুন</string>
|
||||||
<string name="widget_config_preview_resize_hint">আপনি হোম স্ক্রিনে রাখার পরে উইজেটটি পুনরায় আকার দিতে পারবেন।</string>
|
<string name="widget_config_preview_resize_hint">আপনার হোম স্ক্রিনে উইজেটটি রাখার পরে আপনি এটির আকার পরিবর্তন করতে পারেন।</string>
|
||||||
<string name="widget_config_custom_label">কাস্টম</string>
|
<string name="widget_config_custom_label">কাস্টম</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">গাঢ়</string>
|
<string name="widget_config_preset_dark">গাঢ়</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">সর্বশেষ দেখা: %s</string>
|
<string name="last_seen_x">সর্বশেষ দেখা: %s</string>
|
||||||
<string name="first_seen_x">প্রথম দেখা: %s</string>
|
<string name="first_seen_x">প্রথম দেখা: %s</string>
|
||||||
<string name="battery_cached_label">শেষ জানা · %s</string>
|
<string name="battery_cached_label">শেষ জানা · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dঘ %2$dমি</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dঘ</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dমি</string>
|
||||||
<string name="permission_post_notifications_label">বিজ্ঞপ্তি দেখান</string>
|
<string name="permission_post_notifications_label">বিজ্ঞপ্তি দেখান</string>
|
||||||
<string name="permission_post_notifications_description">"CAPod-কে আপনার AirPods সম্পর্কে বিজ্ঞপ্তি দেখানোর অনুমতি দিন, যেমন সংযুক্ত থাকাকালীন তাদের বর্তমান অবস্থা।"</string>
|
<string name="permission_post_notifications_description">"CAPod-কে আপনার AirPods সম্পর্কে বিজ্ঞপ্তি দেখানোর অনুমতি দিন, যেমন সংযুক্ত থাকাকালীন তাদের বর্তমান অবস্থা।"</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">ডান পড সিরিয়াল</string>
|
<string name="device_settings_info_right_serial_label">ডান পড সিরিয়াল</string>
|
||||||
<string name="device_settings_info_left_bonded_label">বাম পেয়ার করা</string>
|
<string name="device_settings_info_left_bonded_label">বাম পেয়ার করা</string>
|
||||||
<string name="device_settings_info_right_bonded_label">ডান পেয়ার করা</string>
|
<string name="device_settings_info_right_bonded_label">ডান পেয়ার করা</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">ব্যাটারি স্বাস্থ্য (অনুমানিত)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">বাম ব্যাটারি স্বাস্থ্য (অনু.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">ডান ব্যাটারি স্বাস্থ্য (অনু.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">এখনও নির্ধারণ করছি, পরে আবার চেক করুন</string>
|
||||||
<string name="device_settings_info_details_label">ডিভাইস বিস্তারিত</string>
|
<string name="device_settings_info_details_label">ডিভাইস বিস্তারিত</string>
|
||||||
<string name="device_settings_info_details_action">ডিভাইস বিস্তারিত দেখুন</string>
|
<string name="device_settings_info_details_action">ডিভাইস বিস্তারিত দেখুন</string>
|
||||||
<string name="device_settings_info_status_label">স্ট্যাটাস</string>
|
<string name="device_settings_info_status_label">স্ট্যাটাস</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Configuracions generals que afecten a tota l\'aplicació.</string>
|
<string name="settings_general_description">Configuracions generals que afecten a tota l\'aplicació.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Amaga els dispositius no coincidents</string>
|
<string name="settings_overview_hide_unmatched_label">Amaga els dispositius no coincidents</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">No mostris els dispositius propers que no coincideixen amb cap dels perfils a la vista general. P. ex. els AirPods d’altres persones.</string>
|
<string name="settings_overview_hide_unmatched_description">No mostris els dispositius propers que no coincideixen amb cap dels perfils a la vista general. P. ex. els AirPods d’altres persones.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Estimació de la bateria</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Obteniu informació sobre com es carrega i s\'esgota aquest dispositiu per estimar el temps restant, el temps fins que es carrega completament i l\'estat de la bateria.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Restableix les dades apreses</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Comença de nou des de la durada nominal de la bateria.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Voleu restablir les dades apreses?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">El CAPod oblidarà el consum mesurat d\'aquest dispositiu, la velocitat de càrrega i la salut de la bateria, i tornarà a començar des de la durada nominal de la bateria.</string>
|
||||||
<string name="settings_acknowledgements_label">Agraïments</string>
|
<string name="settings_acknowledgements_label">Agraïments</string>
|
||||||
<string name="settings_debug_autoreports_label">Informes automàtics d\'errors</string>
|
<string name="settings_debug_autoreports_label">Informes automàtics d\'errors</string>
|
||||||
<string name="settings_debug_autoreports_description">Informa automàticament de problemes. Per exemple: detalls sobre un bloqueig de l\'aplicació perquè pugui esbrinar com solucionar-ho.</string>
|
<string name="settings_debug_autoreports_description">Informa automàticament de problemes. Per exemple: detalls sobre un bloqueig de l\'aplicació perquè pugui esbrinar com solucionar-ho.</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Últim cop vist: %s</string>
|
<string name="last_seen_x">Últim cop vist: %s</string>
|
||||||
<string name="first_seen_x">Primera vegada vist: %s</string>
|
<string name="first_seen_x">Primera vegada vist: %s</string>
|
||||||
<string name="battery_cached_label">Última vegada vist · %s</string>
|
<string name="battery_cached_label">Última vegada vist · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Mostra notificacions</string>
|
<string name="permission_post_notifications_label">Mostra notificacions</string>
|
||||||
<string name="permission_post_notifications_description">"Permet que el CAPod mostri notificacions sobre els vostres AirPods, p. ex. el seu estat actual mentre estan connectats."</string>
|
<string name="permission_post_notifications_description">"Permet que el CAPod mostri notificacions sobre els vostres AirPods, p. ex. el seu estat actual mentre estan connectats."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Número de sèrie de l\'auricular dret</string>
|
<string name="device_settings_info_right_serial_label">Número de sèrie de l\'auricular dret</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Esquerre vinculat</string>
|
<string name="device_settings_info_left_bonded_label">Esquerre vinculat</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Dret vinculat</string>
|
<string name="device_settings_info_right_bonded_label">Dret vinculat</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Salut de la bateria (estimat)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Salut de la bateria esquerra (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Salut de la bateria dreta (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Encara s\'està determinant, torneu més tard</string>
|
||||||
<string name="device_settings_info_details_label">Detalls del dispositiu</string>
|
<string name="device_settings_info_details_label">Detalls del dispositiu</string>
|
||||||
<string name="device_settings_info_details_action">Mostra els detalls del dispositiu</string>
|
<string name="device_settings_info_details_action">Mostra els detalls del dispositiu</string>
|
||||||
<string name="device_settings_info_status_label">Estat</string>
|
<string name="device_settings_info_status_label">Estat</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Obecná vylepšení, která ovlivňují celou aplikaci.</string>
|
<string name="settings_general_description">Obecná vylepšení, která ovlivňují celou aplikaci.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Skrýt nepřiřazená zařízení</string>
|
<string name="settings_overview_hide_unmatched_label">Skrýt nepřiřazená zařízení</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">V přehledu nezobrazovat blízká zařízení, která neodpovídají žádnému z vašich profilů, např. AirPods jiných lidí.</string>
|
<string name="settings_overview_hide_unmatched_description">V přehledu nezobrazovat blízká zařízení, která neodpovídají žádnému z vašich profilů, např. AirPods jiných lidí.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Odhady baterie</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Zjistěte, jak se toto zařízení nabíjí a vybíjí, abyste mohli odhadnout zbývající čas, čas do nabití a stav baterie.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Obnovit naučená data</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Začněte znovu od deklarované výdrže baterie.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Obnovit naučená data?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod zapomene naměřený úbytek, rychlost nabíjení a stav baterie tohoto zařízení a začne znovu od deklarované výdrže baterie.</string>
|
||||||
<string name="settings_acknowledgements_label">Poděkování</string>
|
<string name="settings_acknowledgements_label">Poděkování</string>
|
||||||
<string name="settings_debug_autoreports_label">Automatické hlášení chyb</string>
|
<string name="settings_debug_autoreports_label">Automatické hlášení chyb</string>
|
||||||
<string name="settings_debug_autoreports_description">Automatické hlášení chyb např. podrobnosti o pádech aplikace.</string>
|
<string name="settings_debug_autoreports_description">Automatické hlášení chyb např. podrobnosti o pádech aplikace.</string>
|
||||||
@@ -282,6 +288,9 @@
|
|||||||
<string name="last_seen_x">Naposledy spatřeno: %s</string>
|
<string name="last_seen_x">Naposledy spatřeno: %s</string>
|
||||||
<string name="first_seen_x">Poprvé spatřeno: %s</string>
|
<string name="first_seen_x">Poprvé spatřeno: %s</string>
|
||||||
<string name="battery_cached_label">Poslední známé \u00B7 %s</string>
|
<string name="battery_cached_label">Poslední známé \u00B7 %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Zobrazit oznámení</string>
|
<string name="permission_post_notifications_label">Zobrazit oznámení</string>
|
||||||
<string name="permission_post_notifications_description">"Povolte aplikaci CAPod zobrazovat oznámení o sluchátkách AirPods, např. o jejich aktuálním stavu během připojení."</string>
|
<string name="permission_post_notifications_description">"Povolte aplikaci CAPod zobrazovat oznámení o sluchátkách AirPods, např. o jejich aktuálním stavu během připojení."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -433,6 +442,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Sériové číslo pravého sluchátka</string>
|
<string name="device_settings_info_right_serial_label">Sériové číslo pravého sluchátka</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Levé spárováno</string>
|
<string name="device_settings_info_left_bonded_label">Levé spárováno</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Pravé spárováno</string>
|
<string name="device_settings_info_right_bonded_label">Pravé spárováno</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Stav baterie (odhadovaný)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Stav levé baterie (odh.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Stav pravé baterie (odh.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Stále se určuje, zkontrolujte později</string>
|
||||||
<string name="device_settings_info_details_label">Detaily zařízení</string>
|
<string name="device_settings_info_details_label">Detaily zařízení</string>
|
||||||
<string name="device_settings_info_details_action">Zobrazit detaily zařízení</string>
|
<string name="device_settings_info_details_action">Zobrazit detaily zařízení</string>
|
||||||
<string name="device_settings_info_status_label">Stav</string>
|
<string name="device_settings_info_status_label">Stav</string>
|
||||||
@@ -514,8 +528,8 @@
|
|||||||
<string name="reaction_sleep_notification_text">%1$s oznámil, že jste usnuli, takže vaše hudba byla pozastavena. Toto můžete vypnout v nastavení zařízení.</string>
|
<string name="reaction_sleep_notification_text">%1$s oznámil, že jste usnuli, takže vaše hudba byla pozastavena. Toto můžete vypnout v nastavení zařízení.</string>
|
||||||
<string name="reaction_charged_channel_label">Oznámení o nabití</string>
|
<string name="reaction_charged_channel_label">Oznámení o nabití</string>
|
||||||
<string name="reaction_charged_notification_title">Nabíjení dokončeno</string>
|
<string name="reaction_charged_notification_title">Nabíjení dokončeno</string>
|
||||||
<string name="reaction_charged_notification_text_full">%1$s je plně nabitý.</string>
|
<string name="reaction_charged_notification_text_full">%1$s je plně nabito.</string>
|
||||||
<string name="reaction_charged_notification_text_partial">%1$s byl nabit na %2$d%%.</string>
|
<string name="reaction_charged_notification_text_partial">%1$s nabito na %2$d%%.</string>
|
||||||
<string name="device_settings_rename_label">Přejmenovat</string>
|
<string name="device_settings_rename_label">Přejmenovat</string>
|
||||||
<string name="device_settings_rename_hint">Název zařízení</string>
|
<string name="device_settings_rename_hint">Název zařízení</string>
|
||||||
<string name="device_settings_rename_confirm">Přejmenovat</string>
|
<string name="device_settings_rename_confirm">Přejmenovat</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Generelle justeringer, der påvirker hele appen.</string>
|
<string name="settings_general_description">Generelle justeringer, der påvirker hele appen.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Skjul ikke-matchende enheder</string>
|
<string name="settings_overview_hide_unmatched_label">Skjul ikke-matchende enheder</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Vis ikke nærliggende enheder, der ikke matcher nogen af dine profiler i overblikkket, f.eks. andres AirPods.</string>
|
<string name="settings_overview_hide_unmatched_description">Vis ikke nærliggende enheder, der ikke matcher nogen af dine profiler i overblikkket, f.eks. andres AirPods.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Batteriestimater</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Lær, hvordan denne enhed oplades og aflades, for at estimere resterende tid, tid til opladning, og batteritilstand.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Nulstil indlærte data</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Start forfra fra den nominelle batterilevetid.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Nulstil indlærte data?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod vil glemme denne enheds målte forbrug, opladningshastighed og batteritilstand, og starte forfra fra den nominelle batterilevetid.</string>
|
||||||
<string name="settings_acknowledgements_label">Anerkendelser</string>
|
<string name="settings_acknowledgements_label">Anerkendelser</string>
|
||||||
<string name="settings_debug_autoreports_label">Automatiske fejlrapporter</string>
|
<string name="settings_debug_autoreports_label">Automatiske fejlrapporter</string>
|
||||||
<string name="settings_debug_autoreports_description">Rapporterer automatisk problemer, f.eks. detaljer om et app-nedbrud, så jeg kan finde ud af, hvordan jeg løser det.</string>
|
<string name="settings_debug_autoreports_description">Rapporterer automatisk problemer, f.eks. detaljer om et app-nedbrud, så jeg kan finde ud af, hvordan jeg løser det.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Baggrundsgennemsigtighed</string>
|
<string name="widget_config_transparency_label">Baggrundsgennemsigtighed</string>
|
||||||
<string name="widget_config_show_device_label">Vis enhedsnavn</string>
|
<string name="widget_config_show_device_label">Vis enhedsnavn</string>
|
||||||
<string name="widget_config_reset_label">Nulstil til standard</string>
|
<string name="widget_config_reset_label">Nulstil til standard</string>
|
||||||
<string name="widget_config_preview_resize_hint">Du kan ændre størrelsen på widgetten, efter du har placeret den på din startskærm.</string>
|
<string name="widget_config_preview_resize_hint">Du kan ændre størrelse på widgetten efter at have placeret den på din hjemmeskærm.</string>
|
||||||
<string name="widget_config_custom_label">Brugerdefineret</string>
|
<string name="widget_config_custom_label">Brugerdefineret</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Mørk</string>
|
<string name="widget_config_preset_dark">Mørk</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Sidst set: %s</string>
|
<string name="last_seen_x">Sidst set: %s</string>
|
||||||
<string name="first_seen_x">Først set: %s</string>
|
<string name="first_seen_x">Først set: %s</string>
|
||||||
<string name="battery_cached_label">Sidst kendte · %s</string>
|
<string name="battery_cached_label">Sidst kendte · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dt %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dt</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Vis notifikationer</string>
|
<string name="permission_post_notifications_label">Vis notifikationer</string>
|
||||||
<string name="permission_post_notifications_description">"Tillad CAPod at vise notifikationer om dine AirPods, f.eks. deres aktuelle status mens de er tilsluttet."</string>
|
<string name="permission_post_notifications_description">"Tillad CAPod at vise notifikationer om dine AirPods, f.eks. deres aktuelle status mens de er tilsluttet."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Højre pod-serienummer</string>
|
<string name="device_settings_info_right_serial_label">Højre pod-serienummer</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Venstre parret</string>
|
<string name="device_settings_info_left_bonded_label">Venstre parret</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Højre parret</string>
|
<string name="device_settings_info_right_bonded_label">Højre parret</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Batteritilstand (estimeret)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Venstre batteritilstand (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Højre batteritilstand (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Bestemmes stadig, kom tilbage senere</string>
|
||||||
<string name="device_settings_info_details_label">Enhedsdetaljer</string>
|
<string name="device_settings_info_details_label">Enhedsdetaljer</string>
|
||||||
<string name="device_settings_info_details_action">Vis enhedsdetaljer</string>
|
<string name="device_settings_info_details_action">Vis enhedsdetaljer</string>
|
||||||
<string name="device_settings_info_status_label">Status</string>
|
<string name="device_settings_info_status_label">Status</string>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<string name="settings_autopplay_label">Automatisches abspielen</string>
|
<string name="settings_autopplay_label">Automatisches abspielen</string>
|
||||||
<string name="settings_autoplay_description">Musik fortsetzen, wenn du deine Pods wieder trägst – aber nur, wenn Auto-Pause sie gestoppt hat. Pausen, die du selbst ausgelöst hast (Telefon, AirPods-Stiel, Schlaf), bleiben aktiv.</string>
|
<string name="settings_autoplay_description">Musik fortsetzen, wenn du deine Pods wieder trägst – aber nur, wenn Auto-Pause sie gestoppt hat. Pausen, die du selbst ausgelöst hast (Telefon, AirPods-Stiel, Schlaf), bleiben aktiv.</string>
|
||||||
<string name="settings_start_music_on_wear_label">Musik beim Anlegen starten</string>
|
<string name="settings_start_music_on_wear_label">Musik beim Anlegen starten</string>
|
||||||
<string name="settings_start_music_on_wear_description">Versucht immer, Musik abzuspielen, wenn du deine Pods aufsetzt – auch nach einer manuellen Pause.</string>
|
<string name="settings_start_music_on_wear_description">Versucht immer, Musik abzuspielen, wenn du deine Pods aufsetzt.</string>
|
||||||
<string name="settings_eardetection_info_label">Hinweis zur Ohr-Erkennung</string>
|
<string name="settings_eardetection_info_label">Hinweis zur Ohr-Erkennung</string>
|
||||||
<string name="settings_eardetection_info_description">Wenn die Ohr-Erkennung nur für einen Pod funktioniert, ist das leider Apple-Einschränkung. Es wird dann nur der \"primäre Pod\" (Mikrofon) erkannt. Weitere Konfiguration auf Apple-Geräten: Einstellungen → Bluetooth → AirPods → Mikrofon.</string>
|
<string name="settings_eardetection_info_description">Wenn die Ohr-Erkennung nur für einen Pod funktioniert, ist das leider Apple-Einschränkung. Es wird dann nur der \"primäre Pod\" (Mikrofon) erkannt. Weitere Konfiguration auf Apple-Geräten: Einstellungen → Bluetooth → AirPods → Mikrofon.</string>
|
||||||
<string name="settings_signal_minimum_label">Minimale Signalqualität</string>
|
<string name="settings_signal_minimum_label">Minimale Signalqualität</string>
|
||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Allgemeine Optimierungen, die die gesamte App betreffen.</string>
|
<string name="settings_general_description">Allgemeine Optimierungen, die die gesamte App betreffen.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Nicht zugeordnete Geräte ausblenden</string>
|
<string name="settings_overview_hide_unmatched_label">Nicht zugeordnete Geräte ausblenden</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Geräte in der Nähe, die keinem deiner Profile entsprechen, werden in der Übersicht nicht angezeigt, z. B. AirPods anderer Personen.</string>
|
<string name="settings_overview_hide_unmatched_description">Geräte in der Nähe, die keinem deiner Profile entsprechen, werden in der Übersicht nicht angezeigt, z. B. AirPods anderer Personen.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Batterieschätzungen</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Erfahre, wie dieses Gerät geladen und entladen wird, um die verbleibende Zeit, die Zeit bis zur Aufladung und die Batteriegesundheit zu schätzen.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Gelernte Daten zurücksetzen</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Nochmal von vorne mit der nominalen Batterielaufzeit beginnen.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Gelernte Daten zurücksetzen?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod wird die gemessene Entladung, die Ladegeschwindigkeit und die Batteriegesundheit dieses Geräts vergessen und nochmal von vorne mit der nominalen Batterielaufzeit beginnen.</string>
|
||||||
<string name="settings_acknowledgements_label">Danksagungen</string>
|
<string name="settings_acknowledgements_label">Danksagungen</string>
|
||||||
<string name="settings_debug_autoreports_label">Automatische Fehlerberichte</string>
|
<string name="settings_debug_autoreports_label">Automatische Fehlerberichte</string>
|
||||||
<string name="settings_debug_autoreports_description">Meldet Probleme automatisch, z.B. Details zu einem App-Absturz, damit ich herausfinden kann, wie ich ihn beheben kann.</string>
|
<string name="settings_debug_autoreports_description">Meldet Probleme automatisch, z.B. Details zu einem App-Absturz, damit ich herausfinden kann, wie ich ihn beheben kann.</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Zuletzt gesehen: %s</string>
|
<string name="last_seen_x">Zuletzt gesehen: %s</string>
|
||||||
<string name="first_seen_x">Zum ersten Mal gesehen: %s</string>
|
<string name="first_seen_x">Zum ersten Mal gesehen: %s</string>
|
||||||
<string name="battery_cached_label">Zuletzt bekannt · %s</string>
|
<string name="battery_cached_label">Zuletzt bekannt · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$d h %2$d Min</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$d h</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$d Min</string>
|
||||||
<string name="permission_post_notifications_label">Zeige Benachrichtigungen</string>
|
<string name="permission_post_notifications_label">Zeige Benachrichtigungen</string>
|
||||||
<string name="permission_post_notifications_description">"Erlaube CAPod, Benachrichtigungen über deine AirPods anzuzeigen, z. B. deren aktuellen Status, während die Verbindung besteht."</string>
|
<string name="permission_post_notifications_description">"Erlaube CAPod, Benachrichtigungen über deine AirPods anzuzeigen, z. B. deren aktuellen Status, während die Verbindung besteht."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Seriennummer rechter Pod</string>
|
<string name="device_settings_info_right_serial_label">Seriennummer rechter Pod</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Links gekoppelt</string>
|
<string name="device_settings_info_left_bonded_label">Links gekoppelt</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Rechts gekoppelt</string>
|
<string name="device_settings_info_right_bonded_label">Rechts gekoppelt</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Batteriezustand (geschätzt)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Linker Batteriezustand (gesch.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Rechter Batteriezustand (gesch.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Wird noch ermittelt, schau später nochmal</string>
|
||||||
<string name="device_settings_info_details_label">Gerätedetails</string>
|
<string name="device_settings_info_details_label">Gerätedetails</string>
|
||||||
<string name="device_settings_info_details_action">Gerätedetails anzeigen</string>
|
<string name="device_settings_info_details_action">Gerätedetails anzeigen</string>
|
||||||
<string name="device_settings_info_status_label">Status</string>
|
<string name="device_settings_info_status_label">Status</string>
|
||||||
@@ -480,7 +494,7 @@
|
|||||||
<!-- New device settings -->
|
<!-- New device settings -->
|
||||||
<string name="device_settings_category_general_label">Allgemein</string>
|
<string name="device_settings_category_general_label">Allgemein</string>
|
||||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||||
<string name="device_settings_microphone_mode_description">Welches Ohrstöpsel als Mikrofon verwendet wird</string>
|
<string name="device_settings_microphone_mode_description">Welcher Ohrstöpsel als Mikrofon verwendet wird</string>
|
||||||
<string name="device_settings_microphone_mode_auto">Automatisch</string>
|
<string name="device_settings_microphone_mode_auto">Automatisch</string>
|
||||||
<string name="device_settings_microphone_mode_right">Rechts</string>
|
<string name="device_settings_microphone_mode_right">Rechts</string>
|
||||||
<string name="device_settings_microphone_mode_left">Links</string>
|
<string name="device_settings_microphone_mode_left">Links</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Γενικές τροποποιήσεις που επηρεάζουν ολόκληρη την εφαρμογή.</string>
|
<string name="settings_general_description">Γενικές τροποποιήσεις που επηρεάζουν ολόκληρη την εφαρμογή.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Απόκρυψη μη αντιστοιχισμένων συσκευών</string>
|
<string name="settings_overview_hide_unmatched_label">Απόκρυψη μη αντιστοιχισμένων συσκευών</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Να μην εμφανίζονται κοντινές συσκευές που δεν αντιστοιχούν σε κανένα από τα προφίλ σου στην επισκόπηση, π.χ. τα AirPods άλλων ανθρώπων.</string>
|
<string name="settings_overview_hide_unmatched_description">Να μην εμφανίζονται κοντινές συσκευές που δεν αντιστοιχούν σε κανένα από τα προφίλ σου στην επισκόπηση, π.χ. τα AirPods άλλων ανθρώπων.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Εκτιμήσεις μπαταρίας</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Μάθετε πώς φορτίζεται και εκφορτίζεται αυτή η συσκευή για να εκτιμήσετε τον υπόλοιπο χρόνο, τον χρόνο έως τη φόρτιση και την υγεία της μπαταρίας.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Επαναφορά μαθημένων δεδομένων</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Ξεκινήστε ξανά από τη χρήση μπαταρίας που αναφέρεται.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Επαναφορά μαθημένων δεδομένων;</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">Το CAPod θα ξεχάσει την μετρημένη κατανάλωση, την ταχύτητα φόρτισης και την υγεία της μπαταρίας αυτής της συσκευής, και θα ξεκινήσει ξανά από τη χρήση μπαταρίας που αναφέρεται.</string>
|
||||||
<string name="settings_acknowledgements_label">Ευχαριστίες</string>
|
<string name="settings_acknowledgements_label">Ευχαριστίες</string>
|
||||||
<string name="settings_debug_autoreports_label">Αυτόματες αναφορές σφαλμάτων</string>
|
<string name="settings_debug_autoreports_label">Αυτόματες αναφορές σφαλμάτων</string>
|
||||||
<string name="settings_debug_autoreports_description">Αναφέρει αυτόματα προβλήματα, π.χ. λεπτομέρειες σχετικά με μια κατάρρευση της εφαρμογής, ώστε να μπορώ να καταλάβω πώς να το διορθώσω.</string>
|
<string name="settings_debug_autoreports_description">Αναφέρει αυτόματα προβλήματα, π.χ. λεπτομέρειες σχετικά με μια κατάρρευση της εφαρμογής, ώστε να μπορώ να καταλάβω πώς να το διορθώσω.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Διαφάνεια φόντου</string>
|
<string name="widget_config_transparency_label">Διαφάνεια φόντου</string>
|
||||||
<string name="widget_config_show_device_label">Εμφάνιση ονόματος συσκευής</string>
|
<string name="widget_config_show_device_label">Εμφάνιση ονόματος συσκευής</string>
|
||||||
<string name="widget_config_reset_label">Επαναφορά στις προεπιλογές</string>
|
<string name="widget_config_reset_label">Επαναφορά στις προεπιλογές</string>
|
||||||
<string name="widget_config_preview_resize_hint">Μπορείτε να αλλάξετε το μέγεθος του widget αφού το τοποθετήσετε στην αρχική οθόνη σας.</string>
|
<string name="widget_config_preview_resize_hint">Μπορείτε να αλλάξετε το μέγεθος του widget μετά την τοποθέτησή του στην αρχική οθόνη σας.</string>
|
||||||
<string name="widget_config_custom_label">Προσαρμοσμένη</string>
|
<string name="widget_config_custom_label">Προσαρμοσμένη</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Σκοτεινό</string>
|
<string name="widget_config_preset_dark">Σκοτεινό</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Τελευταία εμφάνιση: %s</string>
|
<string name="last_seen_x">Τελευταία εμφάνιση: %s</string>
|
||||||
<string name="first_seen_x">Πρώτη εμφάνιση: %s</string>
|
<string name="first_seen_x">Πρώτη εμφάνιση: %s</string>
|
||||||
<string name="battery_cached_label">Τελευταία γνωστή · %s</string>
|
<string name="battery_cached_label">Τελευταία γνωστή · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dώ %2$dλ</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dώ</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dλ</string>
|
||||||
<string name="permission_post_notifications_label">Εμφάνιση ειδοποιήσεων</string>
|
<string name="permission_post_notifications_label">Εμφάνιση ειδοποιήσεων</string>
|
||||||
<string name="permission_post_notifications_description">"Επιτρέψτε στο CAPod να εμφανίζει ειδοποιήσεις σχετικά με τα AirPods σας, π.χ. την τρέχουσα κατάστασή τους ενώ είναι συνδεδεμένα."</string>
|
<string name="permission_post_notifications_description">"Επιτρέψτε στο CAPod να εμφανίζει ειδοποιήσεις σχετικά με τα AirPods σας, π.χ. την τρέχουσα κατάστασή τους ενώ είναι συνδεδεμένα."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Σειριακός Αριθμός Δεξιού Pod</string>
|
<string name="device_settings_info_right_serial_label">Σειριακός Αριθμός Δεξιού Pod</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Αριστερό Συζευγμένο</string>
|
<string name="device_settings_info_left_bonded_label">Αριστερό Συζευγμένο</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Δεξί Συζευγμένο</string>
|
<string name="device_settings_info_right_bonded_label">Δεξί Συζευγμένο</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Υγεία Μπαταρίας (εκτιμώμενη)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Υγεία Αριστερής Μπαταρίας (εκτ.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Υγεία Δεξιάς Μπαταρίας (εκτ.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Εξακολουθεί να προσδιορίζεται, ελέγξτε αργότερα</string>
|
||||||
<string name="device_settings_info_details_label">Λεπτομέρειες Συσκευής</string>
|
<string name="device_settings_info_details_label">Λεπτομέρειες Συσκευής</string>
|
||||||
<string name="device_settings_info_details_action">Εμφάνιση λεπτομερειών συσκευής</string>
|
<string name="device_settings_info_details_action">Εμφάνιση λεπτομερειών συσκευής</string>
|
||||||
<string name="device_settings_info_status_label">Κατάσταση</string>
|
<string name="device_settings_info_status_label">Κατάσταση</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Ajustes generales que afectan a toda la app.</string>
|
<string name="settings_general_description">Ajustes generales que afectan a toda la app.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no emparejados</string>
|
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no emparejados</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">No mostrar en el resumen los dispositivos cercanos que no coincidan con ninguno de tus perfiles, p. ej., los AirPods de otras personas.</string>
|
<string name="settings_overview_hide_unmatched_description">No mostrar en el resumen los dispositivos cercanos que no coincidan con ninguno de tus perfiles, p. ej., los AirPods de otras personas.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Estimaciones de batería</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Aprendé cómo este dispositivo se carga y se descarga para estimar el tiempo restante, el tiempo hasta que se cargue y la salud de la batería.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Reiniciar datos aprendidos</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Comenzá desde cero con la vida útil nominal de la batería.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">¿Reiniciar datos aprendidos?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod olvidará el consumo medido, la velocidad de carga y la salud de la batería de este dispositivo, y comenzará de nuevo con la vida útil nominal de la batería.</string>
|
||||||
<string name="settings_acknowledgements_label">Agradecimientos</string>
|
<string name="settings_acknowledgements_label">Agradecimientos</string>
|
||||||
<string name="settings_debug_autoreports_label">Reportes automáticos de errores</string>
|
<string name="settings_debug_autoreports_label">Reportes automáticos de errores</string>
|
||||||
<string name="settings_debug_autoreports_description">Reporta problemas automáticamente, ej. detalles sobre un cierre inesperado de la app para que pueda averiguar cómo solucionarlo.</string>
|
<string name="settings_debug_autoreports_description">Reporta problemas automáticamente, ej. detalles sobre un cierre inesperado de la app para que pueda averiguar cómo solucionarlo.</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Ultima vez conectado: %s</string>
|
<string name="last_seen_x">Ultima vez conectado: %s</string>
|
||||||
<string name="first_seen_x">Primera vez conectados: %s</string>
|
<string name="first_seen_x">Primera vez conectados: %s</string>
|
||||||
<string name="battery_cached_label">Último conocido · %s</string>
|
<string name="battery_cached_label">Último conocido · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
|
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
|
||||||
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
|
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
|
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
|
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
|
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Salud de la batería (estimada)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Salud batería izq. (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Salud batería der. (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Aún determinando, volvé más tarde</string>
|
||||||
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
|
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
|
||||||
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
|
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
|
||||||
<string name="device_settings_info_status_label">Estado</string>
|
<string name="device_settings_info_status_label">Estado</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Ajustes generales que afectan a toda la aplicación.</string>
|
<string name="settings_general_description">Ajustes generales que afectan a toda la aplicación.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no emparejados</string>
|
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no emparejados</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">No mostrar dispositivos cercanos que no coincidan con ninguno de tus perfiles en el resumen, p. ej. los AirPods de otras personas.</string>
|
<string name="settings_overview_hide_unmatched_description">No mostrar dispositivos cercanos que no coincidan con ninguno de tus perfiles en el resumen, p. ej. los AirPods de otras personas.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Estimaciones de batería</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Aprende cómo se carga y descarga este dispositivo para estimar el tiempo restante, el tiempo hasta cargarse y la salud de la batería.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Restablecer datos aprendidos</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Comienza desde la vida útil nominal de la batería.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">¿Restablecer datos aprendidos?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod olvidará el consumo medido, la velocidad de carga y la salud de la batería de este dispositivo, y comenzará desde la vida útil nominal de la batería.</string>
|
||||||
<string name="settings_acknowledgements_label">Agradecimientos</string>
|
<string name="settings_acknowledgements_label">Agradecimientos</string>
|
||||||
<string name="settings_debug_autoreports_label">Reportes automáticos de errores</string>
|
<string name="settings_debug_autoreports_label">Reportes automáticos de errores</string>
|
||||||
<string name="settings_debug_autoreports_description">Reporta problemas automáticamente, ej. detalles sobre un cierre inesperado de la aplicación para que pueda averiguar cómo solucionarlo.</string>
|
<string name="settings_debug_autoreports_description">Reporta problemas automáticamente, ej. detalles sobre un cierre inesperado de la aplicación para que pueda averiguar cómo solucionarlo.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Transparencia de fondo</string>
|
<string name="widget_config_transparency_label">Transparencia de fondo</string>
|
||||||
<string name="widget_config_show_device_label">Mostrar nombre del dispositivo</string>
|
<string name="widget_config_show_device_label">Mostrar nombre del dispositivo</string>
|
||||||
<string name="widget_config_reset_label">Restablecer predeterminados</string>
|
<string name="widget_config_reset_label">Restablecer predeterminados</string>
|
||||||
<string name="widget_config_preview_resize_hint">Puedes cambiar el tamaño del widget después de colocarlo en tu pantalla de inicio.</string>
|
<string name="widget_config_preview_resize_hint">Puedes redimensionar el widget después de colocarlo en tu pantalla de inicio.</string>
|
||||||
<string name="widget_config_custom_label">Personalizado</string>
|
<string name="widget_config_custom_label">Personalizado</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Oscuro</string>
|
<string name="widget_config_preset_dark">Oscuro</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Ultima vez conectado: %s</string>
|
<string name="last_seen_x">Ultima vez conectado: %s</string>
|
||||||
<string name="first_seen_x">Primera vez conectados: %s</string>
|
<string name="first_seen_x">Primera vez conectados: %s</string>
|
||||||
<string name="battery_cached_label">Último conocido · %s</string>
|
<string name="battery_cached_label">Último conocido · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
|
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
|
||||||
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
|
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
|
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
|
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
|
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Salud de la batería (estimada)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Salud de la batería izquierda (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Salud de la batería derecha (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Aún se está determinando, vuelve más tarde</string>
|
||||||
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
|
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
|
||||||
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
|
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
|
||||||
<string name="device_settings_info_status_label">Estado</string>
|
<string name="device_settings_info_status_label">Estado</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Ajustes generales que afectan a toda la aplicación.</string>
|
<string name="settings_general_description">Ajustes generales que afectan a toda la aplicación.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no reconocidos</string>
|
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos no reconocidos</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">No mostrar en el resumen los dispositivos cercanos que no coincidan con ninguno de tus perfiles, p. ej. los AirPods de otras personas.</string>
|
<string name="settings_overview_hide_unmatched_description">No mostrar en el resumen los dispositivos cercanos que no coincidan con ninguno de tus perfiles, p. ej. los AirPods de otras personas.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Estimaciones de batería</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Aprende cómo se carga y descarga este dispositivo para estimar el tiempo restante, el tiempo hasta que se cargue completamente y la salud de la batería.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Restablecer datos aprendidos</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Comienza de nuevo desde la duración nominal de la batería.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">¿Restablecer datos aprendidos?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod olvidará el consumo medido de este dispositivo, la velocidad de carga y la salud de la batería, y comenzará de nuevo desde la duración nominal de la batería.</string>
|
||||||
<string name="settings_acknowledgements_label">Agradecimientos</string>
|
<string name="settings_acknowledgements_label">Agradecimientos</string>
|
||||||
<string name="settings_debug_autoreports_label">Informes de error automáticos</string>
|
<string name="settings_debug_autoreports_label">Informes de error automáticos</string>
|
||||||
<string name="settings_debug_autoreports_description">Informa automáticamente de los problemas, p. ej., detalles sobre un fallo de la aplicación para que pueda averiguar cómo solucionarlo.</string>
|
<string name="settings_debug_autoreports_description">Informa automáticamente de los problemas, p. ej., detalles sobre un fallo de la aplicación para que pueda averiguar cómo solucionarlo.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Transparencia del fondo</string>
|
<string name="widget_config_transparency_label">Transparencia del fondo</string>
|
||||||
<string name="widget_config_show_device_label">Mostrar el nombre del dispositivo</string>
|
<string name="widget_config_show_device_label">Mostrar el nombre del dispositivo</string>
|
||||||
<string name="widget_config_reset_label">Restaurar los valores predeterminados</string>
|
<string name="widget_config_reset_label">Restaurar los valores predeterminados</string>
|
||||||
<string name="widget_config_preview_resize_hint">Puedes cambiar el tamaño del widget después de colocarlo en la pantalla de inicio.</string>
|
<string name="widget_config_preview_resize_hint">Puedes cambiar el tamaño del widget después de colocarlo en tu pantalla de inicio.</string>
|
||||||
<string name="widget_config_custom_label">Personalizado</string>
|
<string name="widget_config_custom_label">Personalizado</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Oscuro</string>
|
<string name="widget_config_preset_dark">Oscuro</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Ultima vez conectado: %s</string>
|
<string name="last_seen_x">Ultima vez conectado: %s</string>
|
||||||
<string name="first_seen_x">Primera vez conectados: %s</string>
|
<string name="first_seen_x">Primera vez conectados: %s</string>
|
||||||
<string name="battery_cached_label">Último conocido · %s</string>
|
<string name="battery_cached_label">Último conocido · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
|
<string name="permission_post_notifications_label">Mostrar las notificaciones</string>
|
||||||
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
|
<string name="permission_post_notifications_description">"Permitir que CAPod muestre notificaciones de tus AirPods, por ejemplo, su estado actual mientras están conectados."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
|
<string name="device_settings_info_right_serial_label">Número de serie del auricular derecho</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
|
<string name="device_settings_info_left_bonded_label">Izquierdo vinculado</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
|
<string name="device_settings_info_right_bonded_label">Derecho vinculado</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Salud de la batería (estimado)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Salud de la batería izquierda (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Salud de la batería derecha (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Aún se está determinando, comprueba más tarde</string>
|
||||||
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
|
<string name="device_settings_info_details_label">Detalles del dispositivo</string>
|
||||||
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
|
<string name="device_settings_info_details_action">Mostrar detalles del dispositivo</string>
|
||||||
<string name="device_settings_info_status_label">Estado</string>
|
<string name="device_settings_info_status_label">Estado</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Kogu rakendust mõjutavad üldseaded.</string>
|
<string name="settings_general_description">Kogu rakendust mõjutavad üldseaded.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Peida mittevastavad seadmed</string>
|
<string name="settings_overview_hide_unmatched_label">Peida mittevastavad seadmed</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Ära näita ülevaates lähedalasuvaid seadmeid, mis ei vasta ühelegi sinu profiilile, nt teiste inimeste AirPods.</string>
|
<string name="settings_overview_hide_unmatched_description">Ära näita ülevaates lähedalasuvaid seadmeid, mis ei vasta ühelegi sinu profiilile, nt teiste inimeste AirPods.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Aku hinnangud</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Saate teada, kuidas see seade laadib ja tühjeneb, et hinnata ülejäänud aega, laadimiseni kuluva aega ja aku tervist.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Lähtesta õpitud andmed</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Alusta uuesti nimiväärtustest.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Lähtesta õpitud andmed?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod unustab selle seadme mõõdetud kulu, laadimiskiiruse ja aku tervise ning alustab uuesti nimiväärtustest.</string>
|
||||||
<string name="settings_acknowledgements_label">Tunnustused</string>
|
<string name="settings_acknowledgements_label">Tunnustused</string>
|
||||||
<string name="settings_debug_autoreports_label">Automaatsed veateated</string>
|
<string name="settings_debug_autoreports_label">Automaatsed veateated</string>
|
||||||
<string name="settings_debug_autoreports_description">Teavitab vigadest ise, nt andmed rakenduse hangumise kohta, et saaksin mõista, kuidas olukorda lahendada.</string>
|
<string name="settings_debug_autoreports_description">Teavitab vigadest ise, nt andmed rakenduse hangumise kohta, et saaksin mõista, kuidas olukorda lahendada.</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Viimati kasutusel: %s</string>
|
<string name="last_seen_x">Viimati kasutusel: %s</string>
|
||||||
<string name="first_seen_x">Esimesena kasutusel: %s</string>
|
<string name="first_seen_x">Esimesena kasutusel: %s</string>
|
||||||
<string name="battery_cached_label">Viimati teadaolev · %s</string>
|
<string name="battery_cached_label">Viimati teadaolev · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dt %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dt</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Näita teateid</string>
|
<string name="permission_post_notifications_label">Näita teateid</string>
|
||||||
<string name="permission_post_notifications_description">"Luba CAPodil näidata AirPodsi teateid, nt praegust olekut ühendatud olles."</string>
|
<string name="permission_post_notifications_description">"Luba CAPodil näidata AirPodsi teateid, nt praegust olekut ühendatud olles."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Parema kõrvaklapiosa seerianumber</string>
|
<string name="device_settings_info_right_serial_label">Parema kõrvaklapiosa seerianumber</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Vasak seotud</string>
|
<string name="device_settings_info_left_bonded_label">Vasak seotud</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Parem seotud</string>
|
<string name="device_settings_info_right_bonded_label">Parem seotud</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Aku tervis (hinnanguline)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Vasaku aku tervis (hinnanguline)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Parema aku tervis (hinnanguline)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Määratakse, vaata hiljem uuesti</string>
|
||||||
<string name="device_settings_info_details_label">Seadme andmed</string>
|
<string name="device_settings_info_details_label">Seadme andmed</string>
|
||||||
<string name="device_settings_info_details_action">Kuva seadme andmed</string>
|
<string name="device_settings_info_details_action">Kuva seadme andmed</string>
|
||||||
<string name="device_settings_info_status_label">Olek</string>
|
<string name="device_settings_info_status_label">Olek</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Aplikazio osoari eragiten dioten doikuntza orokorrak.</string>
|
<string name="settings_general_description">Aplikazio osoari eragiten dioten doikuntza orokorrak.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Ezkutatu bat ez datozen gailuak</string>
|
<string name="settings_overview_hide_unmatched_label">Ezkutatu bat ez datozen gailuak</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Ez erakutsi ikuspegiaren inguruan dauden eta zure profilekin bat ez datozen gailuak, esaterako, beste pertsonen AirPods-ak.</string>
|
<string name="settings_overview_hide_unmatched_description">Ez erakutsi ikuspegiaren inguruan dauden eta zure profilekin bat ez datozen gailuak, esaterako, beste pertsonen AirPods-ak.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Bateriaren estimazioak</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Ikasi nola kargatzen eta deskargatzen den gailu hau, geratzen den denbora, kargatzeko denbora eta bateriaren osasuna estimatzeko.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Ikasitako datuak berrezarri</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Kalifikatutako bateriaren bizitzatik berriro hasi.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Ikasitako datuak berrezarri?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod-ek gailu honetako neurtutako kontsumoa, kargatzeko abiadura eta bateriaren osasuna ahaztuko ditu, eta kalifikatutako bateriaren bizitzatik berriro hasiko da.</string>
|
||||||
<string name="settings_acknowledgements_label">Eskertzak</string>
|
<string name="settings_acknowledgements_label">Eskertzak</string>
|
||||||
<string name="settings_debug_autoreports_label">Erroreen txosten automatikoak</string>
|
<string name="settings_debug_autoreports_label">Erroreen txosten automatikoak</string>
|
||||||
<string name="settings_debug_autoreports_description">Automatikoki arazoak salatzen ditu, adib. aplikazioaren kraskaduraren xehetasunak, konpontzen jakin dezadan.</string>
|
<string name="settings_debug_autoreports_description">Automatikoki arazoak salatzen ditu, adib. aplikazioaren kraskaduraren xehetasunak, konpontzen jakin dezadan.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Atzeko planoaren gardentasuna</string>
|
<string name="widget_config_transparency_label">Atzeko planoaren gardentasuna</string>
|
||||||
<string name="widget_config_show_device_label">Gailuaren izena erakutsi</string>
|
<string name="widget_config_show_device_label">Gailuaren izena erakutsi</string>
|
||||||
<string name="widget_config_reset_label">Berriro lehenespenera berrezarri</string>
|
<string name="widget_config_reset_label">Berriro lehenespenera berrezarri</string>
|
||||||
<string name="widget_config_preview_resize_hint">Widgeta tamainaz alda dezakezu hasierako pantailan jarri ondoren.</string>
|
<string name="widget_config_preview_resize_hint">Widgeta tamaina aldatu dezakezu, hasierako pantailan jarri ondoren.</string>
|
||||||
<string name="widget_config_custom_label">Pertsonalizatua</string>
|
<string name="widget_config_custom_label">Pertsonalizatua</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Iluna</string>
|
<string name="widget_config_preset_dark">Iluna</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Azkenekoz ikusia: %s</string>
|
<string name="last_seen_x">Azkenekoz ikusia: %s</string>
|
||||||
<string name="first_seen_x">Lehen aldiz ikusia: %s</string>
|
<string name="first_seen_x">Lehen aldiz ikusia: %s</string>
|
||||||
<string name="battery_cached_label">Azken ezaguna · %s</string>
|
<string name="battery_cached_label">Azken ezaguna · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Erakutsi jakinarazpenak</string>
|
<string name="permission_post_notifications_label">Erakutsi jakinarazpenak</string>
|
||||||
<string name="permission_post_notifications_description">"Baimendu CAPod-i zure AirPods-ei buruzko jakinarazpenak erakustea, adib. konektatuta dauden bitartean euren egungo egoera."</string>
|
<string name="permission_post_notifications_description">"Baimendu CAPod-i zure AirPods-ei buruzko jakinarazpenak erakustea, adib. konektatuta dauden bitartean euren egungo egoera."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Eskuineko pod-aren seriea</string>
|
<string name="device_settings_info_right_serial_label">Eskuineko pod-aren seriea</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Ezkerra Lotuta</string>
|
<string name="device_settings_info_left_bonded_label">Ezkerra Lotuta</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Eskuina Lotuta</string>
|
<string name="device_settings_info_right_bonded_label">Eskuina Lotuta</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Bateriaren osasuna (kalkulatua)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Ezkerreko bateriaren osasuna (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Eskuineko bateriaren osasuna (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Oraindik kalkulatzen, beranduago itzuli</string>
|
||||||
<string name="device_settings_info_details_label">Gailuaren xehetasunak</string>
|
<string name="device_settings_info_details_label">Gailuaren xehetasunak</string>
|
||||||
<string name="device_settings_info_details_action">Erakutsi gailuaren xehetasunak</string>
|
<string name="device_settings_info_details_action">Erakutsi gailuaren xehetasunak</string>
|
||||||
<string name="device_settings_info_status_label">Egoera</string>
|
<string name="device_settings_info_status_label">Egoera</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">تنظیمات کلی که بر کل برنامه تأثیر میگذارند.</string>
|
<string name="settings_general_description">تنظیمات کلی که بر کل برنامه تأثیر میگذارند.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">پنهان کردن دستگاههای نامتناسب</string>
|
<string name="settings_overview_hide_unmatched_label">پنهان کردن دستگاههای نامتناسب</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">دستگاههای نزدیکی که با هیچکدام از پروفایلهای شما تطابق ندارند در نمای کلی نمایش داده نمیشوند، مثلاً AirPods دیگران.</string>
|
<string name="settings_overview_hide_unmatched_description">دستگاههای نزدیکی که با هیچکدام از پروفایلهای شما تطابق ندارند در نمای کلی نمایش داده نمیشوند، مثلاً AirPods دیگران.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">برآورد زمان باتری</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">یاد بگیرید که این دستگاه چگونه شارژ و تخلیه میشود تا زمان باقیمانده، زمان تا پر شدن و سلامت باتری را تخمین بزنید.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">بازنشانی دادههای یادگیری</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">شروع مجدد از عمر اسمی باتری.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">بازنشانی دادههای یادگیریشده؟</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod دادههای اندازهگیریشده این دستگاه (تخلیه، سرعت شارژ و سلامت باتری) را فراموش خواهد کرد و دوباره از عمر اسمی باتری شروع خواهد کرد.</string>
|
||||||
<string name="settings_acknowledgements_label">قدردانیها</string>
|
<string name="settings_acknowledgements_label">قدردانیها</string>
|
||||||
<string name="settings_debug_autoreports_label">گزارشهای خطای خودکار</string>
|
<string name="settings_debug_autoreports_label">گزارشهای خطای خودکار</string>
|
||||||
<string name="settings_debug_autoreports_description">مشکلات را بهطور خودکار گزارش میدهد، مثلاً جزئیات مربوط به خرابی برنامه تا بتوانم نحوه رفع آن را بفهمم.</string>
|
<string name="settings_debug_autoreports_description">مشکلات را بهطور خودکار گزارش میدهد، مثلاً جزئیات مربوط به خرابی برنامه تا بتوانم نحوه رفع آن را بفهمم.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">شفافیت پسزمینه</string>
|
<string name="widget_config_transparency_label">شفافیت پسزمینه</string>
|
||||||
<string name="widget_config_show_device_label">نمایش نام دستگاه</string>
|
<string name="widget_config_show_device_label">نمایش نام دستگاه</string>
|
||||||
<string name="widget_config_reset_label">بازنشانی به مقادیر پیشفرض</string>
|
<string name="widget_config_reset_label">بازنشانی به مقادیر پیشفرض</string>
|
||||||
<string name="widget_config_preview_resize_hint">میتوانید پس از قرار دادن ویجت روی صفحه اصلی، اندازه آن را تغییر دهید.</string>
|
<string name="widget_config_preview_resize_hint">شما میتوانید ابزارک را پس از قرار دادن آن در صفحه اصلی تغییر اندازه دهید.</string>
|
||||||
<string name="widget_config_custom_label">سفارشی</string>
|
<string name="widget_config_custom_label">سفارشی</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">تیره</string>
|
<string name="widget_config_preset_dark">تیره</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">آخرین مشاهده: %s</string>
|
<string name="last_seen_x">آخرین مشاهده: %s</string>
|
||||||
<string name="first_seen_x">اولین مشاهده: %s</string>
|
<string name="first_seen_x">اولین مشاهده: %s</string>
|
||||||
<string name="battery_cached_label">آخرین · %s</string>
|
<string name="battery_cached_label">آخرین · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$d س %2$d د</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$d س</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$d د</string>
|
||||||
<string name="permission_post_notifications_label">نمایش اعلانها</string>
|
<string name="permission_post_notifications_label">نمایش اعلانها</string>
|
||||||
<string name="permission_post_notifications_description">"به CAPod اجازه دهید اعلانهایی درباره AirPods شما نشان دهد، مثلاً وضعیت فعلی آنها هنگام اتصال."</string>
|
<string name="permission_post_notifications_description">"به CAPod اجازه دهید اعلانهایی درباره AirPods شما نشان دهد، مثلاً وضعیت فعلی آنها هنگام اتصال."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">شماره سریال پاد راست</string>
|
<string name="device_settings_info_right_serial_label">شماره سریال پاد راست</string>
|
||||||
<string name="device_settings_info_left_bonded_label">چپ متصل</string>
|
<string name="device_settings_info_left_bonded_label">چپ متصل</string>
|
||||||
<string name="device_settings_info_right_bonded_label">راست متصل</string>
|
<string name="device_settings_info_right_bonded_label">راست متصل</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">سلامت باتری (تخمینی)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">سلامت باتری سمت چپ (تخمینی)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">سلامت باتری سمت راست (تخمینی)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">هنوز درحال تعیین است، بعدا دوباره مراجعه کنید</string>
|
||||||
<string name="device_settings_info_details_label">جزئیات دستگاه</string>
|
<string name="device_settings_info_details_label">جزئیات دستگاه</string>
|
||||||
<string name="device_settings_info_details_action">نمایش جزئیات دستگاه</string>
|
<string name="device_settings_info_details_action">نمایش جزئیات دستگاه</string>
|
||||||
<string name="device_settings_info_status_label">وضعیت</string>
|
<string name="device_settings_info_status_label">وضعیت</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Yleiset säädöt, jotka vaikuttavat koko sovellukseen.</string>
|
<string name="settings_general_description">Yleiset säädöt, jotka vaikuttavat koko sovellukseen.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Piilota yhteensopimattomat laitteet</string>
|
<string name="settings_overview_hide_unmatched_label">Piilota yhteensopimattomat laitteet</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Älä näytä lähistöllä olevia laitteita, jotka eivät vastaa mitään profiileistasi yleiskatsauksessa, esim. muiden ihmisten AirPodseja.</string>
|
<string name="settings_overview_hide_unmatched_description">Älä näytä lähistöllä olevia laitteita, jotka eivät vastaa mitään profiileistasi yleiskatsauksessa, esim. muiden ihmisten AirPodseja.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Akun arviot</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Opi kuinka tämä laite latautuu ja kulutetaan, jotta voit arvioida jäljellä olevaa aikaa, latautumiseen kuluvaa aikaa ja akun kuntoa.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Nollaa opitut tiedot</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Aloita alusta nimellisakun perusteella.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Nollataan opitut tiedot?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod unohtaa tämän laitteen mitatun kulutuksen, latausnopeuden ja akun kunnon, ja aloittaa alusta nimellisakun perusteella.</string>
|
||||||
<string name="settings_acknowledgements_label">Kiitokset</string>
|
<string name="settings_acknowledgements_label">Kiitokset</string>
|
||||||
<string name="settings_debug_autoreports_label">Automaattiset virheraportit</string>
|
<string name="settings_debug_autoreports_label">Automaattiset virheraportit</string>
|
||||||
<string name="settings_debug_autoreports_description">Raportoi ongelmat automaattisesti, esim. tiedot sovelluksen kaatumisesta, jotta voin selvittää, miten se korjataan.</string>
|
<string name="settings_debug_autoreports_description">Raportoi ongelmat automaattisesti, esim. tiedot sovelluksen kaatumisesta, jotta voin selvittää, miten se korjataan.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Taustan läpinäkyvyys</string>
|
<string name="widget_config_transparency_label">Taustan läpinäkyvyys</string>
|
||||||
<string name="widget_config_show_device_label">Näytä laitteen nimi</string>
|
<string name="widget_config_show_device_label">Näytä laitteen nimi</string>
|
||||||
<string name="widget_config_reset_label">Palauta oletusasetukset</string>
|
<string name="widget_config_reset_label">Palauta oletusasetukset</string>
|
||||||
<string name="widget_config_preview_resize_hint">Voit muuttaa widgetin kokoa sen asettamisen jälkeen aloitusnäytöllä.</string>
|
<string name="widget_config_preview_resize_hint">Voit muuttaa widgetin kokoa sen jälkeen, kun olet asettanut sen kotinäytöllesi.</string>
|
||||||
<string name="widget_config_custom_label">Mukautettu</string>
|
<string name="widget_config_custom_label">Mukautettu</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Tumma</string>
|
<string name="widget_config_preset_dark">Tumma</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Viimeksi nähty: %s</string>
|
<string name="last_seen_x">Viimeksi nähty: %s</string>
|
||||||
<string name="first_seen_x">Ensimmäisen kerran nähty: %s</string>
|
<string name="first_seen_x">Ensimmäisen kerran nähty: %s</string>
|
||||||
<string name="battery_cached_label">Viimeksi nähty · %s</string>
|
<string name="battery_cached_label">Viimeksi nähty · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Näytä ilmoitukset</string>
|
<string name="permission_post_notifications_label">Näytä ilmoitukset</string>
|
||||||
<string name="permission_post_notifications_description">"Salli CAPod-sovelluksen näyttää ilmoituksia AirPodeistasi, esim. niiden nykyinen tila yhdistettynä."</string>
|
<string name="permission_post_notifications_description">"Salli CAPod-sovelluksen näyttää ilmoituksia AirPodeistasi, esim. niiden nykyinen tila yhdistettynä."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Oikean kuulokkeen sarjanumero</string>
|
<string name="device_settings_info_right_serial_label">Oikean kuulokkeen sarjanumero</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Vasen yhdistetty</string>
|
<string name="device_settings_info_left_bonded_label">Vasen yhdistetty</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Oikea yhdistetty</string>
|
<string name="device_settings_info_right_bonded_label">Oikea yhdistetty</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Akun kunto (arvioitu)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Vasemman akun kunto (arvioitu)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Oikean akun kunto (arvioitu)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Vielä määritteillään, tarkista myöhemmin</string>
|
||||||
<string name="device_settings_info_details_label">Laitteen tiedot</string>
|
<string name="device_settings_info_details_label">Laitteen tiedot</string>
|
||||||
<string name="device_settings_info_details_action">Näytä laitteen tiedot</string>
|
<string name="device_settings_info_details_action">Näytä laitteen tiedot</string>
|
||||||
<string name="device_settings_info_status_label">Tila</string>
|
<string name="device_settings_info_status_label">Tila</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Mga pangkalahatang pag-aayos na nakakaapekto sa buong app.</string>
|
<string name="settings_general_description">Mga pangkalahatang pag-aayos na nakakaapekto sa buong app.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Itago ang mga hindi tugmang device</string>
|
<string name="settings_overview_hide_unmatched_label">Itago ang mga hindi tugmang device</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Huwag ipakita ang mga kalapit na device na hindi tugma sa alinman sa iyong mga profile sa pangkalahatang-tanaw, hal. ang mga AirPods ng ibang tao.</string>
|
<string name="settings_overview_hide_unmatched_description">Huwag ipakita ang mga kalapit na device na hindi tugma sa alinman sa iyong mga profile sa pangkalahatang-tanaw, hal. ang mga AirPods ng ibang tao.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Mga Pagtataya ng Baterya</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Alamin kung paano nag-charge at gumagamit ng kuryente ang device na ito upang matantya ang natitirang oras, oras hanggang sa puno, at kalusugan ng baterya.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Burahin ang natutunan na datos</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Magsimula muli mula sa rated na buhay ng baterya.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Burahin ang natutunan na datos?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">Makakalimutan ng CAPod ang sinusukat na paggamit ng kuryente, bilis ng pag-charge, at kalusugan ng baterya ng device na ito, at magsisimula muli mula sa rated na buhay ng baterya.</string>
|
||||||
<string name="settings_acknowledgements_label">Mga Pasasalamat</string>
|
<string name="settings_acknowledgements_label">Mga Pasasalamat</string>
|
||||||
<string name="settings_debug_autoreports_label">Mga awtomatikong ulat ng bug</string>
|
<string name="settings_debug_autoreports_label">Mga awtomatikong ulat ng bug</string>
|
||||||
<string name="settings_debug_autoreports_description">Awtomatikong nag-uulat ng mga isyu, hal. mga detalye sa isang pag-crash ng app para malaman ko kung paano ito ayusin.</string>
|
<string name="settings_debug_autoreports_description">Awtomatikong nag-uulat ng mga isyu, hal. mga detalye sa isang pag-crash ng app para malaman ko kung paano ito ayusin.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Transparency ng background</string>
|
<string name="widget_config_transparency_label">Transparency ng background</string>
|
||||||
<string name="widget_config_show_device_label">Ipakita ang pangalan ng device</string>
|
<string name="widget_config_show_device_label">Ipakita ang pangalan ng device</string>
|
||||||
<string name="widget_config_reset_label">I-reset sa mga default</string>
|
<string name="widget_config_reset_label">I-reset sa mga default</string>
|
||||||
<string name="widget_config_preview_resize_hint">Maaari mong baguhin ang laki ng widget pagkatapos ilagay ito sa iyong home screen.</string>
|
<string name="widget_config_preview_resize_hint">Maaari mong baguhin ang laki ng widget pagkatapos itong ilagay sa iyong home screen.</string>
|
||||||
<string name="widget_config_custom_label">Pasadyang</string>
|
<string name="widget_config_custom_label">Pasadyang</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Madilim</string>
|
<string name="widget_config_preset_dark">Madilim</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Huling nakita: %s</string>
|
<string name="last_seen_x">Huling nakita: %s</string>
|
||||||
<string name="first_seen_x">Unang nakita: %s</string>
|
<string name="first_seen_x">Unang nakita: %s</string>
|
||||||
<string name="battery_cached_label">Huling kilala · %s</string>
|
<string name="battery_cached_label">Huling kilala · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$d h</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$d m</string>
|
||||||
<string name="permission_post_notifications_label">Ipakita ang mga notipikasyon</string>
|
<string name="permission_post_notifications_label">Ipakita ang mga notipikasyon</string>
|
||||||
<string name="permission_post_notifications_description">"Payagan ang CAPod na magpakita ng mga notipikasyon tungkol sa iyong AirPods, hal. ang kanilang kasalukuyang status habang konektado."</string>
|
<string name="permission_post_notifications_description">"Payagan ang CAPod na magpakita ng mga notipikasyon tungkol sa iyong AirPods, hal. ang kanilang kasalukuyang status habang konektado."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Serial ng Kanang Pod</string>
|
<string name="device_settings_info_right_serial_label">Serial ng Kanang Pod</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Kaliwang Naka-bond</string>
|
<string name="device_settings_info_left_bonded_label">Kaliwang Naka-bond</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Kanang Naka-bond</string>
|
<string name="device_settings_info_right_bonded_label">Kanang Naka-bond</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Kalusugan ng Baterya (tinantya)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Kalusugan ng Kaliwang Baterya (tinantya.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Kalusugan ng Kananang Baterya (tinantya.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Patuloy na tinutukoy, bumalik mamaya</string>
|
||||||
<string name="device_settings_info_details_label">Mga Detalye ng Device</string>
|
<string name="device_settings_info_details_label">Mga Detalye ng Device</string>
|
||||||
<string name="device_settings_info_details_action">Ipakita ang mga detalye ng device</string>
|
<string name="device_settings_info_details_action">Ipakita ang mga detalye ng device</string>
|
||||||
<string name="device_settings_info_status_label">Katayuan</string>
|
<string name="device_settings_info_status_label">Katayuan</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Réglage généraux qui affectent l\'appli entière.</string>
|
<string name="settings_general_description">Réglage généraux qui affectent l\'appli entière.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Masquer les appareils sans correspondance</string>
|
<string name="settings_overview_hide_unmatched_label">Masquer les appareils sans correspondance</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Ne pas afficher dans l’aperçu les appareils à proximité qui ne correspondent à aucun de vos profils, p. ex. les AirPods d’autres personnes.</string>
|
<string name="settings_overview_hide_unmatched_description">Ne pas afficher dans l’aperçu les appareils à proximité qui ne correspondent à aucun de vos profils, p. ex. les AirPods d’autres personnes.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Estimations batterie</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Découvrez comment cet appareil se charge et se décharge pour estimer le temps restant, le temps jusqu’à la charge complète et l’état de la batterie.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Réinitialiser les données apprises</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Recommencer à partir de l’autonomie nominale de la batterie.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Réinitialiser les données apprises ?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod oubliera la consommation mesurée de cet appareil, sa vitesse de charge et l’état de sa batterie, et recommencera à partir de l’autonomie nominale de la batterie.</string>
|
||||||
<string name="settings_acknowledgements_label">Remerciements</string>
|
<string name="settings_acknowledgements_label">Remerciements</string>
|
||||||
<string name="settings_debug_autoreports_label">Relevés de bogue automatiques</string>
|
<string name="settings_debug_autoreports_label">Relevés de bogue automatiques</string>
|
||||||
<string name="settings_debug_autoreports_description">Signale automatiquement les problèmes, p. ex., les détails d’un plantage de l’appli, afin que je puisse essayer de les corriger.</string>
|
<string name="settings_debug_autoreports_description">Signale automatiquement les problèmes, p. ex., les détails d’un plantage de l’appli, afin que je puisse essayer de les corriger.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Transparence de l\'arrière-plan</string>
|
<string name="widget_config_transparency_label">Transparence de l\'arrière-plan</string>
|
||||||
<string name="widget_config_show_device_label">Afficher le nom de l’appareil</string>
|
<string name="widget_config_show_device_label">Afficher le nom de l’appareil</string>
|
||||||
<string name="widget_config_reset_label">Rétablir les valeurs par défaut</string>
|
<string name="widget_config_reset_label">Rétablir les valeurs par défaut</string>
|
||||||
<string name="widget_config_preview_resize_hint">Vous pouvez redimensionner le widget après l\'avoir placé sur votre écran d\'accueil.</string>
|
<string name="widget_config_preview_resize_hint">Vous pouvez redimensionner le widget après l’avoir placé sur votre écran d’accueil.</string>
|
||||||
<string name="widget_config_custom_label">Personnalisée</string>
|
<string name="widget_config_custom_label">Personnalisée</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Sombre</string>
|
<string name="widget_config_preset_dark">Sombre</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Dernière connection : %s</string>
|
<string name="last_seen_x">Dernière connection : %s</string>
|
||||||
<string name="first_seen_x">Première connexion : %s</string>
|
<string name="first_seen_x">Première connexion : %s</string>
|
||||||
<string name="battery_cached_label">Dernière fois vu · %s</string>
|
<string name="battery_cached_label">Dernière fois vu · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Afficher les notifications</string>
|
<string name="permission_post_notifications_label">Afficher les notifications</string>
|
||||||
<string name="permission_post_notifications_description">"Permettre à CAPod d’afficher des notifications au sujet des AirPods, p. ex. leur état actuel quand ils sont connectés."</string>
|
<string name="permission_post_notifications_description">"Permettre à CAPod d’afficher des notifications au sujet des AirPods, p. ex. leur état actuel quand ils sont connectés."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Numéro de série de l’écouteur droit</string>
|
<string name="device_settings_info_right_serial_label">Numéro de série de l’écouteur droit</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Gauche appairé</string>
|
<string name="device_settings_info_left_bonded_label">Gauche appairé</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Droite appairée</string>
|
<string name="device_settings_info_right_bonded_label">Droite appairée</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Santé de la batterie (estimée)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Santé de la batterie gauche (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Santé de la batterie droite (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">En cours de détermination, revenez plus tard</string>
|
||||||
<string name="device_settings_info_details_label">Détails de l’appareil</string>
|
<string name="device_settings_info_details_label">Détails de l’appareil</string>
|
||||||
<string name="device_settings_info_details_action">Afficher les détails de l’appareil</string>
|
<string name="device_settings_info_details_action">Afficher les détails de l’appareil</string>
|
||||||
<string name="device_settings_info_status_label">État</string>
|
<string name="device_settings_info_status_label">État</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Axustes xerais que afectan a toda a aplicación.</string>
|
<string name="settings_general_description">Axustes xerais que afectan a toda a aplicación.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos non coincidentes</string>
|
<string name="settings_overview_hide_unmatched_label">Ocultar dispositivos non coincidentes</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Non mostrar dispositivos próximos que non coincidan con ningún dos teus perfís na vista xeral, p. ex. os AirPods doutras persoas.</string>
|
<string name="settings_overview_hide_unmatched_description">Non mostrar dispositivos próximos que non coincidan con ningún dos teus perfís na vista xeral, p. ex. os AirPods doutras persoas.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Estimacións de batería</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Aprende como este dispositivo se carga e se descarrega para estimar o tempo restante, o tempo ata estar cargado e a saúde da batería.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Restablecer datos aprendidos</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Comezar de novo desde a vida útil da batería indicada.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Restablecer datos aprendidos?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod esquecerá a drenaxe medida deste dispositivo, a velocidade de carga e a saúde da batería, e comezará de novo desde a vida útil da batería indicada.</string>
|
||||||
<string name="settings_acknowledgements_label">Agradecementos</string>
|
<string name="settings_acknowledgements_label">Agradecementos</string>
|
||||||
<string name="settings_debug_autoreports_label">Informes de erros automáticos</string>
|
<string name="settings_debug_autoreports_label">Informes de erros automáticos</string>
|
||||||
<string name="settings_debug_autoreports_description">Informa automaticamente de problemas, p. ex., detalles sobre un fallo da aplicación para que poida descubrir como solucionalo.</string>
|
<string name="settings_debug_autoreports_description">Informa automaticamente de problemas, p. ex., detalles sobre un fallo da aplicación para que poida descubrir como solucionalo.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Transparencia de fondo</string>
|
<string name="widget_config_transparency_label">Transparencia de fondo</string>
|
||||||
<string name="widget_config_show_device_label">Mostrar nome do dispositivo</string>
|
<string name="widget_config_show_device_label">Mostrar nome do dispositivo</string>
|
||||||
<string name="widget_config_reset_label">Restablecer aos valores predeterminados</string>
|
<string name="widget_config_reset_label">Restablecer aos valores predeterminados</string>
|
||||||
<string name="widget_config_preview_resize_hint">Podes cambiar o tamaño do widget despois de colocalo na pantalla de inicio.</string>
|
<string name="widget_config_preview_resize_hint">Podes redimensionar o widget despois de colocalo na túa pantalla de inicio.</string>
|
||||||
<string name="widget_config_custom_label">Personalizado</string>
|
<string name="widget_config_custom_label">Personalizado</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Escuro</string>
|
<string name="widget_config_preset_dark">Escuro</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Visto por última vez: %s</string>
|
<string name="last_seen_x">Visto por última vez: %s</string>
|
||||||
<string name="first_seen_x">Visto por primeira vez: %s</string>
|
<string name="first_seen_x">Visto por primeira vez: %s</string>
|
||||||
<string name="battery_cached_label">Último coñecido · %s</string>
|
<string name="battery_cached_label">Último coñecido · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Mostrar notificacións</string>
|
<string name="permission_post_notifications_label">Mostrar notificacións</string>
|
||||||
<string name="permission_post_notifications_description">"Permitir que CAPod mostre notificacións sobre os teus AirPods, p. ex., o seu estado actual mentres están conectados."</string>
|
<string name="permission_post_notifications_description">"Permitir que CAPod mostre notificacións sobre os teus AirPods, p. ex., o seu estado actual mentres están conectados."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Número de serie do auricular dereito</string>
|
<string name="device_settings_info_right_serial_label">Número de serie do auricular dereito</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Esquerdo vinculado</string>
|
<string name="device_settings_info_left_bonded_label">Esquerdo vinculado</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Dereito vinculado</string>
|
<string name="device_settings_info_right_bonded_label">Dereito vinculado</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Saúde da batería (estimada)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Saúde da batería esquerda (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Saúde da batería dereita (est.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Aínda se está determinando, volve máis tarde</string>
|
||||||
<string name="device_settings_info_details_label">Detalles do dispositivo</string>
|
<string name="device_settings_info_details_label">Detalles do dispositivo</string>
|
||||||
<string name="device_settings_info_details_action">Mostrar detalles do dispositivo</string>
|
<string name="device_settings_info_details_action">Mostrar detalles do dispositivo</string>
|
||||||
<string name="device_settings_info_status_label">Estado</string>
|
<string name="device_settings_info_status_label">Estado</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">सामान्य समायोजन जो पूरे ऐप को प्रभावित करते हैं।</string>
|
<string name="settings_general_description">सामान्य समायोजन जो पूरे ऐप को प्रभावित करते हैं।</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">बेमेल डिवाइस छुपाएँ</string>
|
<string name="settings_overview_hide_unmatched_label">बेमेल डिवाइस छुपाएँ</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">ओवरव्यू में उन नज़दीकी डिवाइस न दिखाएँ जो आपकी किसी प्रोफ़ाइल से मेल नहीं खाते, जैसे दूसरों के AirPods।</string>
|
<string name="settings_overview_hide_unmatched_description">ओवरव्यू में उन नज़दीकी डिवाइस न दिखाएँ जो आपकी किसी प्रोफ़ाइल से मेल नहीं खाते, जैसे दूसरों के AirPods।</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">बैटरी अनुमान</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">यह सीखें कि यह डिवाइस कैसे चार्ज होता है और खत्म होता है ताकि शेष समय, चार्ज होने तक का समय और बैटरी स्वास्थ्य का अनुमान लगाया जा सके।</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">सीखे गए डेटा को रीसेट करें</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">रेटेड बैटरी जीवन से फिर से शुरू करें।</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">सीखे गए डेटा को रीसेट करें?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod इस डिवाइस की मापी गई खपत, चार्जिंग गति और बैटरी स्वास्थ्य को भूल जाएगा, और रेटेड बैटरी जीवन से फिर से शुरू करेगा।</string>
|
||||||
<string name="settings_acknowledgements_label">स्वीकृतियाँ</string>
|
<string name="settings_acknowledgements_label">स्वीकृतियाँ</string>
|
||||||
<string name="settings_debug_autoreports_label">स्वचालित बग रिपोर्ट</string>
|
<string name="settings_debug_autoreports_label">स्वचालित बग रिपोर्ट</string>
|
||||||
<string name="settings_debug_autoreports_description">समस्याओं की स्वचालित रूप से रिपोर्ट करता है, जैसे ऐप क्रैश पर विवरण ताकि मैं यह पता लगा सकूं कि इसे कैसे ठीक किया जाए।</string>
|
<string name="settings_debug_autoreports_description">समस्याओं की स्वचालित रूप से रिपोर्ट करता है, जैसे ऐप क्रैश पर विवरण ताकि मैं यह पता लगा सकूं कि इसे कैसे ठीक किया जाए।</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">पृष्ठभूमि पारदर्शिता</string>
|
<string name="widget_config_transparency_label">पृष्ठभूमि पारदर्शिता</string>
|
||||||
<string name="widget_config_show_device_label">डिवाइस का नाम दिखाएं</string>
|
<string name="widget_config_show_device_label">डिवाइस का नाम दिखाएं</string>
|
||||||
<string name="widget_config_reset_label">डिफ़ॉल्ट पर रीसेट करें</string>
|
<string name="widget_config_reset_label">डिफ़ॉल्ट पर रीसेट करें</string>
|
||||||
<string name="widget_config_preview_resize_hint">आप होम स्क्रीन पर रखने के बाद विजेट का आकार बदल सकते हैं।</string>
|
<string name="widget_config_preview_resize_hint">होम स्क्रीन पर विजेट रखने के बाद आप इसका आकार बदल सकते हैं।</string>
|
||||||
<string name="widget_config_custom_label">कस्टम</string>
|
<string name="widget_config_custom_label">कस्टम</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">गहरा</string>
|
<string name="widget_config_preset_dark">गहरा</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">अंतिम बार देखा: %s</string>
|
<string name="last_seen_x">अंतिम बार देखा: %s</string>
|
||||||
<string name="first_seen_x">पहली बार देखा: %s</string>
|
<string name="first_seen_x">पहली बार देखा: %s</string>
|
||||||
<string name="battery_cached_label">अंतिम ज्ञात · %s</string>
|
<string name="battery_cached_label">अंतिम ज्ञात · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">सूचनाएं दिखाएं</string>
|
<string name="permission_post_notifications_label">सूचनाएं दिखाएं</string>
|
||||||
<string name="permission_post_notifications_description">"CAPod को आपके AirPods के बारे में सूचनाएं दिखाने की अनुमति दें, जैसे कनेक्ट होने पर उनकी वर्तमान स्थिति।"</string>
|
<string name="permission_post_notifications_description">"CAPod को आपके AirPods के बारे में सूचनाएं दिखाने की अनुमति दें, जैसे कनेक्ट होने पर उनकी वर्तमान स्थिति।"</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">दाएं पॉड का सीरियल नंबर</string>
|
<string name="device_settings_info_right_serial_label">दाएं पॉड का सीरियल नंबर</string>
|
||||||
<string name="device_settings_info_left_bonded_label">बायाँ युग्मित</string>
|
<string name="device_settings_info_left_bonded_label">बायाँ युग्मित</string>
|
||||||
<string name="device_settings_info_right_bonded_label">दायाँ युग्मित</string>
|
<string name="device_settings_info_right_bonded_label">दायाँ युग्मित</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">बैटरी स्वास्थ्य (अनुमानित)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">बाईं बैटरी स्वास्थ्य (अनुमानित)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">दाईं बैटरी स्वास्थ्य (अनुमानित)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">अभी निर्धारण हो रहा है, बाद में फिर से जांचें</string>
|
||||||
<string name="device_settings_info_details_label">डिवाइस विवरण</string>
|
<string name="device_settings_info_details_label">डिवाइस विवरण</string>
|
||||||
<string name="device_settings_info_details_action">डिवाइस विवरण दिखाएं</string>
|
<string name="device_settings_info_details_action">डिवाइस विवरण दिखाएं</string>
|
||||||
<string name="device_settings_info_status_label">स्थिति</string>
|
<string name="device_settings_info_status_label">स्थिति</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Opće prilagodbe koje utječu na cijelu aplikaciju.</string>
|
<string name="settings_general_description">Opće prilagodbe koje utječu na cijelu aplikaciju.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Sakrij neprepoznate uređaje</string>
|
<string name="settings_overview_hide_unmatched_label">Sakrij neprepoznate uređaje</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Ne prikazuj obližnje uređaje koji ne odgovaraju nijednom od tvojih profila u pregledu, npr. AirPods-e drugih osoba.</string>
|
<string name="settings_overview_hide_unmatched_description">Ne prikazuj obližnje uređaje koji ne odgovaraju nijednom od tvojih profila u pregledu, npr. AirPods-e drugih osoba.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Procjene baterije</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Saznajte kako se ovaj uređaj puni i prazni kako bi se procijenilo preostalo vrijeme, vrijeme do punjenja i zdravlje baterije.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Obriši naučene podatke</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Započnite ponovno od ocijenjene životnosti baterije.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Obriši naučene podatke?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod će zaboraviti izmjereno pražnjenje ovog uređaja, brzinu punjenja i zdravlje baterije, te početi ponovno od ocijenjene životnosti baterije.</string>
|
||||||
<string name="settings_acknowledgements_label">Zahvale</string>
|
<string name="settings_acknowledgements_label">Zahvale</string>
|
||||||
<string name="settings_debug_autoreports_label">Automatska izvješća o pogreškama</string>
|
<string name="settings_debug_autoreports_label">Automatska izvješća o pogreškama</string>
|
||||||
<string name="settings_debug_autoreports_description">Automatski prijavljuje probleme, npr. detalje o rušenju aplikacije kako bih mogao shvatiti kako to popraviti.</string>
|
<string name="settings_debug_autoreports_description">Automatski prijavljuje probleme, npr. detalje o rušenju aplikacije kako bih mogao shvatiti kako to popraviti.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Prozirnost pozadine</string>
|
<string name="widget_config_transparency_label">Prozirnost pozadine</string>
|
||||||
<string name="widget_config_show_device_label">Prikaži naziv uređaja</string>
|
<string name="widget_config_show_device_label">Prikaži naziv uređaja</string>
|
||||||
<string name="widget_config_reset_label">Vrati na zadane vrijednosti</string>
|
<string name="widget_config_reset_label">Vrati na zadane vrijednosti</string>
|
||||||
<string name="widget_config_preview_resize_hint">Možete promijeniti veličinu widgeta nakon što ga postavite na početni zaslon.</string>
|
<string name="widget_config_preview_resize_hint">Možete promijeniti veličinu widgeta nakon postavljanja na početni zaslon.</string>
|
||||||
<string name="widget_config_custom_label">Prilagođeno</string>
|
<string name="widget_config_custom_label">Prilagođeno</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Tamno</string>
|
<string name="widget_config_preset_dark">Tamno</string>
|
||||||
@@ -280,6 +286,9 @@
|
|||||||
<string name="last_seen_x">Zadnji put viđeno: %s</string>
|
<string name="last_seen_x">Zadnji put viđeno: %s</string>
|
||||||
<string name="first_seen_x">Prvi put viđeno: %s</string>
|
<string name="first_seen_x">Prvi put viđeno: %s</string>
|
||||||
<string name="battery_cached_label">Zadnje poznato · %s</string>
|
<string name="battery_cached_label">Zadnje poznato · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Prikaži obavijesti</string>
|
<string name="permission_post_notifications_label">Prikaži obavijesti</string>
|
||||||
<string name="permission_post_notifications_description">"Dopustite CAPod-u prikazivanje obavijesti o vašim AirPods, npr. njihov trenutni status dok su povezane."</string>
|
<string name="permission_post_notifications_description">"Dopustite CAPod-u prikazivanje obavijesti o vašim AirPods, npr. njihov trenutni status dok su povezane."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -428,6 +437,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Serijski broj desnog poda</string>
|
<string name="device_settings_info_right_serial_label">Serijski broj desnog poda</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Lijevo vezano</string>
|
<string name="device_settings_info_left_bonded_label">Lijevo vezano</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Desno vezano</string>
|
<string name="device_settings_info_right_bonded_label">Desno vezano</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Zdravlje baterije (procijenjena)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Zdravlje lijeve baterije (proc.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Zdravlje desne baterije (proc.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Još se određuje, provjeri ponovno kasnije</string>
|
||||||
<string name="device_settings_info_details_label">Detalji uređaja</string>
|
<string name="device_settings_info_details_label">Detalji uređaja</string>
|
||||||
<string name="device_settings_info_details_action">Prikaži detalje uređaja</string>
|
<string name="device_settings_info_details_action">Prikaži detalje uređaja</string>
|
||||||
<string name="device_settings_info_status_label">Stanje</string>
|
<string name="device_settings_info_status_label">Stanje</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Általános módosítások, amelyek az egész alkalmazást érintik.</string>
|
<string name="settings_general_description">Általános módosítások, amelyek az egész alkalmazást érintik.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Párosítatlan eszközök elrejtése</string>
|
<string name="settings_overview_hide_unmatched_label">Párosítatlan eszközök elrejtése</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Ne jelenjenek meg az áttekintőben azok a közeli eszközök, amelyek nem egyeznek egy profilodhoz sem, pl. mások AirPodjai.</string>
|
<string name="settings_overview_hide_unmatched_description">Ne jelenjenek meg az áttekintőben azok a közeli eszközök, amelyek nem egyeznek egy profilodhoz sem, pl. mások AirPodjai.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Akkumulátor becslések</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Ismerje meg, hogyan töltődik és merül az eszköz, hogy megbecsülhesse a hátralévő időt, a töltésig tartó időt és az akkumulátor állapotát.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Megtanult adatok alaphelyzetbe állítása</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Kezdje újra a névleges akkumulátor-élettartamtól.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Megtanult adatok alaphelyzetbe állítása?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">A CAPod elfelejti az eszköz mért fogyasztását, töltési sebességét és akkumulátor állapotát, és a névleges akkumulátor-élettartamtól kezdi újra.</string>
|
||||||
<string name="settings_acknowledgements_label">Köszönetnyilvánítás</string>
|
<string name="settings_acknowledgements_label">Köszönetnyilvánítás</string>
|
||||||
<string name="settings_debug_autoreports_label">Automatikus hibajelentések</string>
|
<string name="settings_debug_autoreports_label">Automatikus hibajelentések</string>
|
||||||
<string name="settings_debug_autoreports_description">Automatikusan jelenti a problémákat, például részleteket az alkalmazás összeomlásáról, hogy kitaláljam, hogyan javíthatom ki.</string>
|
<string name="settings_debug_autoreports_description">Automatikusan jelenti a problémákat, például részleteket az alkalmazás összeomlásáról, hogy kitaláljam, hogyan javíthatom ki.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Háttér átlátszósága</string>
|
<string name="widget_config_transparency_label">Háttér átlátszósága</string>
|
||||||
<string name="widget_config_show_device_label">Eszköznév megjelenítése</string>
|
<string name="widget_config_show_device_label">Eszköznév megjelenítése</string>
|
||||||
<string name="widget_config_reset_label">Alapértékek visszaállítása</string>
|
<string name="widget_config_reset_label">Alapértékek visszaállítása</string>
|
||||||
<string name="widget_config_preview_resize_hint">A widgetet átméretezheted, miután elhelyezted a kezdőképernyőn.</string>
|
<string name="widget_config_preview_resize_hint">A widgetet az otthoni képernyőre helyezés után átméretezhet.</string>
|
||||||
<string name="widget_config_custom_label">Egyéni</string>
|
<string name="widget_config_custom_label">Egyéni</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Sötét</string>
|
<string name="widget_config_preset_dark">Sötét</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Utoljára látva: %s</string>
|
<string name="last_seen_x">Utoljára látva: %s</string>
|
||||||
<string name="first_seen_x">Először látva: %s</string>
|
<string name="first_seen_x">Először látva: %s</string>
|
||||||
<string name="battery_cached_label">Utoljára ismert · %s</string>
|
<string name="battery_cached_label">Utoljára ismert · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dó %2$dp</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dó</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dp</string>
|
||||||
<string name="permission_post_notifications_label">Értesítések megjelenítése</string>
|
<string name="permission_post_notifications_label">Értesítések megjelenítése</string>
|
||||||
<string name="permission_post_notifications_description">"Engedélyezze a CAPod-nak, hogy értesítéseket jelenítsen meg az AirPods-ról, pl. aktuális állapotuk csatlakozás közben."</string>
|
<string name="permission_post_notifications_description">"Engedélyezze a CAPod-nak, hogy értesítéseket jelenítsen meg az AirPods-ról, pl. aktuális állapotuk csatlakozás közben."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Jobb fülhallgató sorozatszáma</string>
|
<string name="device_settings_info_right_serial_label">Jobb fülhallgató sorozatszáma</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Bal fülhallgató párosítva</string>
|
<string name="device_settings_info_left_bonded_label">Bal fülhallgató párosítva</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Jobb fülhallgató párosítva</string>
|
<string name="device_settings_info_right_bonded_label">Jobb fülhallgató párosítva</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Akkumulátor egészsége (becsült)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Bal akkumulátor egészsége (becsült)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Jobb akkumulátor egészsége (becsült)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Még megállapítás alatt, később nézzen vissza</string>
|
||||||
<string name="device_settings_info_details_label">Eszköz részletei</string>
|
<string name="device_settings_info_details_label">Eszköz részletei</string>
|
||||||
<string name="device_settings_info_details_action">Eszköz részleteinek megjelenítése</string>
|
<string name="device_settings_info_details_action">Eszköz részleteinek megjelenítése</string>
|
||||||
<string name="device_settings_info_status_label">Állapot</string>
|
<string name="device_settings_info_status_label">Állapot</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Ընդհանուր կարգավորումներ, որոնք ազդում են ամբողջ հավելվածի վրա։</string>
|
<string name="settings_general_description">Ընդհանուր կարգավորումներ, որոնք ազդում են ամբողջ հավելվածի վրա։</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Թաքցնել անհամապատասխան սարքերը</string>
|
<string name="settings_overview_hide_unmatched_label">Թաքցնել անհամապատասխան սարքերը</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Չցուցադրել մոտակա սարքերը, որոնք չեն համընկնում ձեր պրոֆիլներից որևէ մեկի հետ, օրինակ՝ ուրիշի AirPods-ները:</string>
|
<string name="settings_overview_hide_unmatched_description">Չցուցադրել մոտակա սարքերը, որոնք չեն համընկնում ձեր պրոֆիլներից որևէ մեկի հետ, օրինակ՝ ուրիշի AirPods-ները:</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Մարտկոցի գնահատականներ</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Իմացեք, թե ինչպես է այս սարքը լիցքավորվում և լիցքազրկվում՝ մնացած ժամանակը, լիցքավորման ժամանակը և մարտկոցի առողջությունը գնահատելու համար:</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Վերականգնել սովորած տվյալները</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Նորից սկսել գնահատված մարտկոցի կյանքից:</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Վերականգնել սովորած տվյալները?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod-ը կմոռանա այս սարքի չափված լիցքազրկումը, լիցքավորման արագությունը և մարտկոցի առողջությունը, և կսկսի նորից գնահատված մարտկոցի կյանքից:</string>
|
||||||
<string name="settings_acknowledgements_label">Շնորհակալագրեր</string>
|
<string name="settings_acknowledgements_label">Շնորհակալագրեր</string>
|
||||||
<string name="settings_debug_autoreports_label">Սխալների ավտոմատ հաշվետվություններ</string>
|
<string name="settings_debug_autoreports_label">Սխալների ավտոմատ հաշվետվություններ</string>
|
||||||
<string name="settings_debug_autoreports_description">Ավտոմատ կերպով հաղորդում է խնդիրների մասին, օրինակ՝ հավելվածի խափանման մանրամասները, որպեսզի կարողանամ պարզել, թե ինչպես այն շտկել։</string>
|
<string name="settings_debug_autoreports_description">Ավտոմատ կերպով հաղորդում է խնդիրների մասին, օրինակ՝ հավելվածի խափանման մանրամասները, որպեսզի կարողանամ պարզել, թե ինչպես այն շտկել։</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Ֆոնի թափանցիկություն</string>
|
<string name="widget_config_transparency_label">Ֆոնի թափանցիկություն</string>
|
||||||
<string name="widget_config_show_device_label">Ցուցադրել սարքի անունը</string>
|
<string name="widget_config_show_device_label">Ցուցադրել սարքի անունը</string>
|
||||||
<string name="widget_config_reset_label">Վերականգնել լռելյալը</string>
|
<string name="widget_config_reset_label">Վերականգնել լռելյալը</string>
|
||||||
<string name="widget_config_preview_resize_hint">Դուք կարող եք փոխել վիջեթի չափը այն ձեր գլխավոր էկրանին տեղադրելուց հետո:</string>
|
<string name="widget_config_preview_resize_hint">Կարող եք վիջեթի չափը փոխել այն տեղադրելուց հետո:</string>
|
||||||
<string name="widget_config_custom_label">Հատուկ</string>
|
<string name="widget_config_custom_label">Հատուկ</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Մութ</string>
|
<string name="widget_config_preset_dark">Մութ</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Վերջին անգամ տեսնված՝ %s</string>
|
<string name="last_seen_x">Վերջին անգամ տեսնված՝ %s</string>
|
||||||
<string name="first_seen_x">Առաջին անգամ տեսնված՝ %s</string>
|
<string name="first_seen_x">Առաջին անգամ տեսնված՝ %s</string>
|
||||||
<string name="battery_cached_label">Վերջին հայտնի · %s</string>
|
<string name="battery_cached_label">Վերջին հայտնի · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dժ %2$dր</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dժ</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dր</string>
|
||||||
<string name="permission_post_notifications_label">Ցուցադրել ծանուցումներ</string>
|
<string name="permission_post_notifications_label">Ցուցադրել ծանուցումներ</string>
|
||||||
<string name="permission_post_notifications_description">"Թույլ տվեք CAPod-ին ցուցադրել ծանուցումներ ձեր AirPods-ների մասին, օրինակ՝ դրանց ընթացիկ կարգավիճակը՝ երբ միացված են։"</string>
|
<string name="permission_post_notifications_description">"Թույլ տվեք CAPod-ին ցուցադրել ծանուցումներ ձեր AirPods-ների մասին, օրինակ՝ դրանց ընթացիկ կարգավիճակը՝ երբ միացված են։"</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Աջ ականջակալի սերիա</string>
|
<string name="device_settings_info_right_serial_label">Աջ ականջակալի սերիա</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Ձախ կապակցված</string>
|
<string name="device_settings_info_left_bonded_label">Ձախ կապակցված</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Աջ կապակցված</string>
|
<string name="device_settings_info_right_bonded_label">Աջ կապակցված</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Մարտկոցի առողջություն (գնահատված)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Ձախ մարտկոցի առողջություն (գն.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Աջ մարտկոցի առողջություն (գն.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Դեռ որոշվում է, ավելի ուշ կրկին ստուգեք</string>
|
||||||
<string name="device_settings_info_details_label">Սարքի մանրամասները</string>
|
<string name="device_settings_info_details_label">Սարքի մանրամասները</string>
|
||||||
<string name="device_settings_info_details_action">Ցուցադրել սարքի մանրամասները</string>
|
<string name="device_settings_info_details_action">Ցուցադրել սարքի մանրամասները</string>
|
||||||
<string name="device_settings_info_status_label">Կարգավիճակ</string>
|
<string name="device_settings_info_status_label">Կարգավիճակ</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Penyesuaian umum yang memengaruhi seluruh aplikasi.</string>
|
<string name="settings_general_description">Penyesuaian umum yang memengaruhi seluruh aplikasi.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Sembunyikan perangkat yang tidak cocok</string>
|
<string name="settings_overview_hide_unmatched_label">Sembunyikan perangkat yang tidak cocok</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Jangan tampilkan perangkat terdekat yang tidak cocok dengan profil mana pun di ringkasan, misalnya AirPods milik orang lain.</string>
|
<string name="settings_overview_hide_unmatched_description">Jangan tampilkan perangkat terdekat yang tidak cocok dengan profil mana pun di ringkasan, misalnya AirPods milik orang lain.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Perkiraan Baterai</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Pelajari cara perangkat ini diisi daya dan dihabiskan untuk memperkirakan waktu yang tersisa, waktu hingga terisi penuh, dan kesehatan baterai.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Setel Ulang Data Terbelajar</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Mulai dari awal dengan daya tahan baterai yang dinilai.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Setel Ulang Data Terbelajar?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod akan melupakan pengurasan, kecepatan pengisian, dan kesehatan baterai perangkat yang telah diukur, serta memulai dari awal dengan daya tahan baterai yang dinilai.</string>
|
||||||
<string name="settings_acknowledgements_label">Ucapan Terima Kasih</string>
|
<string name="settings_acknowledgements_label">Ucapan Terima Kasih</string>
|
||||||
<string name="settings_debug_autoreports_label">Laporan bug otomatis</string>
|
<string name="settings_debug_autoreports_label">Laporan bug otomatis</string>
|
||||||
<string name="settings_debug_autoreports_description">Melaporkan masalah secara otomatis, mis. detail tentang kerusakan aplikasi sehingga saya dapat mencari tahu cara memperbaikinya.</string>
|
<string name="settings_debug_autoreports_description">Melaporkan masalah secara otomatis, mis. detail tentang kerusakan aplikasi sehingga saya dapat mencari tahu cara memperbaikinya.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Transparansi latar belakang</string>
|
<string name="widget_config_transparency_label">Transparansi latar belakang</string>
|
||||||
<string name="widget_config_show_device_label">Tampilkan nama perangkat</string>
|
<string name="widget_config_show_device_label">Tampilkan nama perangkat</string>
|
||||||
<string name="widget_config_reset_label">Atur ulang ke default</string>
|
<string name="widget_config_reset_label">Atur ulang ke default</string>
|
||||||
<string name="widget_config_preview_resize_hint">Anda dapat mengubah ukuran widget setelah menempatkannya di layar beranda.</string>
|
<string name="widget_config_preview_resize_hint">Anda dapat mengubah ukuran widget setelah menempatkannya di layar utama.</string>
|
||||||
<string name="widget_config_custom_label">Kustom</string>
|
<string name="widget_config_custom_label">Kustom</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Gelap</string>
|
<string name="widget_config_preset_dark">Gelap</string>
|
||||||
@@ -276,6 +282,9 @@
|
|||||||
<string name="last_seen_x">Terakhir terlihat: %s</string>
|
<string name="last_seen_x">Terakhir terlihat: %s</string>
|
||||||
<string name="first_seen_x">Pertama terlihat: %s</string>
|
<string name="first_seen_x">Pertama terlihat: %s</string>
|
||||||
<string name="battery_cached_label">Terakhir diketahui · %s</string>
|
<string name="battery_cached_label">Terakhir diketahui · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dj %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dj</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Tampilkan notifikasi</string>
|
<string name="permission_post_notifications_label">Tampilkan notifikasi</string>
|
||||||
<string name="permission_post_notifications_description">"Izinkan CAPod menampilkan notifikasi tentang AirPods Anda, mis. status saat ini saat terhubung."</string>
|
<string name="permission_post_notifications_description">"Izinkan CAPod menampilkan notifikasi tentang AirPods Anda, mis. status saat ini saat terhubung."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -418,6 +427,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Nomor Seri Pod Kanan</string>
|
<string name="device_settings_info_right_serial_label">Nomor Seri Pod Kanan</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Kiri Terhubung</string>
|
<string name="device_settings_info_left_bonded_label">Kiri Terhubung</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Kanan Terhubung</string>
|
<string name="device_settings_info_right_bonded_label">Kanan Terhubung</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Kesehatan Baterai (diperkirakan)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Kesehatan Baterai Kiri (perkiraan)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Kesehatan Baterai Kanan (perkiraan)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Sedang ditentukan, periksa kembali nanti</string>
|
||||||
<string name="device_settings_info_details_label">Detail Perangkat</string>
|
<string name="device_settings_info_details_label">Detail Perangkat</string>
|
||||||
<string name="device_settings_info_details_action">Tampilkan detail perangkat</string>
|
<string name="device_settings_info_details_action">Tampilkan detail perangkat</string>
|
||||||
<string name="device_settings_info_status_label">Status</string>
|
<string name="device_settings_info_status_label">Status</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Almennar stillingar sem hafa áhrif á allt forritið.</string>
|
<string name="settings_general_description">Almennar stillingar sem hafa áhrif á allt forritið.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Fela ópassandi tæki</string>
|
<string name="settings_overview_hide_unmatched_label">Fela ópassandi tæki</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Sýnir ekki nálæg tæki sem passa ekki við neitt af sniðunum þínum í yfirlitinu, t.d. AirPods annarra.</string>
|
<string name="settings_overview_hide_unmatched_description">Sýnir ekki nálæg tæki sem passa ekki við neitt af sniðunum þínum í yfirlitinu, t.d. AirPods annarra.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Rafhlöðuáætlanir</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Lærðu hvernig þetta tæki hleðst og eyðir orku til að áætla eftirstandandi tíma, tíma þar til fullt er og heilsu rafhlöðu.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Endurstilla lærð gögn</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Byrja upp á nýtt frá tilgreindri rafhlöðuendingu.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Endurstilla lærð gögn?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod mun gleyma mældum gangi, hleðsluhraða og heilsu rafhlöðu þessa tækis og byrja upp á nýtt frá tilgreindri rafhlöðuendingu.</string>
|
||||||
<string name="settings_acknowledgements_label">Þakkir</string>
|
<string name="settings_acknowledgements_label">Þakkir</string>
|
||||||
<string name="settings_debug_autoreports_label">Sjálfvirkar villuskýrslur</string>
|
<string name="settings_debug_autoreports_label">Sjálfvirkar villuskýrslur</string>
|
||||||
<string name="settings_debug_autoreports_description">Tilkynnir sjálfkrafa um vandamál, t.d. upplýsingar um hrun forrits svo ég geti fundið út hvernig á að laga það.</string>
|
<string name="settings_debug_autoreports_description">Tilkynnir sjálfkrafa um vandamál, t.d. upplýsingar um hrun forrits svo ég geti fundið út hvernig á að laga það.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Gagnsæi bakgrunns</string>
|
<string name="widget_config_transparency_label">Gagnsæi bakgrunns</string>
|
||||||
<string name="widget_config_show_device_label">Sýna heiti tækis</string>
|
<string name="widget_config_show_device_label">Sýna heiti tækis</string>
|
||||||
<string name="widget_config_reset_label">Endurstilla sjálfgildi</string>
|
<string name="widget_config_reset_label">Endurstilla sjálfgildi</string>
|
||||||
<string name="widget_config_preview_resize_hint">Þú getur breytt stærð græjunnar eftir að þú hefur sett hana á heimaskjáinn þinn.</string>
|
<string name="widget_config_preview_resize_hint">Þú getur breytt stærð græjunnar eftir að hafa sett hana á heimaskjá þinn.</string>
|
||||||
<string name="widget_config_custom_label">Sérsniðið</string>
|
<string name="widget_config_custom_label">Sérsniðið</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Dökkt</string>
|
<string name="widget_config_preset_dark">Dökkt</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Síðast séð: %s</string>
|
<string name="last_seen_x">Síðast séð: %s</string>
|
||||||
<string name="first_seen_x">Fyrst séð: %s</string>
|
<string name="first_seen_x">Fyrst séð: %s</string>
|
||||||
<string name="battery_cached_label">Síðast þekkt · %s</string>
|
<string name="battery_cached_label">Síðast þekkt · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Sýna tilkynningar</string>
|
<string name="permission_post_notifications_label">Sýna tilkynningar</string>
|
||||||
<string name="permission_post_notifications_description">"Leyfðu CAPod að sýna tilkynningar um AirPods, t.d. núverandi stöðu þeirra á meðan þau eru tengd."</string>
|
<string name="permission_post_notifications_description">"Leyfðu CAPod að sýna tilkynningar um AirPods, t.d. núverandi stöðu þeirra á meðan þau eru tengd."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Raðnúmer hægri hlustunarpípu</string>
|
<string name="device_settings_info_right_serial_label">Raðnúmer hægri hlustunarpípu</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Vinstri tengd</string>
|
<string name="device_settings_info_left_bonded_label">Vinstri tengd</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Hægri tengd</string>
|
<string name="device_settings_info_right_bonded_label">Hægri tengd</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Heilsa rafhlöðu (áætluð)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Heilsa vinstrar rafhlöðu (áætl.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Heilsa hægrar rafhlöðu (áætl.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Enn er verið að reikna, athugaðu seinna</string>
|
||||||
<string name="device_settings_info_details_label">Tækjaupplýsingar</string>
|
<string name="device_settings_info_details_label">Tækjaupplýsingar</string>
|
||||||
<string name="device_settings_info_details_action">Sýna tækjaupplýsingar</string>
|
<string name="device_settings_info_details_action">Sýna tækjaupplýsingar</string>
|
||||||
<string name="device_settings_info_status_label">Staða</string>
|
<string name="device_settings_info_status_label">Staða</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">Modifiche generali che interessano l\'intera app.</string>
|
<string name="settings_general_description">Modifiche generali che interessano l\'intera app.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">Nascondi dispositivi non abbinati</string>
|
<string name="settings_overview_hide_unmatched_label">Nascondi dispositivi non abbinati</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">Non mostrare nella panoramica i dispositivi nelle vicinanze che non corrispondono a nessuno dei tuoi profili, ad es. gli AirPods di altre persone.</string>
|
<string name="settings_overview_hide_unmatched_description">Non mostrare nella panoramica i dispositivi nelle vicinanze che non corrispondono a nessuno dei tuoi profili, ad es. gli AirPods di altre persone.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">Stime della batteria</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">Scopri come questo dispositivo si carica e si scarica per stimare il tempo rimasto, il tempo fino alla carica completa e lo stato della batteria.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">Reimposta dati acquisiti</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">Ricomincia dalla durata della batteria dichiarata.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">Reimpostare i dati acquisiti?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod dimenticherà il consumo misurato del dispositivo, la velocità di ricarica e lo stato della batteria, e ricomincerà dalla durata della batteria dichiarata.</string>
|
||||||
<string name="settings_acknowledgements_label">Riconoscimenti</string>
|
<string name="settings_acknowledgements_label">Riconoscimenti</string>
|
||||||
<string name="settings_debug_autoreports_label">Segnalazioni automatiche di bug</string>
|
<string name="settings_debug_autoreports_label">Segnalazioni automatiche di bug</string>
|
||||||
<string name="settings_debug_autoreports_description">Segnala automaticamente i problemi, ad es. dettagli su un arresto anomalo dell\'app in modo che io possa capire come risolverlo.</string>
|
<string name="settings_debug_autoreports_description">Segnala automaticamente i problemi, ad es. dettagli su un arresto anomalo dell\'app in modo che io possa capire come risolverlo.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">Trasparenza sfondo</string>
|
<string name="widget_config_transparency_label">Trasparenza sfondo</string>
|
||||||
<string name="widget_config_show_device_label">Mostra nome dispositivo</string>
|
<string name="widget_config_show_device_label">Mostra nome dispositivo</string>
|
||||||
<string name="widget_config_reset_label">Ripristina predefiniti</string>
|
<string name="widget_config_reset_label">Ripristina predefiniti</string>
|
||||||
<string name="widget_config_preview_resize_hint">Puoi ridimensionare il widget dopo averlo posizionato sulla schermata Home.</string>
|
<string name="widget_config_preview_resize_hint">Puoi ridimensionare il widget dopo averlo posizionato sulla schermata iniziale.</string>
|
||||||
<string name="widget_config_custom_label">Personalizzato</string>
|
<string name="widget_config_custom_label">Personalizzato</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">Scuro</string>
|
<string name="widget_config_preset_dark">Scuro</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">Visto l\'ultima volta: %s</string>
|
<string name="last_seen_x">Visto l\'ultima volta: %s</string>
|
||||||
<string name="first_seen_x">Visto la prima volta: %s</string>
|
<string name="first_seen_x">Visto la prima volta: %s</string>
|
||||||
<string name="battery_cached_label">Ultimo noto · %s</string>
|
<string name="battery_cached_label">Ultimo noto · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||||
<string name="permission_post_notifications_label">Mostra notifiche</string>
|
<string name="permission_post_notifications_label">Mostra notifiche</string>
|
||||||
<string name="permission_post_notifications_description">"Consenti a CAPod di mostrare notifiche sui tuoi AirPods, ad es. il loro stato attuale mentre sono connessi."</string>
|
<string name="permission_post_notifications_description">"Consenti a CAPod di mostrare notifiche sui tuoi AirPods, ad es. il loro stato attuale mentre sono connessi."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">Seriale pod destro</string>
|
<string name="device_settings_info_right_serial_label">Seriale pod destro</string>
|
||||||
<string name="device_settings_info_left_bonded_label">Sinistra associata</string>
|
<string name="device_settings_info_left_bonded_label">Sinistra associata</string>
|
||||||
<string name="device_settings_info_right_bonded_label">Destra associata</string>
|
<string name="device_settings_info_right_bonded_label">Destra associata</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">Salute della batteria (stimata)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">Salute della batteria sinistra (stim.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">Salute della batteria destra (stim.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">Determinazione in corso, controlla più tardi</string>
|
||||||
<string name="device_settings_info_details_label">Dettagli dispositivo</string>
|
<string name="device_settings_info_details_label">Dettagli dispositivo</string>
|
||||||
<string name="device_settings_info_details_action">Mostra dettagli dispositivo</string>
|
<string name="device_settings_info_details_action">Mostra dettagli dispositivo</string>
|
||||||
<string name="device_settings_info_status_label">Stato</string>
|
<string name="device_settings_info_status_label">Stato</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">שינויים כלליים שמשפיעים על האפליקציה כולה.</string>
|
<string name="settings_general_description">שינויים כלליים שמשפיעים על האפליקציה כולה.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">הסתר מכשירים שאינם תואמים</string>
|
<string name="settings_overview_hide_unmatched_label">הסתר מכשירים שאינם תואמים</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">אל תציג מכשירים קרובים שאינם תואמים אף אחד מהפרופילים שלך בתצוגה הכללית, למשל AirPods של אנשים אחרים.</string>
|
<string name="settings_overview_hide_unmatched_description">אל תציג מכשירים קרובים שאינם תואמים אף אחד מהפרופילים שלך בתצוגה הכללית, למשל AirPods של אנשים אחרים.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">הערכות סוללה</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">למד כיצד הציוד הזה טוען ומתרוקן כדי להעריך את הזמן הנותר, הזמן עד לטעינה מלאה ובריאות הסוללה.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">איפוס נתונים שנלמדו</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">התחל מחדש מחיי הסוללה המדורגים.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">לאפס את הנתונים שנלמדו?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod תשכח את ניקוז הציוד שנמדד, מהירות הטעינה ובריאות הסוללה, והתחלה מחדש מחיי הסוללה המדורגים.</string>
|
||||||
<string name="settings_acknowledgements_label">תודות</string>
|
<string name="settings_acknowledgements_label">תודות</string>
|
||||||
<string name="settings_debug_autoreports_label">דוחות באגים אוטומטיים</string>
|
<string name="settings_debug_autoreports_label">דוחות באגים אוטומטיים</string>
|
||||||
<string name="settings_debug_autoreports_description">מדווח אוטומטית על בעיות, למשל, פרטים על קריסת אפליקציה כדי שאוכל להבין איך לתקן את זה.</string>
|
<string name="settings_debug_autoreports_description">מדווח אוטומטית על בעיות, למשל, פרטים על קריסת אפליקציה כדי שאוכל להבין איך לתקן את זה.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">שקיפות רקע</string>
|
<string name="widget_config_transparency_label">שקיפות רקע</string>
|
||||||
<string name="widget_config_show_device_label">הצג שם התקן</string>
|
<string name="widget_config_show_device_label">הצג שם התקן</string>
|
||||||
<string name="widget_config_reset_label">אפס לברירות המחדל</string>
|
<string name="widget_config_reset_label">אפס לברירות המחדל</string>
|
||||||
<string name="widget_config_preview_resize_hint">ניתן לשנות את גודל הווידג\'ט לאחר הצבתו על מסך הבית שלך.</string>
|
<string name="widget_config_preview_resize_hint">אתה יכול לשנות את גודל הווידג\'ט לאחר הצבתו במסך הבית שלך.</string>
|
||||||
<string name="widget_config_custom_label">מותאם אישית</string>
|
<string name="widget_config_custom_label">מותאם אישית</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">כהה</string>
|
<string name="widget_config_preset_dark">כהה</string>
|
||||||
@@ -282,6 +288,9 @@
|
|||||||
<string name="last_seen_x">נראה לאחרונה: %s</string>
|
<string name="last_seen_x">נראה לאחרונה: %s</string>
|
||||||
<string name="first_seen_x">נראה לראשונה: %s</string>
|
<string name="first_seen_x">נראה לראשונה: %s</string>
|
||||||
<string name="battery_cached_label">אחרון ידוע · %s</string>
|
<string name="battery_cached_label">אחרון ידוע · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dש %2$dד</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dש</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dד</string>
|
||||||
<string name="permission_post_notifications_label">הצגת התראות</string>
|
<string name="permission_post_notifications_label">הצגת התראות</string>
|
||||||
<string name="permission_post_notifications_description">"אפשר ל-CAPod להציג התראות על ה-AirPods שלך, למשל המצב הנוכחי שלהם בזמן חיבור."</string>
|
<string name="permission_post_notifications_description">"אפשר ל-CAPod להציג התראות על ה-AirPods שלך, למשל המצב הנוכחי שלהם בזמן חיבור."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -433,6 +442,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">מספר סידורי ימין</string>
|
<string name="device_settings_info_right_serial_label">מספר סידורי ימין</string>
|
||||||
<string name="device_settings_info_left_bonded_label">שמאל מחובר</string>
|
<string name="device_settings_info_left_bonded_label">שמאל מחובר</string>
|
||||||
<string name="device_settings_info_right_bonded_label">ימין מחובר</string>
|
<string name="device_settings_info_right_bonded_label">ימין מחובר</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">בריאות הסוללה (משוערת)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">בריאות סוללה שמאלה (משוערת)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">בריאות סוללה ימינה (משוערת)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">עדיין בקביעה, בדוק שוב מאוחר יותר</string>
|
||||||
<string name="device_settings_info_details_label">פרטי התקן</string>
|
<string name="device_settings_info_details_label">פרטי התקן</string>
|
||||||
<string name="device_settings_info_details_action">הצג פרטי התקן</string>
|
<string name="device_settings_info_details_action">הצג פרטי התקן</string>
|
||||||
<string name="device_settings_info_status_label">מצב</string>
|
<string name="device_settings_info_status_label">מצב</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">アプリ全体に影響する一般的な調整。</string>
|
<string name="settings_general_description">アプリ全体に影響する一般的な調整。</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">一致しないデバイスを非表示</string>
|
<string name="settings_overview_hide_unmatched_label">一致しないデバイスを非表示</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">プロフィールに一致しない近くのデバイス(他の人のAirPodsなど)を概要に表示しません。</string>
|
<string name="settings_overview_hide_unmatched_description">プロフィールに一致しない近くのデバイス(他の人のAirPodsなど)を概要に表示しません。</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">バッテリー推定値</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">このデバイスの充電・放電パターンを学習して、残り時間、充電完了までの時間、バッテリーの状態を推定します。</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">学習データをリセット</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">定格バッテリー寿命からやり直します。</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">学習データをリセットしますか?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPodはこのデバイスの測定済み放電、充電速度、バッテリーの状態を忘れて、定格バッテリー寿命からやり直します。</string>
|
||||||
<string name="settings_acknowledgements_label">謝辞</string>
|
<string name="settings_acknowledgements_label">謝辞</string>
|
||||||
<string name="settings_debug_autoreports_label">自動バグレポート</string>
|
<string name="settings_debug_autoreports_label">自動バグレポート</string>
|
||||||
<string name="settings_debug_autoreports_description">アプリのクラッシュに関する詳細など、問題を自動的に報告して、修正方法を把握できるようにします。</string>
|
<string name="settings_debug_autoreports_description">アプリのクラッシュに関する詳細など、問題を自動的に報告して、修正方法を把握できるようにします。</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">背景の透明度</string>
|
<string name="widget_config_transparency_label">背景の透明度</string>
|
||||||
<string name="widget_config_show_device_label">デバイス名を表示</string>
|
<string name="widget_config_show_device_label">デバイス名を表示</string>
|
||||||
<string name="widget_config_reset_label">デフォルトにリセット</string>
|
<string name="widget_config_reset_label">デフォルトにリセット</string>
|
||||||
<string name="widget_config_preview_resize_hint">ホーム画面に配置した後、ウィジェットのサイズを変更できます。</string>
|
<string name="widget_config_preview_resize_hint">ホーム画面に配置した後、ウィジェットをリサイズできます。</string>
|
||||||
<string name="widget_config_custom_label">カスタム</string>
|
<string name="widget_config_custom_label">カスタム</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">ダーク</string>
|
<string name="widget_config_preset_dark">ダーク</string>
|
||||||
@@ -276,6 +282,9 @@
|
|||||||
<string name="last_seen_x">最終確認: %s</string>
|
<string name="last_seen_x">最終確認: %s</string>
|
||||||
<string name="first_seen_x">初回確認: %s</string>
|
<string name="first_seen_x">初回確認: %s</string>
|
||||||
<string name="battery_cached_label">最後の既知の位置 · %s</string>
|
<string name="battery_cached_label">最後の既知の位置 · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$d時間%2$d分</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$d時間</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$d分</string>
|
||||||
<string name="permission_post_notifications_label">通知を表示</string>
|
<string name="permission_post_notifications_label">通知を表示</string>
|
||||||
<string name="permission_post_notifications_description">"接続中の現在の状態など、AirPodsに関する通知をCAPodが表示できるようにします。"</string>
|
<string name="permission_post_notifications_description">"接続中の現在の状態など、AirPodsに関する通知をCAPodが表示できるようにします。"</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -418,6 +427,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">右ポッドのシリアル</string>
|
<string name="device_settings_info_right_serial_label">右ポッドのシリアル</string>
|
||||||
<string name="device_settings_info_left_bonded_label">左ボンド済み</string>
|
<string name="device_settings_info_left_bonded_label">左ボンド済み</string>
|
||||||
<string name="device_settings_info_right_bonded_label">右ボンド済み</string>
|
<string name="device_settings_info_right_bonded_label">右ボンド済み</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">バッテリーの状態 (推定値)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">左のバッテリー状態 (推定値)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">右のバッテリー状態 (推定値)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">〜%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">計算中です。後ほどもう一度ご確認ください。</string>
|
||||||
<string name="device_settings_info_details_label">デバイス詳細</string>
|
<string name="device_settings_info_details_label">デバイス詳細</string>
|
||||||
<string name="device_settings_info_details_action">デバイス詳細を表示</string>
|
<string name="device_settings_info_details_action">デバイス詳細を表示</string>
|
||||||
<string name="device_settings_info_status_label">状態</string>
|
<string name="device_settings_info_status_label">状態</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">ზოგადი შესწორებები, რომლებიც გავლენას ახდენს მთელ აპლიკაციაზე.</string>
|
<string name="settings_general_description">ზოგადი შესწორებები, რომლებიც გავლენას ახდენს მთელ აპლიკაციაზე.</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">შეუსაბამო მოწყობილობების დამალვა</string>
|
<string name="settings_overview_hide_unmatched_label">შეუსაბამო მოწყობილობების დამალვა</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">არ აჩვენო მახლობლად მყოფი მოწყობილობები, რომლებიც არ ემთხვევა თქვენს პროფილებს მიმოხილვაში, მაგ. სხვა ადამიანების AirPods.</string>
|
<string name="settings_overview_hide_unmatched_description">არ აჩვენო მახლობლად მყოფი მოწყობილობები, რომლებიც არ ემთხვევა თქვენს პროფილებს მიმოხილვაში, მაგ. სხვა ადამიანების AirPods.</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">ბატარეის შეფასებები</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">შეიტყვეთ, როგორ იტენდება და იხარჯება ეს მოწყობილობა, რათა შეაფასოთ დარჩენილი დრო, დრო სრული დატენებამდე და ბატარეის მდგომარეობა.</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">მიღებული მონაცემების გადატვირთვა</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">დაიწყეთ ისევ შეფასებული ბატარეის სიცოცხლიდან.</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">გადატვირთოთ მიღებული მონაცემები?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod დაავიწყდება ამ მოწყობილობის გაზომილი მოხმარება, დატენების სიჩქარე და ბატარეის მდგომარეობა, და დაიწყებს ისევ შეფასებული ბატარეის სიცოცხლიდან.</string>
|
||||||
<string name="settings_acknowledgements_label">მადლობები</string>
|
<string name="settings_acknowledgements_label">მადლობები</string>
|
||||||
<string name="settings_debug_autoreports_label">შეცდომების ავტომატური ანგარიშები</string>
|
<string name="settings_debug_autoreports_label">შეცდომების ავტომატური ანგარიშები</string>
|
||||||
<string name="settings_debug_autoreports_description">ავტომატურად აცნობებს პრობლემების შესახებ, მაგ. აპლიკაციის ავარიის დეტალებს, რათა გავარკვიო, როგორ გამოვასწორო.</string>
|
<string name="settings_debug_autoreports_description">ავტომატურად აცნობებს პრობლემების შესახებ, მაგ. აპლიკაციის ავარიის დეტალებს, რათა გავარკვიო, როგორ გამოვასწორო.</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">ფონის გამჭვირვალობა</string>
|
<string name="widget_config_transparency_label">ფონის გამჭვირვალობა</string>
|
||||||
<string name="widget_config_show_device_label">მოწყობილობის სახელის ჩვენება</string>
|
<string name="widget_config_show_device_label">მოწყობილობის სახელის ჩვენება</string>
|
||||||
<string name="widget_config_reset_label">დაბრუნება ნაგულისხმებ პარამეტრებზე</string>
|
<string name="widget_config_reset_label">დაბრუნება ნაგულისხმებ პარამეტრებზე</string>
|
||||||
<string name="widget_config_preview_resize_hint">ვიჯეტის ზომის შეცვლა შესაძლებელია მთავარ ეკრანზე განთავსების შემდეგ.</string>
|
<string name="widget_config_preview_resize_hint">თქვენ შეგიძლიათ გადაზომოთ ვიჯეტი მას შემდეგ, რაც მოათავსებთ მას თქვენი მთავარი ეკრანზე.</string>
|
||||||
<string name="widget_config_custom_label">მორგებული</string>
|
<string name="widget_config_custom_label">მორგებული</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">მუქი</string>
|
<string name="widget_config_preset_dark">მუქი</string>
|
||||||
@@ -278,6 +284,9 @@
|
|||||||
<string name="last_seen_x">ბოლოს ნანახია: %s</string>
|
<string name="last_seen_x">ბოლოს ნანახია: %s</string>
|
||||||
<string name="first_seen_x">პირველად ნანახია: %s</string>
|
<string name="first_seen_x">პირველად ნანახია: %s</string>
|
||||||
<string name="battery_cached_label">ბოლო ცნობილი · %s</string>
|
<string name="battery_cached_label">ბოლო ცნობილი · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dსთ %2$dწთ</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dსთ</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dწთ</string>
|
||||||
<string name="permission_post_notifications_label">შეტყობინებების ჩვენება</string>
|
<string name="permission_post_notifications_label">შეტყობინებების ჩვენება</string>
|
||||||
<string name="permission_post_notifications_description">"დაუშვით, რომ CAPod აჩვენებდეს შეტყობინებებს თქვენი AirPods-ის შესახებ, მაგალითად მათ მიმდინარე სტატუსს დაკავშირებისას."</string>
|
<string name="permission_post_notifications_description">"დაუშვით, რომ CAPod აჩვენებდეს შეტყობინებებს თქვენი AirPods-ის შესახებ, მაგალითად მათ მიმდინარე სტატუსს დაკავშირებისას."</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -423,6 +432,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">მარჯვენა Pod-ის სერიალური</string>
|
<string name="device_settings_info_right_serial_label">მარჯვენა Pod-ის სერიალური</string>
|
||||||
<string name="device_settings_info_left_bonded_label">მარცხენა დაწყვილებული</string>
|
<string name="device_settings_info_left_bonded_label">მარცხენა დაწყვილებული</string>
|
||||||
<string name="device_settings_info_right_bonded_label">მარჯვენა დაწყვილებული</string>
|
<string name="device_settings_info_right_bonded_label">მარჯვენა დაწყვილებული</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">ბატარეის ჯანმრთელობა (შეფასებული)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">მარცხენა ბატარეის ჯანმრთელობა (შ.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">მარჯვენა ბატარეის ჯანმრთელობა (შ.)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">ჯერ კიდევ განისაზღვრება, მოგვიანებით შეამოწმეთ</string>
|
||||||
<string name="device_settings_info_details_label">მოწყობილობის დეტალურები</string>
|
<string name="device_settings_info_details_label">მოწყობილობის დეტალურები</string>
|
||||||
<string name="device_settings_info_details_action">მოწყობილობის დეტალურების ჩვენება</string>
|
<string name="device_settings_info_details_action">მოწყობილობის დეტალურების ჩვენება</string>
|
||||||
<string name="device_settings_info_status_label">სტატუსი</string>
|
<string name="device_settings_info_status_label">სტატუსი</string>
|
||||||
|
|||||||
@@ -122,6 +122,12 @@
|
|||||||
<string name="settings_general_description">ការកែតម្រូវទូទៅដែលប៉ះពាល់ដល់កម្មវិធីទាំងមូល។</string>
|
<string name="settings_general_description">ការកែតម្រូវទូទៅដែលប៉ះពាល់ដល់កម្មវិធីទាំងមូល។</string>
|
||||||
<string name="settings_overview_hide_unmatched_label">លាក់ឧបករណ៍ដែលមិនត្រូវគ្នា</string>
|
<string name="settings_overview_hide_unmatched_label">លាក់ឧបករណ៍ដែលមិនត្រូវគ្នា</string>
|
||||||
<string name="settings_overview_hide_unmatched_description">មិនបង្ហាញឧបករណ៍នៅជិតដែលមិនត្រូវនឹងប្រវត្តិរូបណាមួយរបស់អ្នកនៅក្នុងទិដ្ឋភាពទូទៅ ឧ. AirPods របស់អ្នកដទៃ។</string>
|
<string name="settings_overview_hide_unmatched_description">មិនបង្ហាញឧបករណ៍នៅជិតដែលមិនត្រូវនឹងប្រវត្តិរូបណាមួយរបស់អ្នកនៅក្នុងទិដ្ឋភាពទូទៅ ឧ. AirPods របស់អ្នកដទៃ។</string>
|
||||||
|
<string name="device_battery_estimate_toggle_label">ការប៉ាន់ប្រមាណថាមពលថ្ម</string>
|
||||||
|
<string name="device_battery_estimate_card_desc">រៀនពីរបៀបដែលឧបករណ៍នេះសាក និងរលាយថាមពល ដើម្បីប៉ាន់ប្រមាណពេលដែលនៅសល់ រយៈពេលសាក និងសុខភាពថ្ម។</string>
|
||||||
|
<string name="device_battery_estimate_reset_action">កំដាប់ទិន្នន័យដែលរៀន</string>
|
||||||
|
<string name="device_battery_estimate_reset_desc">ចាប់ផ្តើមម្តងទៀតពីរយៈពេលថ្មដែលបានវាយតម្លៃ។</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_title">លុបទិន្នន័យដែលរៀនឡើងវិញ?</string>
|
||||||
|
<string name="device_battery_estimate_reset_confirm_message">CAPod នឹងភ្លេចការរលាយដែលបានវាស់វែងរបស់ឧបករណ៍នេះ ល្បឿនសាក និងសុខភាពថ្ម ហើយចាប់ផ្តើមម្តងទៀតពីរយៈពេលថ្មដែលបានវាយតម្លៃ។</string>
|
||||||
<string name="settings_acknowledgements_label">ការទទួលស្គាល់</string>
|
<string name="settings_acknowledgements_label">ការទទួលស្គាល់</string>
|
||||||
<string name="settings_debug_autoreports_label">របាយការណ៍កំហុសដោយស្វ័យប្រវត្តិ</string>
|
<string name="settings_debug_autoreports_label">របាយការណ៍កំហុសដោយស្វ័យប្រវត្តិ</string>
|
||||||
<string name="settings_debug_autoreports_description">រាយការណ៍បញ្ហាដោយស្វ័យប្រវត្តិ ឧ. ព័ត៌មានលម្អិតអំពីការគាំងកម្មវិធី ដូច្នេះខ្ញុំអាចរកវិធីដោះស្រាយវាបាន។</string>
|
<string name="settings_debug_autoreports_description">រាយការណ៍បញ្ហាដោយស្វ័យប្រវត្តិ ឧ. ព័ត៌មានលម្អិតអំពីការគាំងកម្មវិធី ដូច្នេះខ្ញុំអាចរកវិធីដោះស្រាយវាបាន។</string>
|
||||||
@@ -156,7 +162,7 @@
|
|||||||
<string name="widget_config_transparency_label">ភាពថ្លាផ្ទៃខាងក្រោយ</string>
|
<string name="widget_config_transparency_label">ភាពថ្លាផ្ទៃខាងក្រោយ</string>
|
||||||
<string name="widget_config_show_device_label">បង្ហាញឈ្មោះឧបករណ៍</string>
|
<string name="widget_config_show_device_label">បង្ហាញឈ្មោះឧបករណ៍</string>
|
||||||
<string name="widget_config_reset_label">កំណត់ឡើងវិញទៅលម្អិតដើម</string>
|
<string name="widget_config_reset_label">កំណត់ឡើងវិញទៅលម្អិតដើម</string>
|
||||||
<string name="widget_config_preview_resize_hint">អ្នកអាចប្តូរទំហំ widget បន្ទាប់ពីដាក់វានៅលើអេក្រង់ដើមរបស់អ្នក។</string>
|
<string name="widget_config_preview_resize_hint">អ្នកអាចផ្លាស់ប្តូរទំហំវីដ្ជិតបន្ទាប់ពីដាក់វានៅលើអេក្រង់ដើមរបស់អ្នក។</string>
|
||||||
<string name="widget_config_custom_label">ផ្ទាល់ខ្លួន</string>
|
<string name="widget_config_custom_label">ផ្ទាល់ខ្លួន</string>
|
||||||
<string name="widget_config_preset_material_you">Material You</string>
|
<string name="widget_config_preset_material_you">Material You</string>
|
||||||
<string name="widget_config_preset_dark">ងងឹត</string>
|
<string name="widget_config_preset_dark">ងងឹត</string>
|
||||||
@@ -276,6 +282,9 @@
|
|||||||
<string name="last_seen_x">ឃើញចុងក្រោយ: %s</string>
|
<string name="last_seen_x">ឃើញចុងក្រោយ: %s</string>
|
||||||
<string name="first_seen_x">ឃើញដំបូង: %s</string>
|
<string name="first_seen_x">ឃើញដំបូង: %s</string>
|
||||||
<string name="battery_cached_label">ដឹងចុងក្រោយ · %s</string>
|
<string name="battery_cached_label">ដឹងចុងក្រោយ · %s</string>
|
||||||
|
<string name="battery_time_remaining_format_hm">%1$dម %2$dន</string>
|
||||||
|
<string name="battery_time_remaining_format_h">%1$dម</string>
|
||||||
|
<string name="battery_time_remaining_format_m">%1$dន</string>
|
||||||
<string name="permission_post_notifications_label">បង្ហាញការជូនដំណឹង</string>
|
<string name="permission_post_notifications_label">បង្ហាញការជូនដំណឹង</string>
|
||||||
<string name="permission_post_notifications_description">"អនុញ្ញាតឱ្យ CAPod បង្ហាញការជូនដំណឹងអំពី AirPods របស់អ្នក ឧ. ស្ថានភាពបច្ចុប្បន្នរបស់ពួកគេខណៈពេលភ្ជាប់។"</string>
|
<string name="permission_post_notifications_description">"អនុញ្ញាតឱ្យ CAPod បង្ហាញការជូនដំណឹងអំពី AirPods របស់អ្នក ឧ. ស្ថានភាពបច្ចុប្បន្នរបស់ពួកគេខណៈពេលភ្ជាប់។"</string>
|
||||||
<!-- Device profiles -->
|
<!-- Device profiles -->
|
||||||
@@ -418,6 +427,11 @@
|
|||||||
<string name="device_settings_info_right_serial_label">លើខស៊ើរី Pod ខាងស្តាំ</string>
|
<string name="device_settings_info_right_serial_label">លើខស៊ើរី Pod ខាងស្តាំ</string>
|
||||||
<string name="device_settings_info_left_bonded_label">ខាងឆ្វេងបានភ្ជាប់</string>
|
<string name="device_settings_info_left_bonded_label">ខាងឆ្វេងបានភ្ជាប់</string>
|
||||||
<string name="device_settings_info_right_bonded_label">ខាងស្តាំបានភ្ជាប់</string>
|
<string name="device_settings_info_right_bonded_label">ខាងស្តាំបានភ្ជាប់</string>
|
||||||
|
<string name="device_settings_info_battery_health_label">សុខភាពថ្ម (ប៉ាន់ស្មាន)</string>
|
||||||
|
<string name="device_settings_info_battery_health_left_label">សុខភាពថ្ម ឆ្វេង (ប៉ាន់ស្មាន)</string>
|
||||||
|
<string name="device_settings_info_battery_health_right_label">សុខភាពថ្ម ស្ដាំ (ប៉ាន់ស្មាន)</string>
|
||||||
|
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||||
|
<string name="device_settings_info_battery_health_pending">នៅកំពុងកំណត់ សូមត្រលប់មកពេលក្រោយ</string>
|
||||||
<string name="device_settings_info_details_label">ព័ត៌មានលម័អិតឧបករណ៍</string>
|
<string name="device_settings_info_details_label">ព័ត៌មានលម័អិតឧបករណ៍</string>
|
||||||
<string name="device_settings_info_details_action">បង់ហាញព័ត៌មានលម័អិតឧបករណ៍</string>
|
<string name="device_settings_info_details_action">បង់ហាញព័ត៌មានលម័អិតឧបករណ៍</string>
|
||||||
<string name="device_settings_info_status_label">ស្ថានភាព</string>
|
<string name="device_settings_info_status_label">ស្ថានភាព</string>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user