mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd827af3cd | ||
|
|
948205667b | ||
|
|
dbbfa75429 | ||
|
|
78790f73df | ||
|
|
2950d8b3f9 | ||
|
|
4d3a9bc165 | ||
|
|
7d57845502 | ||
|
|
83ef111599 | ||
|
|
0e6848d886 | ||
|
|
1eeeb63a76 | ||
|
|
a4ba302223 | ||
|
|
8c9094d85a | ||
|
|
4529b107dd | ||
|
|
17663bc759 | ||
|
|
278257998b | ||
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 | ||
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 760 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 761 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 605 KiB |
@@ -25,16 +25,23 @@ ScreenshotContent.kt (mock data + composables)
|
||||
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
||||
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
||||
|
||||
## Commit policy
|
||||
|
||||
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
|
||||
|
||||
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
|
||||
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
||||
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||
# Use this for local iteration and PRs that touch screenshot content.
|
||||
./fastlane/generate_screenshots.sh --smoke
|
||||
|
||||
# Full run — all 68 locales. Use only when intending to upload to Play Store
|
||||
# (the non-smoke output is .gitignored and should not be committed).
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Copy into fastlane directories (run after generate)
|
||||
./fastlane/copy_screenshots.sh
|
||||
|
||||
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
|
||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
|
||||
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
|
||||
|
||||
## Removing or Renaming a Screenshot
|
||||
|
||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||
2. Run `generate_screenshots.sh`
|
||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/*/images/phoneScreenshots/` and get uploaded to Play Store
|
||||
2. Run `generate_screenshots.sh --smoke`
|
||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||
|
||||
## After UI Changes
|
||||
|
||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
|
||||
|
||||
```bash
|
||||
./fastlane/generate_screenshots.sh
|
||||
./fastlane/generate_screenshots.sh --smoke
|
||||
./fastlane/copy_screenshots.sh --clean
|
||||
```
|
||||
|
||||
## Refreshing all locales on Play Store
|
||||
|
||||
Periodic, manual operation — not per-PR:
|
||||
|
||||
```bash
|
||||
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
|
||||
./fastlane/copy_screenshots.sh --clean
|
||||
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
|
||||
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
|
||||
```
|
||||
|
||||
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
||||
|
||||
+10
@@ -17,6 +17,16 @@
|
||||
.kotlin
|
||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||
app/src/screenshotTest*/reference/
|
||||
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
|
||||
# Full localization is uploaded by occasional manual regen + screenshots_only;
|
||||
# Play Store retains previously-uploaded screenshots for locales not pushed.
|
||||
fastlane/metadata/android/*/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
|
||||
.codex
|
||||
protocol-research/
|
||||
_site/
|
||||
|
||||
@@ -81,8 +81,7 @@ Currently supported models:
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
|
||||
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
|
||||
|
||||
## Thanks to
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -130,6 +130,7 @@ android {
|
||||
//noinspection WrongGradleMethod
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "4g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
@@ -37,17 +38,26 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||
|
||||
@Composable
|
||||
internal fun DashboardContent() = PreviewWrapper {
|
||||
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
||||
val devices = if (showAap) {
|
||||
listOf(
|
||||
MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||
MockPodDataProvider.singlePodMonitoredWithAap(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
)
|
||||
}
|
||||
OverviewScreen(
|
||||
state = OverviewViewModel.State(
|
||||
now = MOCK_NOW,
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
isDebugMode = false,
|
||||
devices = devices,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
||||
|
||||
@Composable
|
||||
internal fun AddProfileContent() = PreviewWrapper {
|
||||
val pairedAirPods = BluetoothDevice2(
|
||||
address = "AA:BB:CC:DD:EE:FF",
|
||||
name = "AirPods Pro",
|
||||
seenFirstAt = MOCK_NOW,
|
||||
)
|
||||
val bondedItems = listOf(
|
||||
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||
device = pairedAirPods,
|
||||
claimedByProfile = null,
|
||||
),
|
||||
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||
device = BluetoothDevice2(
|
||||
address = "11:22:33:44:55:66",
|
||||
name = "Living Room TV",
|
||||
seenFirstAt = MOCK_NOW,
|
||||
),
|
||||
claimedByProfile = null,
|
||||
),
|
||||
)
|
||||
DeviceProfileCreationScreen(
|
||||
state = DeviceProfileCreationViewModel.State(
|
||||
isEditMode = false,
|
||||
name = "",
|
||||
name = "My AirPods Pro",
|
||||
nameError = null,
|
||||
selectedModel = null,
|
||||
selectedModel = PodModel.AIRPODS_PRO2,
|
||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||
identityKey = null,
|
||||
encryptionKey = null,
|
||||
selectedDevice = null,
|
||||
bondedDeviceItems = emptyList(),
|
||||
selectedDevice = pairedAirPods,
|
||||
bondedDeviceItems = bondedItems,
|
||||
minimumSignalQuality = 0.15f,
|
||||
canSave = false,
|
||||
canSave = true,
|
||||
),
|
||||
onBack = {},
|
||||
onSave = {},
|
||||
@@ -212,7 +241,7 @@ private fun PreviewDashboardLight() = DashboardContent()
|
||||
|
||||
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
||||
@Composable
|
||||
private fun PreviewDashboardDark() = DashboardContent()
|
||||
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||
|
||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||
@Composable
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="foss_upgrade_donate_label">تبرُّع</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
|
||||
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string>
|
||||
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string>
|
||||
<string name="foss_upgrade_donate_label">تبرع</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">أنا متبرّع فعلًا</string>
|
||||
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على AirPods</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
|
||||
<string name="upgrade_foss_sponsor_action">دعم المشروع</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من Google Play.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">هل عدتم فعلًا؟ دعمكم يُبقي CAPod حيًا.</string>
|
||||
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
|
||||
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متوفّرة.</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_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
|
||||
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
|
||||
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
|
||||
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string>
|
||||
<string name="upgrade_restore_action">استعادة المُشتَرَى</string>
|
||||
<string name="upgrade_badge_label">احترافي</string>
|
||||
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من Google Play. يُرجى مسح ذاكرة التخزين المؤقّت لـ Google Play وإعادة تشغيل هاتفك.\n\nخطأ %s</string>
|
||||
<string name="upgrade_restore_action">استعادة المشتريات</string>
|
||||
<string name="upgrade_badge_label">النسخة الاحترافية</string>
|
||||
</resources>
|
||||
|
||||
@@ -141,6 +141,20 @@
|
||||
android:name=".monitor.core.worker.MonitorService"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".main.ui.tile.AncTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_anc_on"
|
||||
android:label="@string/tile_anc_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="false" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||
import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
@@ -22,16 +18,16 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@HiltAndroidApp
|
||||
open class App : Application() {
|
||||
@@ -46,22 +42,19 @@ open class App : Application() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||
|
||||
val foregroundExceptionHandled = AtomicBoolean(false)
|
||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
val isTimingExc = throwable.isForegroundServiceTimingException()
|
||||
val isMain = thread === Looper.getMainLooper().thread
|
||||
if (isTimingExc && isMain && foregroundExceptionHandled.compareAndSet(false, true)) {
|
||||
runCatching {
|
||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
||||
}
|
||||
Looper.loop()
|
||||
return@setDefaultUncaughtExceptionHandler
|
||||
}
|
||||
runCatching { log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
||||
}
|
||||
Thread.setDefaultUncaughtExceptionHandler(
|
||||
CapodUncaughtExceptionHandler(
|
||||
previousHandler = oldHandler,
|
||||
cancelBeforeDelegate = { throwable ->
|
||||
// Best-effort shutdown: the system handler may terminate the process immediately,
|
||||
// but cancellation can still close sockets if it gets a scheduling window.
|
||||
if (::appScope.isInitialized) {
|
||||
appScope.cancel(CancellationException("Uncaught exception", throwable))
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
autoReporting.setup(this)
|
||||
|
||||
@@ -90,14 +83,5 @@ open class App : Application() {
|
||||
|
||||
companion object {
|
||||
internal val TAG = logTag("CAP")
|
||||
|
||||
private fun Throwable.isForegroundServiceTimingException(): Boolean {
|
||||
var current: Throwable? = this
|
||||
while (current != null) {
|
||||
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.os.Looper
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
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 java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
internal class CapodUncaughtExceptionHandler(
|
||||
private val previousHandler: Thread.UncaughtExceptionHandler?,
|
||||
private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread },
|
||||
private val loopMainThread: () -> Unit = { Looper.loop() },
|
||||
private val reportForegroundServiceTimingException: (Throwable) -> Unit = { throwable ->
|
||||
Bugs.report(
|
||||
tag = App.TAG,
|
||||
message = "Foreground service timing exception suppressed",
|
||||
exception = throwable,
|
||||
)
|
||||
},
|
||||
private val cancelBeforeDelegate: (Throwable) -> Unit = {},
|
||||
private val exit: (Int) -> Unit = { exitProcess(it) },
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
|
||||
private val foregroundExceptionHandled = AtomicBoolean(false)
|
||||
|
||||
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||
if (shouldSuppress(thread, throwable)) {
|
||||
runCatching {
|
||||
log(App.TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
reportForegroundServiceTimingException(throwable)
|
||||
}
|
||||
|
||||
val loopResult = runCatching { loopMainThread() }
|
||||
if (loopResult.isSuccess) return
|
||||
|
||||
val loopFailure = loopResult.exceptionOrNull()!!
|
||||
runCatching {
|
||||
log(App.TAG, ERROR) {
|
||||
"Main loop failed after foreground service timing exception suppression: ${loopFailure.asLog()}"
|
||||
}
|
||||
}
|
||||
delegate(thread, loopFailure)
|
||||
return
|
||||
}
|
||||
|
||||
runCatching { log(App.TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
||||
delegate(thread, throwable)
|
||||
}
|
||||
|
||||
private fun shouldSuppress(thread: Thread, throwable: Throwable): Boolean {
|
||||
val isMainThread = runCatching { thread === mainThreadProvider() }.getOrDefault(false)
|
||||
return throwable.isForegroundServiceTimingException() &&
|
||||
isMainThread &&
|
||||
foregroundExceptionHandled.compareAndSet(false, true)
|
||||
}
|
||||
|
||||
private fun delegate(thread: Thread, throwable: Throwable) {
|
||||
runCatching { cancelBeforeDelegate(throwable) }
|
||||
previousHandler?.uncaughtException(thread, throwable) ?: exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Throwable.isForegroundServiceTimingException(): Boolean {
|
||||
var current: Throwable? = this
|
||||
while (current != null) {
|
||||
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioPlaybackConfiguration
|
||||
import android.os.Build
|
||||
import android.view.KeyEvent
|
||||
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.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -14,48 +17,103 @@ class MediaControl @Inject constructor(
|
||||
private val audioManager: AudioManager,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
private var capPauseExpiryElapsedRealtime: Long = 0L
|
||||
/**
|
||||
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
|
||||
* dispatches a resume or when music transitions inactive→active from any source. Read by
|
||||
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
|
||||
*
|
||||
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
|
||||
* pod-out conversations and had a known race where music starting again via another source
|
||||
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
|
||||
*/
|
||||
@Volatile private var capPaused: Boolean = false
|
||||
@Volatile private var lastKnownMusicActive: Boolean = false
|
||||
|
||||
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
|
||||
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
|
||||
val nowActive = audioManager.isMusicActive
|
||||
if (!lastKnownMusicActive && nowActive) {
|
||||
// Music started by some source (could be us via sendPlay or someone else).
|
||||
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
|
||||
// re-fire a play key over already-active music.
|
||||
capPaused = false
|
||||
}
|
||||
lastKnownMusicActive = nowActive
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Seed the active flag from current state so we won't miss the next inactive→active
|
||||
// transition if music is already playing when MediaControl is constructed.
|
||||
lastKnownMusicActive = audioManager.isMusicActive
|
||||
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
|
||||
}
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = audioManager.isMusicActive
|
||||
|
||||
val wasRecentlyPausedByCap: Boolean
|
||||
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
|
||||
get() = capPaused
|
||||
|
||||
suspend fun sendPlay() {
|
||||
log(TAG, INFO) { "sendPlay()" }
|
||||
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
|
||||
if (audioManager.isMusicActive && !capPaused) {
|
||||
log(TAG, INFO) { "Music is already playing, not sending play" }
|
||||
return
|
||||
}
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
||||
clearRecentCapPause()
|
||||
capPaused = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
|
||||
*
|
||||
* Returns `true` when a key event was actually dispatched (and the 15-second
|
||||
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
|
||||
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
|
||||
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
|
||||
* cooldown on a real pause — should branch on the return value rather than checking
|
||||
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
|
||||
* @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
|
||||
* pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
|
||||
* flow. When `false` (default), the dispatched pause clears any pending auto-resume —
|
||||
* this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
|
||||
* is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
|
||||
* behavior where only ear-removal auto-pauses are eligible for auto-resume.
|
||||
*
|
||||
* Returns `true` when a key event was actually dispatched, `false` when the call was a
|
||||
* no-op because nothing was playing. A no-op leaves the existing flag state untouched
|
||||
* (so a sleep-reaction firing while music is already paused doesn't accidentally cancel
|
||||
* a pending auto-resume from a recent ear-removal pause). Callers that need to
|
||||
* distinguish "we actually paused" from "there was nothing to pause" — e.g. the sleep
|
||||
* reaction, which gates its notification and cooldown on a real pause — should branch
|
||||
* on the return value rather than checking [isPlaying] themselves to avoid a
|
||||
* check-then-act race with the audio system.
|
||||
*/
|
||||
suspend fun sendPause(): Boolean {
|
||||
log(TAG, INFO) { "sendPause()" }
|
||||
suspend fun sendPause(rememberForResume: Boolean = false): Boolean {
|
||||
log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
||||
return false
|
||||
}
|
||||
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
|
||||
// playback callback that fires during the dispatch (e.g. a fast user resume on the
|
||||
// phone, or another app grabbing audio focus and immediately starting) could clear
|
||||
// capPaused mid-dispatch and we'd then overwrite it back to true on a stale pause.
|
||||
// This single explicit assignment also covers the contract that an explicit user
|
||||
// pause cancels a pending auto-resume.
|
||||
capPaused = rememberForResume
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
||||
markRecentCapPause()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches MEDIA_STOP and clears any pending auto-resume — Stop is an explicit user
|
||||
* "stay stopped" action, so a later pod-in must not auto-resume from a prior auto-pause.
|
||||
*/
|
||||
suspend fun sendStop() {
|
||||
log(TAG, INFO) { "sendStop()" }
|
||||
capPaused = false
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
|
||||
}
|
||||
|
||||
suspend fun sendPlayPause() {
|
||||
log(TAG) { "sendPlayPause()" }
|
||||
if (wasRecentlyPausedByCap) {
|
||||
if (capPaused) {
|
||||
sendPlay()
|
||||
return
|
||||
}
|
||||
@@ -101,16 +159,71 @@ class MediaControl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun markRecentCapPause() {
|
||||
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
|
||||
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
|
||||
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
/**
|
||||
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
|
||||
* the prior + the volume actually applied, so the caller can later restore it and detect whether
|
||||
* the user changed the volume in the meantime.
|
||||
*
|
||||
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
|
||||
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
|
||||
* frequent push event and the volume panel flashing would be noisy. The applied target is read
|
||||
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
|
||||
*/
|
||||
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
|
||||
return null
|
||||
}
|
||||
if (audioManager.isVolumeFixed) {
|
||||
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
|
||||
return null
|
||||
}
|
||||
val percent = reductionPercent.coerceIn(0, 100)
|
||||
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
|
||||
if (target >= prior) {
|
||||
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||
VolumeDuck(priorVolume = prior, appliedVolume = applied)
|
||||
} catch (e: SecurityException) {
|
||||
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
|
||||
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearRecentCapPause() {
|
||||
capPauseExpiryElapsedRealtime = 0L
|
||||
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
|
||||
fun restoreMusicVolume(priorVolume: Int) {
|
||||
if (audioManager.isVolumeFixed) return
|
||||
try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
|
||||
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
|
||||
data class VolumeDuck(
|
||||
val priorVolume: Int,
|
||||
val appliedVolume: Int,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MediaControl")
|
||||
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -32,7 +33,6 @@ import javax.inject.Singleton
|
||||
class BleScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val fakeBleData: FakeBleData,
|
||||
private val scanResultForwarder: BleScanResultForwarder,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
@@ -113,22 +113,7 @@ class BleScanner @Inject constructor(
|
||||
null
|
||||
}
|
||||
|
||||
val flushJob = if (!disableDirectScanCallback) {
|
||||
launch {
|
||||
log(TAG) { "Flush job launched" }
|
||||
while (isActive) {
|
||||
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
||||
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
|
||||
when (scannerMode) {
|
||||
ScannerMode.LOW_POWER -> break
|
||||
ScannerMode.BALANCED -> delay(2000)
|
||||
ScannerMode.LOW_LATENCY -> delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var flushJob: Job? = null
|
||||
|
||||
val filterList = when {
|
||||
useOffloadedFiltering -> filters.toList()
|
||||
@@ -167,31 +152,59 @@ class BleScanner @Inject constructor(
|
||||
setReportDelay(delay)
|
||||
}.build()
|
||||
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callbackIntent)
|
||||
} else {
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callback)
|
||||
flushJob = launch {
|
||||
log(TAG) { "Flush job launched" }
|
||||
while (isActive) {
|
||||
try {
|
||||
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
||||
scanner.flushPendingScanResults(callback)
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
|
||||
close(e)
|
||||
break
|
||||
}
|
||||
when (scannerMode) {
|
||||
ScannerMode.LOW_POWER -> break
|
||||
ScannerMode.BALANCED -> delay(2000)
|
||||
ScannerMode.LOW_LATENCY -> delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callbackIntent)
|
||||
} else {
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callback)
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "startScan() denied: ${e.message}" }
|
||||
forwarderConsumer?.cancel()
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
forwarderConsumer?.cancel()
|
||||
flushJob?.cancel()
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
|
||||
}
|
||||
log(TAG) { "BleScanner stopped" }
|
||||
}
|
||||
}
|
||||
.map { fakeBleData.maybeAddfakeData(it) }
|
||||
|
||||
private val receiverIntent by lazy {
|
||||
Intent(context, BleScanResultReceiver::class.java).apply {
|
||||
|
||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
||||
import java.time.Instant
|
||||
|
||||
data class BluetoothDevice2(
|
||||
internal val internal: BluetoothDevice,
|
||||
val address: BluetoothAddress,
|
||||
val name: String?,
|
||||
val seenFirstAt: Instant,
|
||||
) {
|
||||
val address: BluetoothAddress
|
||||
get() = internal.address
|
||||
|
||||
val name: String?
|
||||
get() = internal.name
|
||||
}
|
||||
internal val internal: BluetoothDevice? = null,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -23,6 +24,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
@@ -255,6 +257,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
.map { device ->
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -304,6 +308,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
val wrappedDevices = rawDevices.map { device ->
|
||||
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -386,9 +392,6 @@ class BluetoothManager2 @Inject constructor(
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private var _isNudgeAvailable: Boolean = true
|
||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||
|
||||
/**
|
||||
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
|
||||
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
|
||||
@@ -408,9 +411,10 @@ class BluetoothManager2 @Inject constructor(
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||
return try {
|
||||
val target = device.internal ?: return false
|
||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||
.apply { isAccessible = true }
|
||||
val result = method.invoke(device.internal, alias) as? Boolean ?: false
|
||||
val result = method.invoke(target, alias) as? Boolean ?: false
|
||||
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
@@ -420,28 +424,42 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
|
||||
getBluetoothProfile().map { bluetoothProfile ->
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
val target = device.internal ?: return@map NudgeAttemptResult.Rejected
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
|
||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
val isSecurityException = e is SecurityException ||
|
||||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||
if (isSecurityException) {
|
||||
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
|
||||
_isNudgeAvailable = false
|
||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
|
||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||
if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
|
||||
} catch (e: Exception) {
|
||||
val isSecurityException = e is SecurityException ||
|
||||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||
if (!isSecurityException) {
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
return@map NudgeAttemptResult.Rejected
|
||||
}
|
||||
// BLUETOOTH_CONNECT is only a runtime permission on Android 12+. On older
|
||||
// versions a SecurityException can't be a missing-permission failure for that
|
||||
// permission — it's hidden-API enforcement (e.g. MODIFY_PHONE_STATE) and we
|
||||
// should persist BROKEN.
|
||||
val missingBtConnect = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
!Permission.BLUETOOTH_CONNECT.isGranted(context)
|
||||
if (missingBtConnect) {
|
||||
log(TAG, WARN) { "nudgeConnection failed because BLUETOOTH_CONNECT is not granted" }
|
||||
NudgeAttemptResult.UnavailableMissingPermission
|
||||
} else {
|
||||
log(TAG, ERROR) { "nudgeConnection is permanently unavailable on this device: $e" }
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
NudgeAttemptResult.UnavailableHiddenApi
|
||||
}
|
||||
}
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
false
|
||||
}
|
||||
}.first()
|
||||
}.first()
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "Manager2")
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.fromHex
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@Reusable
|
||||
class FakeBleData @Inject constructor(
|
||||
private val debugSettings: DebugSettings,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
|
||||
if (!debugSettings.showFakeData.valueBlocking) return originals
|
||||
return originals + getFakeData()
|
||||
}
|
||||
|
||||
fun getFakeData(): Collection<BleScanResult> {
|
||||
val fakeDevices = mutableListOf<BleScanResult>()
|
||||
val now = timeSource.now()
|
||||
val generatedAt = timeSource.elapsedRealtimeNanos()
|
||||
// AirPods Gen1
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "78:73:AF:B4:85:22",
|
||||
rssi = Random.nextInt(100) * -1,
|
||||
generatedAtNanos = generatedAt + 100,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// AirPods Gen2
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "78:73:FF:B4:85:5E",
|
||||
rssi = Random.nextInt(100) * -1,
|
||||
generatedAtNanos = generatedAt + 100,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// AirPods Gen3
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "4E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 200,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
// AirPods Max
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "7E:E5:C7:65:D2:B5",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 300,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
// BeatsFlex
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "5E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 400,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// Tws i99999
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "5E:9E:D1:29:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 400,
|
||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// Unknown Device
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "6E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 500,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
return fakeDevices
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class NudgeAvailability {
|
||||
@SerialName("nudge.unknown") UNKNOWN,
|
||||
@SerialName("nudge.available") AVAILABLE,
|
||||
@SerialName("nudge.broken") BROKEN,
|
||||
}
|
||||
|
||||
enum class NudgeAttemptResult {
|
||||
Accepted,
|
||||
Rejected,
|
||||
UnavailableHiddenApi,
|
||||
UnavailableMissingPermission,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
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.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NudgeCapabilityStore @Inject constructor(
|
||||
generalSettings: GeneralSettings,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
) {
|
||||
|
||||
private val persistedValue = generalSettings.nudgeAvailability
|
||||
|
||||
// Resolve the persisted value synchronously at construction so consumers that read
|
||||
// `availability.value` (resolver, AutoConnect precondition, UI state) never see the
|
||||
// synthetic UNKNOWN seed before the first DataStore emission arrives. On a known-broken
|
||||
// device this prevents one bonus ALWAYS-mode + nudge-attempt cycle per cold start.
|
||||
val availability: StateFlow<NudgeAvailability> = persistedValue.flow
|
||||
.stateIn(
|
||||
scope = appScope + dispatcherProvider.IO,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = persistedValue.valueBlocking,
|
||||
)
|
||||
|
||||
fun record(result: NudgeAttemptResult) {
|
||||
val verdict = verdictFor(result) ?: return
|
||||
if (availability.value == verdict) return
|
||||
log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" }
|
||||
appScope.launch(dispatcherProvider.IO) {
|
||||
persistedValue.value(verdict)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "NudgeCapabilityStore")
|
||||
|
||||
internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) {
|
||||
NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE
|
||||
NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN
|
||||
NudgeAttemptResult.Rejected,
|
||||
NudgeAttemptResult.UnavailableMissingPermission -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
||||
|
||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-mixed",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProMixed(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-keys",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-aap",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
@@ -227,12 +230,14 @@ object MockPodDataProvider {
|
||||
|
||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||
profileId = "preview-single",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMax(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-single-aap",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMaxCharging(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
|
||||
) {
|
||||
val keyName: String get() = key.name
|
||||
|
||||
val flow: Flow<T> = dataStore.data.map { prefs ->
|
||||
reader(prefs[key])
|
||||
}
|
||||
val flow: Flow<T> = dataStore.data
|
||||
.map { prefs -> prefs[key] }
|
||||
.distinctUntilChanged()
|
||||
.map { raw -> reader(raw) }
|
||||
|
||||
data class Updated<T>(val old: T, val new: T)
|
||||
|
||||
|
||||
@@ -5,23 +5,41 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.asLogSummary
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object Bugs {
|
||||
var reporter: AutomaticBugReporter? = null
|
||||
|
||||
/**
|
||||
* True while the debug log recorder is actively recording. Surfaces extra
|
||||
* diagnostic UI (e.g. raw BLE payload hex on device cards) so users helping
|
||||
* to add support for new headphones can capture useful data without a
|
||||
* permanent debug toggle.
|
||||
*/
|
||||
val isDebug = MutableStateFlow(false)
|
||||
|
||||
fun report(
|
||||
tag: String,
|
||||
message: String,
|
||||
exception: Throwable
|
||||
) {
|
||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
||||
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
|
||||
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
|
||||
|
||||
reporter?.notify(exception) ?: run {
|
||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
||||
val bugReporter = reporter
|
||||
if (bugReporter == null) {
|
||||
runCatching { log(TAG, WARN) { "Bug tracking not initialized yet." } }
|
||||
return
|
||||
}
|
||||
|
||||
runCatching { bugReporter.notify(exception) }
|
||||
.onFailure { failure ->
|
||||
runCatching { log(TAG, WARN) { "Bug reporter failed: ${failure.asLog()}" } }
|
||||
}
|
||||
}
|
||||
|
||||
private val TAG = logTag("Bugs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
|
||||
// Reporting is opt-out for gplay, and opt-in for github builds
|
||||
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
||||
)
|
||||
val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false)
|
||||
|
||||
val showFakeData = dataStore.createValue("debug.fakedata.enabled", false)
|
||||
|
||||
val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false)
|
||||
|
||||
}
|
||||
|
||||
@@ -61,16 +61,19 @@ object Logging {
|
||||
message: String
|
||||
) {
|
||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||
snapshot
|
||||
.filter { it.isLoggable(priority) }
|
||||
.forEach {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
snapshot.forEach {
|
||||
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
||||
if (isLoggable) {
|
||||
runCatching {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
@@ -110,14 +113,28 @@ inline fun log(
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.asLog(): String {
|
||||
fun Throwable.asLog(): String = runCatching {
|
||||
val stringWriter = StringWriter(256)
|
||||
val printWriter = PrintWriter(stringWriter, false)
|
||||
printStackTrace(printWriter)
|
||||
printWriter.flush()
|
||||
return stringWriter.toString()
|
||||
stringWriter.toString()
|
||||
}.getOrElse { renderFailure ->
|
||||
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
|
||||
}
|
||||
|
||||
fun Throwable.asLogSummary(): String {
|
||||
val throwableClass = javaClass.name
|
||||
val throwableMessage = safeMessage()
|
||||
return if (throwableMessage.isNullOrBlank()) {
|
||||
throwableClass
|
||||
} else {
|
||||
"$throwableClass: $throwableMessage"
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
|
||||
|
||||
@PublishedApi
|
||||
internal fun Any.logTagViaCallSite(): String {
|
||||
val javaClass = this::class.java
|
||||
|
||||
@@ -11,6 +11,7 @@ import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
|
||||
}
|
||||
}
|
||||
.launchIn(appScope)
|
||||
|
||||
internalState.flow
|
||||
.onEach { Bugs.isDebug.value = it.isRecording }
|
||||
.launchIn(appScope)
|
||||
}
|
||||
|
||||
private fun createSessionDir(): File {
|
||||
|
||||
@@ -36,9 +36,6 @@ object Nav {
|
||||
@Serializable
|
||||
data object General : Settings
|
||||
|
||||
@Serializable
|
||||
data object Debug : Settings
|
||||
|
||||
@Serializable
|
||||
data object Support : Settings
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package eu.darken.capod.common.permissions
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.PermissionChecker
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.withinApiLevel
|
||||
@@ -18,7 +17,7 @@ enum class Permission(
|
||||
val permissionId: String,
|
||||
val isScanBlocking: Boolean = false,
|
||||
val isGranted: (Context) -> Boolean = {
|
||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
||||
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
|
||||
},
|
||||
) {
|
||||
BLUETOOTH(
|
||||
@@ -87,4 +86,4 @@ enum class Permission(
|
||||
fun Permission.isRequired(context: Context): Boolean = when {
|
||||
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
||||
else -> !isGranted(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.datastore.createValue
|
||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
@@ -36,8 +37,6 @@ class GeneralSettings @Inject constructor(
|
||||
|
||||
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
||||
|
||||
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true)
|
||||
|
||||
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
|
||||
|
||||
val keepConnectedNotificationAfterDisconnect =
|
||||
@@ -74,6 +73,12 @@ class GeneralSettings @Inject constructor(
|
||||
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
|
||||
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
|
||||
|
||||
/** Runtime-detected verdict for whether `BluetoothHeadset.connect()` reflection works on this device. */
|
||||
val nudgeAvailability = dataStore.createValue(
|
||||
"core.bluetooth.nudge.availability", NudgeAvailability.UNKNOWN, json,
|
||||
onErrorFallbackToDefault = true,
|
||||
)
|
||||
|
||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||
|
||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
package eu.darken.capod.main.core
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class MonitorMode(
|
||||
@StringRes val labelRes: Int
|
||||
val intensity: Int,
|
||||
) {
|
||||
@SerialName("monitor.mode.manual") MANUAL(
|
||||
R.string.settings_monitor_mode_manual_label
|
||||
),
|
||||
@SerialName("monitor.mode.automatic") AUTOMATIC(
|
||||
R.string.settings_monitor_mode_automatic_label
|
||||
),
|
||||
@SerialName("monitor.mode.always") ALWAYS(
|
||||
R.string.settings_monitor_mode_always_label
|
||||
),
|
||||
}
|
||||
@SerialName("monitor.mode.manual") MANUAL(intensity = 0),
|
||||
@SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
|
||||
@SerialName("monitor.mode.always") ALWAYS(intensity = 2),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.permissions.isRequired
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.profiles.core.toReactionConfig
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -21,7 +22,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class PermissionTool @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
||||
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
|
||||
|
||||
val missingPermissions: Flow<Set<Permission>> = combine(
|
||||
permissionCheckTrigger,
|
||||
generalSettings.monitorMode.flow,
|
||||
monitorModeResolver.effectiveMode,
|
||||
anyPopupEnabled,
|
||||
) { _, monitorMode, showPopUp ->
|
||||
Permission.entries
|
||||
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
|
||||
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
|
||||
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
|
||||
.filter { isApplicable(it, monitorMode, showPopUp) }
|
||||
.filter { it.isRequired(context) }
|
||||
.toSet()
|
||||
}
|
||||
@@ -61,5 +60,24 @@ class PermissionTool @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("PermissionTool")
|
||||
|
||||
/**
|
||||
* Whether [permission] is applicable for the user's current configuration.
|
||||
* Three permissions are conditional on monitor mode or popup usage:
|
||||
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed
|
||||
* - [Permission.ACCESS_BACKGROUND_LOCATION] same
|
||||
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
|
||||
* Everything else is unconditionally applicable.
|
||||
*/
|
||||
internal fun isApplicable(
|
||||
permission: Permission,
|
||||
monitorMode: MonitorMode,
|
||||
anyPopupEnabled: Boolean,
|
||||
): Boolean = when (permission) {
|
||||
Permission.IGNORE_BATTERY_OPTIMIZATION,
|
||||
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
|
||||
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.navigation.LocalNavigationController
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationController
|
||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
|
||||
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
|
||||
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
|
||||
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
|
||||
private val warmIntents = SingleEventFlow<Intent>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
||||
navCtrl.setup(backStack)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Cold-start intent (the activity's launching intent).
|
||||
consumeUpgradeExtra(intent)
|
||||
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
|
||||
// directly from onNewIntent) guarantees navCtrl.setup() has run.
|
||||
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
|
||||
}
|
||||
|
||||
CapodTheme(state = themeState) {
|
||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeUpgradeExtra(intent)
|
||||
setIntent(intent)
|
||||
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
|
||||
warmIntents.tryEmit(intent)
|
||||
}
|
||||
|
||||
private fun consumeUpgradeExtra(intent: Intent?) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.AutoAwesome
|
||||
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
||||
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
||||
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -148,6 +149,8 @@ fun DeviceSettingsScreenHost(
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onAncModeChange = { vm.setAncMode(it) },
|
||||
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
|
||||
onConversationActionChange = { vm.setConversationAction(it) },
|
||||
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
|
||||
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
|
||||
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
|
||||
onToneVolumeChange = { vm.setToneVolume(it) },
|
||||
@@ -167,11 +170,11 @@ fun DeviceSettingsScreenHost(
|
||||
onOnePodModeChange = { vm.setOnePodMode(it) },
|
||||
onAutoPlayChange = { vm.setAutoPlay(it) },
|
||||
onAutoPauseChange = { vm.setAutoPause(it) },
|
||||
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
|
||||
onAutoConnectChange = { vm.setAutoConnect(it) },
|
||||
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
|
||||
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
|
||||
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
||||
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
|
||||
onOpenIssueTracker = { vm.openIssueTracker() },
|
||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||
)
|
||||
@@ -186,6 +189,8 @@ fun DeviceSettingsScreen(
|
||||
onNavigateUp: () -> Unit,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
|
||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
|
||||
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
|
||||
onToneVolumeChange: (Int) -> Unit = {},
|
||||
@@ -205,11 +210,11 @@ fun DeviceSettingsScreen(
|
||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||
onFixMonitorMode: () -> Unit = {},
|
||||
onOpenIssueTracker: () -> Unit = {},
|
||||
onOpenAapTracker: () -> Unit = {},
|
||||
) {
|
||||
@@ -339,17 +344,18 @@ fun DeviceSettingsScreen(
|
||||
device = device,
|
||||
features = features,
|
||||
isPro = isPro,
|
||||
monitorMode = state.monitorMode,
|
||||
onAutoPlayChange = onAutoPlayChange,
|
||||
onAutoPauseChange = onAutoPauseChange,
|
||||
onStartMusicOnWearChange = onStartMusicOnWearChange,
|
||||
onOnePodModeChange = onOnePodModeChange,
|
||||
onConversationalAwarenessChange = onConversationalAwarenessChange,
|
||||
onConversationActionChange = onConversationActionChange,
|
||||
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
|
||||
onSleepDetectionChange = onSleepDetectionChange,
|
||||
onAutoConnectChange = onAutoConnectChange,
|
||||
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
||||
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
||||
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
||||
onFixMonitorMode = onFixMonitorMode,
|
||||
onOpenIssueTracker = onOpenIssueTracker,
|
||||
)
|
||||
}
|
||||
@@ -386,7 +392,7 @@ fun DeviceSettingsScreen(
|
||||
val showSoundSection =
|
||||
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
||||
(features.hasToneVolume && toneVol != null) ||
|
||||
features.hasMicrophoneMode
|
||||
(features.hasMicrophoneMode && device.microphoneMode != null)
|
||||
if (showSoundSection) {
|
||||
item("sound_section") {
|
||||
SoundCard(
|
||||
@@ -404,7 +410,7 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
|
||||
// ── Controls ─────────────────────────────────
|
||||
val showControlsSection = features.hasStemConfig ||
|
||||
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
|
||||
+41
-22
@@ -5,8 +5,10 @@ import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
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.log
|
||||
@@ -16,9 +18,9 @@ import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
@@ -29,6 +31,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -51,7 +54,8 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||
private val timeSource: TimeSource,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
@@ -114,8 +118,9 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
|
||||
// Seed this branch so the screen can render immediately after navigation.
|
||||
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
||||
generalSettings.monitorMode.flow,
|
||||
monitorModeResolver.effectiveMode,
|
||||
profilesRepo.profiles,
|
||||
nudgeCapabilityStore.availability,
|
||||
) { args ->
|
||||
val device = args[1] as PodDevice?
|
||||
val upgrade = args[2] as UpgradeRepo.Info
|
||||
@@ -127,6 +132,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
||||
val nudgeAvailability = args[7] as NudgeAvailability
|
||||
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.id == profileId }
|
||||
?.stemActions
|
||||
@@ -142,7 +148,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
device = device,
|
||||
now = timeSource.now(),
|
||||
isPro = upgrade.isPro,
|
||||
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
|
||||
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
|
||||
isForceConnecting = forcing,
|
||||
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
|
||||
monitorMode = monitorMode,
|
||||
@@ -206,18 +212,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
if (!bluetoothManager.isNudgeAvailable) {
|
||||
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
val accepted = try {
|
||||
val result = try {
|
||||
bluetoothManager.nudgeConnection(bonded)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
||||
false
|
||||
NudgeAttemptResult.Rejected
|
||||
}
|
||||
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" }
|
||||
if (!accepted) {
|
||||
log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
|
||||
nudgeCapabilityStore.record(result)
|
||||
if (result != NudgeAttemptResult.Accepted) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
}
|
||||
} finally {
|
||||
@@ -382,6 +389,15 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
syncEarDetection(autoPause = enabled)
|
||||
}
|
||||
|
||||
fun setStartMusicOnWear(enabled: Boolean) = launch {
|
||||
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
|
||||
if (enabled && !upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
return@launch
|
||||
}
|
||||
updateProfileNow { it.copy(startMusicOnWear = enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the device-side Automatic Ear Detection setting in sync with the
|
||||
* auto-play / auto-pause reaction toggles. When either reaction is active
|
||||
@@ -405,15 +421,6 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
fun setAutoConnect(enabled: Boolean) = launch {
|
||||
log(TAG, INFO) { "setAutoConnect($enabled)" }
|
||||
updateProfileNow { it.copy(autoConnect = enabled) }
|
||||
if (enabled) {
|
||||
// Auto-connect requires ALWAYS mode to react to BLE/case/ear events when
|
||||
// nothing is connected. We intentionally do NOT revert on disable — the user
|
||||
// may have set ALWAYS manually, or another profile may still need it.
|
||||
if (generalSettings.monitorMode.value() != MonitorMode.ALWAYS) {
|
||||
log(TAG, INFO) { "Forcing monitorMode to ALWAYS because autoConnect was enabled" }
|
||||
generalSettings.monitorMode.value(MonitorMode.ALWAYS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
|
||||
@@ -431,11 +438,23 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
|
||||
}
|
||||
|
||||
fun setMonitorModeAutomatic() = launch {
|
||||
log(TAG, INFO) { "setMonitorModeAutomatic()" }
|
||||
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
|
||||
fun setConversationAction(action: ConversationAction) = launch {
|
||||
log(TAG, INFO) { "setConversationAction($action)" }
|
||||
// The action picker is only shown while Conversation Awareness is already enabled (the pod
|
||||
// emits no speaking frames otherwise), so no need to auto-enable it here.
|
||||
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
return@launch
|
||||
}
|
||||
updateProfileNow { it.copy(conversationAction = action) }
|
||||
}
|
||||
|
||||
fun setConversationVolumeReduction(percent: Int) = launch {
|
||||
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
|
||||
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
|
||||
}
|
||||
|
||||
|
||||
fun navToPressControls() = launch {
|
||||
log(TAG, INFO) { "navToPressControls()" }
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
|
||||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
|
||||
val volSwipe = device.volumeSwipe
|
||||
val volSwipeLen = device.volumeSwipeLength
|
||||
|
||||
val showPressControlsNav = features.hasStemConfig ||
|
||||
features.hasPressSpeed ||
|
||||
features.hasPressHoldDuration ||
|
||||
features.hasEndCallMuteMic
|
||||
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
||||
if (showPressControlsNav) {
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.os.Build
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
|
||||
import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.LooksOne
|
||||
import androidx.compose.material.icons.twotone.Nightlight
|
||||
import androidx.compose.material.icons.twotone.PauseCircle
|
||||
import androidx.compose.material.icons.twotone.PlayCircle
|
||||
import androidx.compose.material.icons.twotone.RecordVoiceOver
|
||||
import androidx.compose.material.icons.twotone.Workspaces
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -29,39 +31,45 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.settings.InfoBoxType
|
||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||
import eu.darken.capod.common.settings.SettingsInfoBox
|
||||
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.main.core.MonitorMode
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
|
||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
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.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
|
||||
@Composable
|
||||
internal fun ReactionsCard(
|
||||
device: PodDevice,
|
||||
features: PodModel.Features,
|
||||
isPro: Boolean,
|
||||
monitorMode: MonitorMode,
|
||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||
onSleepDetectionChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||
onFixMonitorMode: () -> Unit = {},
|
||||
onOpenIssueTracker: () -> Unit = {},
|
||||
) {
|
||||
val reactions = device.reactions
|
||||
val enabled = device.isAapReady
|
||||
|
||||
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
||||
var showConversationActionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
||||
if (features.hasEarDetection) {
|
||||
@@ -73,6 +81,16 @@ internal fun ReactionsCard(
|
||||
onCheckedChange = onAutoPlayChange,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (reactions.autoPlay) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.PlayCircle,
|
||||
title = stringResource(R.string.settings_start_music_on_wear_label),
|
||||
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
|
||||
checked = reactions.startMusicOnWear,
|
||||
onCheckedChange = onStartMusicOnWearChange,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
}
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.PauseCircle,
|
||||
title = stringResource(R.string.settings_autopause_label),
|
||||
@@ -117,20 +135,45 @@ internal fun ReactionsCard(
|
||||
}
|
||||
if (device.isAapConnected) {
|
||||
val convAwareness = device.conversationalAwareness
|
||||
val hasAnyAapReaction =
|
||||
(features.hasConversationAwareness && convAwareness != null) ||
|
||||
features.hasSleepDetection
|
||||
if (features.hasConversationAwareness && convAwareness != null) {
|
||||
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
|
||||
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
|
||||
if (showConversationAwareness) {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.Hearing,
|
||||
title = stringResource(R.string.conversation_awareness_label),
|
||||
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
|
||||
checked = convAwareness.enabled,
|
||||
checked = convAwareness?.enabled == true,
|
||||
onCheckedChange = onConversationalAwarenessChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
|
||||
// speaking frames otherwise), so only surface it once CA is actually enabled.
|
||||
if (convAwareness?.enabled == true) {
|
||||
SettingsBaseItem(
|
||||
icon = Icons.TwoTone.RecordVoiceOver,
|
||||
title = stringResource(R.string.settings_conversation_action_label),
|
||||
subtitle = stringResource(reactions.conversationAction.labelRes),
|
||||
onClick = { showConversationActionDialog = true },
|
||||
enabled = enabled,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
|
||||
SettingsSliderItem(
|
||||
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
|
||||
title = stringResource(R.string.settings_conversation_volume_reduction_label),
|
||||
value = reactions.conversationVolumeReduction.toFloat(),
|
||||
onValueChange = { onConversationVolumeReductionChange(it.toInt()) },
|
||||
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
|
||||
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
|
||||
enabled = enabled,
|
||||
valueLabel = { "${it.toInt()}%" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (features.hasSleepDetection) {
|
||||
if (showConversationAwareness) ReactionsDivider()
|
||||
val sleepDet = device.sleepDetection
|
||||
?: AapSetting.SleepDetection(enabled = true)
|
||||
SettingsSwitchItem(
|
||||
@@ -198,23 +241,24 @@ internal fun ReactionsCard(
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
|
||||
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) {
|
||||
SettingsInfoBox(
|
||||
text = stringResource(R.string.settings_popup_warning_manual_mode),
|
||||
type = InfoBoxType.WARNING,
|
||||
action = {
|
||||
TextButton(onClick = onFixMonitorMode) {
|
||||
Text(stringResource(R.string.general_fix_it_action))
|
||||
}
|
||||
},
|
||||
)
|
||||
} else if (anyPopupEnabled) {
|
||||
if (anyPopupEnabled) {
|
||||
SettingsInfoBox(
|
||||
text = stringResource(R.string.settings_popup_info_not_in_app),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showConversationActionDialog) {
|
||||
ConversationActionDialog(
|
||||
current = reactions.conversationAction,
|
||||
onSelect = {
|
||||
onConversationActionChange(it)
|
||||
showConversationActionDialog = false
|
||||
},
|
||||
onDismiss = { showConversationActionDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showAutoConnectConditionDialog) {
|
||||
AutoConnectConditionDialog(
|
||||
current = reactions.autoConnectCondition,
|
||||
@@ -246,7 +290,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
|
||||
device = device,
|
||||
features = device.model.features,
|
||||
isPro = state.isPro,
|
||||
monitorMode = state.monitorMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -259,6 +302,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
|
||||
device = device,
|
||||
features = device.model.features,
|
||||
isPro = state.isPro,
|
||||
monitorMode = state.monitorMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ internal fun SoundCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (features.hasMicrophoneMode) {
|
||||
if (features.hasMicrophoneMode && device.microphoneMode != null) {
|
||||
if (isPro) {
|
||||
val micMode = device.microphoneMode
|
||||
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
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.semantics.Role
|
||||
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
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
|
||||
@Composable
|
||||
internal fun ConversationActionDialog(
|
||||
current: ConversationAction,
|
||||
onSelect: (ConversationAction) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.settings_conversation_action_label)) },
|
||||
text = {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
ConversationAction.entries.forEach { action ->
|
||||
val isSelected = action == current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { onSelect(action) },
|
||||
role = Role.RadioButton,
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = isSelected, onClick = null)
|
||||
Text(
|
||||
text = stringResource(action.labelRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun ConversationActionDialogPreview() = PreviewWrapper {
|
||||
ConversationActionDialog(
|
||||
current = ConversationAction.LOWER_VOLUME,
|
||||
onSelect = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
@@ -327,7 +327,7 @@ fun OverviewScreen(
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
isPro = state.upgradeInfo.isPro,
|
||||
showDebug = state.isDebugMode,
|
||||
showDebug = state.isDebug,
|
||||
now = state.now,
|
||||
isCollapsed = isCollapsed,
|
||||
onToggleCollapse = if (isToggleable) {
|
||||
@@ -376,7 +376,7 @@ fun OverviewScreen(
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
isPro = state.upgradeInfo.isPro,
|
||||
showDebug = state.isDebugMode,
|
||||
showDebug = state.isDebug,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onUpgrade = onUpgrade,
|
||||
@@ -438,7 +438,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
@@ -465,7 +465,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
@@ -488,7 +488,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = emptyList(),
|
||||
upgradeInfo = MockPodDataProvider.gplayInfo(),
|
||||
@@ -511,7 +511,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = false,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
||||
|
||||
@@ -5,8 +5,7 @@ import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
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.log
|
||||
@@ -22,7 +21,9 @@ import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.tierRank
|
||||
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.protocol.AapCommand
|
||||
@@ -33,12 +34,13 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -49,11 +51,11 @@ class OverviewViewModel @Inject constructor(
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val generalSettings: GeneralSettings,
|
||||
debugSettings: DebugSettings,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val timeSource: TimeSource,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
@@ -76,29 +78,30 @@ class OverviewViewModel @Inject constructor(
|
||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
val workerAutolaunch = permissionTool.missingScanPermissions
|
||||
.onEach { permissions ->
|
||||
if (permissions.isNotEmpty()) {
|
||||
log(TAG) { "Missing scan permissions: $permissions" }
|
||||
val workerAutolaunch = combine(
|
||||
permissionTool.missingScanPermissions,
|
||||
monitorModeResolver.effectiveMode,
|
||||
// BluetoothManager2.connectedDevices is gated by a slow HEADSET-profile lookup. Without
|
||||
// an onStart seed the combine wouldn't fire until that profile is bound, so ALWAYS mode
|
||||
// would silently delay autolaunch until the first connected-device emission.
|
||||
bluetoothManager.connectedDevices
|
||||
.onStart { emit(emptyList()) }
|
||||
.map { it.isNotEmpty() }
|
||||
.distinctUntilChanged(),
|
||||
) { missing, mode, anyConnected -> Triple(missing, mode, anyConnected) }
|
||||
.distinctUntilChanged()
|
||||
.onEach { (missing, mode, anyConnected) ->
|
||||
if (missing.isNotEmpty()) {
|
||||
log(TAG) { "Missing scan permissions: $missing" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
|
||||
val shouldStart = when (mode) {
|
||||
MonitorMode.MANUAL -> false
|
||||
MonitorMode.AUTOMATIC -> {
|
||||
try {
|
||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
||||
devices?.isNotEmpty() == true
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
MonitorMode.AUTOMATIC -> anyConnected
|
||||
MonitorMode.ALWAYS -> true
|
||||
}
|
||||
if (shouldStart) {
|
||||
log(TAG) { "Starting monitor" }
|
||||
log(TAG) { "Starting monitor (mode=$mode)" }
|
||||
monitorControl.startMonitor()
|
||||
}
|
||||
}
|
||||
@@ -125,7 +128,7 @@ class OverviewViewModel @Inject constructor(
|
||||
updateTicker,
|
||||
permissionTool.missingPermissions,
|
||||
pods,
|
||||
debugSettings.isDebugModeEnabled.flow,
|
||||
Bugs.isDebug,
|
||||
bluetoothManager.isBluetoothEnabled,
|
||||
profilesRepo.profiles,
|
||||
upgradeRepo.upgradeInfo,
|
||||
@@ -133,7 +136,7 @@ class OverviewViewModel @Inject constructor(
|
||||
userExpansionOverrides,
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
profilesRepo.hadLegacyReactionData,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
||||
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
||||
// Prune stale overrides (profiles that no longer exist)
|
||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||
@@ -142,7 +145,7 @@ class OverviewViewModel @Inject constructor(
|
||||
now = timeSource.now(),
|
||||
permissions = permissions,
|
||||
devices = devices,
|
||||
isDebugMode = isDebugMode,
|
||||
isDebug = isDebug,
|
||||
isBluetoothEnabled = isBluetoothEnabled,
|
||||
profiles = profiles,
|
||||
upgradeInfo = upgradeInfo,
|
||||
@@ -158,7 +161,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val now: Instant,
|
||||
val permissions: Set<Permission>,
|
||||
val devices: List<PodDevice>,
|
||||
val isDebugMode: Boolean,
|
||||
val isDebug: Boolean,
|
||||
val isBluetoothEnabled: Boolean,
|
||||
val profiles: List<DeviceProfile>,
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
@@ -175,7 +178,7 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
val profiledDevices: List<PodDevice> by lazy {
|
||||
devices.filter { it.profileId != null }.sortedWith(
|
||||
compareBy<PodDevice> { deviceTierRank(it) }
|
||||
compareBy<PodDevice> { it.tierRank() }
|
||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||
)
|
||||
}
|
||||
@@ -284,12 +287,5 @@ class OverviewViewModel @Inject constructor(
|
||||
companion object {
|
||||
private const val FREE_DEVICE_LIMIT = 1
|
||||
private val TAG = logTag("Overview", "VM")
|
||||
|
||||
/** Connection tier rank for sorting: lower = higher priority. */
|
||||
internal fun deviceTierRank(device: PodDevice): Int = when {
|
||||
device.isSystemConnected -> 0
|
||||
device.isLive -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,6 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
) {
|
||||
PodGauge(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
batteryPercent = device.batteryLeft,
|
||||
chargingState = device.leftPodChargingState
|
||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isLeftInEar ?: false,
|
||||
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
|
||||
PodGauge(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
batteryPercent = device.batteryRight,
|
||||
chargingState = device.rightPodChargingState
|
||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isRightInEar ?: false,
|
||||
@@ -360,7 +358,7 @@ private fun PodGauge(
|
||||
|
||||
// Battery percentage
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
@@ -412,7 +410,7 @@ private fun CaseRow(
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase.toBatteryFloat(),
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
|
||||
@@ -60,7 +60,9 @@ import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
||||
val percent = device.batteryHeadset
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "gaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(88.dp),
|
||||
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
|
||||
// Battery text inside ring
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (device.batteryHeadset != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
+17
-15
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
|
||||
@Composable
|
||||
fun CompactBatterySummary(
|
||||
device: PodDevice,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasAnyBattery = device.batteryLeft != null
|
||||
|| device.batteryRight != null
|
||||
|| device.batteryHeadset != null
|
||||
|| device.batteryCase != null
|
||||
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||
|| isKnownBattery(device.batteryRight)
|
||||
|| isKnownBattery(device.batteryHeadset)
|
||||
|| isKnownBattery(device.batteryCase)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
||||
percent = device.batteryRight,
|
||||
)
|
||||
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
||||
iconRes = null,
|
||||
percent = device.batteryHeadset,
|
||||
)
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
||||
@Composable
|
||||
private fun MiniPodRing(
|
||||
iconRes: Int?,
|
||||
percent: Float?,
|
||||
percent: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = percent?.coerceIn(0f, 1f)
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "miniGaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(28.dp),
|
||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (percent != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase ?: -1f,
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
.height(6.dp),
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
package eu.darken.capod.main.ui.settings.general
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.twotone.AccountTree
|
||||
import androidx.compose.material.icons.twotone.BugReport
|
||||
import androidx.compose.material.icons.twotone.Contrast
|
||||
import androidx.compose.material.icons.twotone.DarkMode
|
||||
import androidx.compose.material.icons.twotone.DisabledVisible
|
||||
import androidx.compose.material.icons.twotone.FilterList
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.twotone.Notifications
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
@@ -55,7 +42,6 @@ import eu.darken.capod.common.theming.ThemeColor
|
||||
import eu.darken.capod.common.theming.ThemeMode
|
||||
import eu.darken.capod.common.theming.ThemeState
|
||||
import eu.darken.capod.common.theming.ThemeStyle
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
|
||||
@Composable
|
||||
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
@@ -67,10 +53,8 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
GeneralSettingsScreen(
|
||||
state = it,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
|
||||
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
||||
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
||||
onDebugSettings = { vm.goToDebugSettings() },
|
||||
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
|
||||
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
|
||||
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
|
||||
@@ -86,10 +70,8 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
fun GeneralSettingsScreen(
|
||||
state: GeneralSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onMonitorModeSelected: (MonitorMode) -> Unit,
|
||||
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
||||
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
||||
onDebugSettings: () -> Unit,
|
||||
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
|
||||
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
|
||||
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
|
||||
@@ -99,7 +81,6 @@ fun GeneralSettingsScreen(
|
||||
onUpgrade: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMonitorModeDialog by remember { mutableStateOf(false) }
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
||||
@@ -122,17 +103,6 @@ fun GeneralSettingsScreen(
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_monitoring_label))
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_monitor_mode_label),
|
||||
subtitle = stringResource(state.monitorMode.labelRes),
|
||||
icon = Icons.TwoTone.DisabledVisible,
|
||||
onClick = { showMonitorModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
||||
}
|
||||
@@ -227,14 +197,6 @@ fun GeneralSettingsScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_debug_label),
|
||||
subtitle = stringResource(R.string.settings_debug_description),
|
||||
icon = Icons.TwoTone.BugReport,
|
||||
onClick = onDebugSettings,
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
|
||||
}
|
||||
@@ -286,20 +248,6 @@ fun GeneralSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonitorModeDialog) {
|
||||
ListPreferenceDialog(
|
||||
title = stringResource(R.string.settings_monitor_mode_label),
|
||||
entries = MonitorMode.entries,
|
||||
selectedEntry = state.monitorMode,
|
||||
onEntrySelected = {
|
||||
onMonitorModeSelected(it)
|
||||
showMonitorModeDialog = false
|
||||
},
|
||||
entryLabel = { stringResource(it.labelRes) },
|
||||
onDismiss = { showMonitorModeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showColorDialog) {
|
||||
ThemeColorSelectorDialog(
|
||||
selectedColor = state.themeState.color,
|
||||
@@ -314,7 +262,6 @@ fun GeneralSettingsScreen(
|
||||
|
||||
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
||||
isPro = isPro,
|
||||
monitorMode = MonitorMode.AUTOMATIC,
|
||||
showConnectedNotification = true,
|
||||
keepNotificationAfterDisconnect = false,
|
||||
isOffloadedFilteringDisabled = false,
|
||||
@@ -329,10 +276,8 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
||||
GeneralSettingsScreen(
|
||||
state = previewGeneralState(isPro = true),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
onOffloadedFilteringDisabledChanged = {},
|
||||
onOffloadedBatchingDisabledChanged = {},
|
||||
onUseIndirectScanResultCallbackChanged = {},
|
||||
@@ -345,60 +290,10 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
||||
GeneralSettingsScreen(
|
||||
state = previewGeneralState(isPro = false),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
onOffloadedFilteringDisabledChanged = {},
|
||||
onOffloadedBatchingDisabledChanged = {},
|
||||
onUseIndirectScanResultCallbackChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ListPreferenceDialog(
|
||||
title: String,
|
||||
entries: List<T>,
|
||||
selectedEntry: T,
|
||||
onEntrySelected: (T) -> Unit,
|
||||
entryLabel: @Composable (T) -> String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
entries.forEach { entry ->
|
||||
val isSelected = entry == selectedEntry
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { onEntrySelected(entry) },
|
||||
role = Role.RadioButton,
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
)
|
||||
Text(
|
||||
text = entryLabel(entry),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+4
-18
@@ -13,7 +13,6 @@ import eu.darken.capod.common.theming.ThemeStyle
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.themeState
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -30,7 +29,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
|
||||
data class State(
|
||||
val isPro: Boolean,
|
||||
val monitorMode: MonitorMode,
|
||||
val showConnectedNotification: Boolean,
|
||||
val keepNotificationAfterDisconnect: Boolean,
|
||||
val isOffloadedFilteringDisabled: Boolean,
|
||||
@@ -43,12 +41,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
|
||||
val state = combine(
|
||||
combine(
|
||||
generalSettings.monitorMode.flow,
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { monitorMode, showNotif, keepNotif ->
|
||||
) { showNotif, keepNotif ->
|
||||
@Suppress("USELESS_CAST")
|
||||
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
|
||||
arrayOf<Any>(showNotif as Any, keepNotif as Any)
|
||||
},
|
||||
combine(
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -63,9 +60,8 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
) { general, compat, themeState, isPro ->
|
||||
State(
|
||||
isPro = isPro,
|
||||
monitorMode = general[0] as MonitorMode,
|
||||
showConnectedNotification = general[1] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[2] as Boolean,
|
||||
showConnectedNotification = general[0] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[1] as Boolean,
|
||||
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||
@@ -73,11 +69,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
fun setMonitorMode(mode: MonitorMode) {
|
||||
log(TAG, INFO) { "setMonitorMode($mode)" }
|
||||
generalSettings.monitorMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setShowConnectedNotification(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
||||
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
||||
@@ -135,11 +126,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
|
||||
fun goToDebugSettings() {
|
||||
log(TAG, INFO) { "goToDebugSettings()" }
|
||||
navTo(Nav.Settings.Debug)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Settings", "General", "VM")
|
||||
}
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package eu.darken.capod.main.ui.settings.general.debug
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
package eu.darken.capod.main.ui.settings.general.debug
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.twotone.BugReport
|
||||
import androidx.compose.material.icons.twotone.DataArray
|
||||
import androidx.compose.material.icons.twotone.DevicesOther
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||
|
||||
@Composable
|
||||
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
state?.let {
|
||||
DebugSettingsScreen(
|
||||
state = it,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
|
||||
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
|
||||
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DebugSettingsScreen(
|
||||
state: DebugSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onDebugModeChanged: (Boolean) -> Unit,
|
||||
onShowFakeDataChanged: (Boolean) -> Unit,
|
||||
onShowUnfilteredChanged: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(R.string.settings_debug_label)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_debug_mode_label),
|
||||
subtitle = stringResource(R.string.settings_debug_mode_description),
|
||||
icon = Icons.TwoTone.BugReport,
|
||||
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.isDebugModeEnabled,
|
||||
onCheckedChange = onDebugModeChanged,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_fake_data_label),
|
||||
subtitle = stringResource(R.string.settings_fake_data_description),
|
||||
icon = Icons.TwoTone.DataArray,
|
||||
onClick = { onShowFakeDataChanged(!state.showFakeData) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.showFakeData,
|
||||
onCheckedChange = onShowFakeDataChanged,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_blescanner_unfiltered_label),
|
||||
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
|
||||
icon = Icons.TwoTone.DevicesOther,
|
||||
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.showUnfiltered,
|
||||
onCheckedChange = onShowUnfilteredChanged,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DebugSettingsScreenPreview() = PreviewWrapper {
|
||||
DebugSettingsScreen(
|
||||
state = DebugSettingsViewModel.State(
|
||||
isDebugModeEnabled = true,
|
||||
showFakeData = false,
|
||||
showUnfiltered = false,
|
||||
),
|
||||
onNavigateUp = {},
|
||||
onDebugModeChanged = {},
|
||||
onShowFakeDataChanged = {},
|
||||
onShowUnfilteredChanged = {},
|
||||
)
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package eu.darken.capod.main.ui.settings.general.debug
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import javax.inject.Inject
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@HiltViewModel
|
||||
class DebugSettingsViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val debugSettings: DebugSettings,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
data class State(
|
||||
val isDebugModeEnabled: Boolean,
|
||||
val showFakeData: Boolean,
|
||||
val showUnfiltered: Boolean,
|
||||
)
|
||||
|
||||
val state = combine(
|
||||
debugSettings.isDebugModeEnabled.flow,
|
||||
debugSettings.showFakeData.flow,
|
||||
debugSettings.showUnfiltered.flow,
|
||||
) { debugMode, fakeData, unfiltered ->
|
||||
State(
|
||||
isDebugModeEnabled = debugMode,
|
||||
showFakeData = fakeData,
|
||||
showUnfiltered = unfiltered,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
fun setDebugModeEnabled(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setDebugModeEnabled($enabled)" }
|
||||
debugSettings.isDebugModeEnabled.valueBlocking = enabled
|
||||
}
|
||||
|
||||
fun setShowFakeData(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowFakeData($enabled)" }
|
||||
debugSettings.showFakeData.valueBlocking = enabled
|
||||
}
|
||||
|
||||
fun setShowUnfiltered(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowUnfiltered($enabled)" }
|
||||
debugSettings.showUnfiltered.valueBlocking = enabled
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Settings", "Debug", "VM")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Process-scoped trailing-edge debouncer for tile-driven `SetAncMode` commands.
|
||||
*
|
||||
* The QS panel collapses after each tap, destroying the [AncTileService] instance.
|
||||
* If the pending-job state lived on the service, taps across consecutive panel
|
||||
* sessions wouldn't cancel each other — every tap would fire a separate
|
||||
* `SetAncMode`, overwhelming the AAP verification loop and triggering
|
||||
* "Rejected after retry" storms that leave the device unresponsive until the
|
||||
* app restarts. Keeping the job here, on a `@Singleton`, means a tap in panel
|
||||
* session B can cancel the deferred send queued by panel session A.
|
||||
*/
|
||||
@Singleton
|
||||
class AncTileSendCoordinator @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val aapManager: AapConnectionManager,
|
||||
) {
|
||||
|
||||
private val lock = Any()
|
||||
private val pendingJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||
private val timeoutJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||
|
||||
private val _pendingModes = MutableStateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>>(emptyMap())
|
||||
val pendingModes: StateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>> = _pendingModes.asStateFlow()
|
||||
|
||||
fun scheduleSetAncMode(
|
||||
address: BluetoothAddress,
|
||||
mode: AapSetting.AncMode.Value,
|
||||
debounce: Duration,
|
||||
timeout: Duration = 5.seconds,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val replacing = pendingJobs[address]?.isActive == true
|
||||
log(TAG, VERBOSE) { "scheduleSetAncMode($mode, addr=$address, debounce=$debounce, replacingPending=$replacing)" }
|
||||
_pendingModes.value = _pendingModes.value + (address to mode)
|
||||
|
||||
pendingJobs.remove(address)?.cancel()
|
||||
timeoutJobs.remove(address)?.cancel()
|
||||
|
||||
pendingJobs[address] = appScope.launch {
|
||||
delay(debounce)
|
||||
log(TAG, VERBOSE) { "debounce elapsed, dispatching SetAncMode($mode) to AAP for $address" }
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
|
||||
log(TAG, VERBOSE) { "sent SetAncMode($mode) to $address" }
|
||||
} catch (e: CancellationException) {
|
||||
log(TAG, VERBOSE) { "send for $mode cancelled (newer tap superseded it)" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "sendCommand failed: ${e.asLog()}" }
|
||||
clearPendingTarget(address, mode)
|
||||
}
|
||||
}
|
||||
|
||||
timeoutJobs[address] = appScope.launch {
|
||||
delay(timeout)
|
||||
if (clearPendingTargetFromTimeout(address, mode)) {
|
||||
log(TAG, VERBOSE) { "pending tile target $mode timed out before device confirmation" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applyPendingTarget(state: AncTileState): AncTileState {
|
||||
val active = state as? AncTileState.Active ?: return state
|
||||
val address = active.deviceAddress ?: return active
|
||||
val target = pendingModes.value[address] ?: return active
|
||||
|
||||
if (target !in active.visible) return active
|
||||
|
||||
if (active.isConfirmed(target)) return active
|
||||
|
||||
return active.copy(pending = target)
|
||||
}
|
||||
|
||||
internal fun acknowledgeDeviceState(state: AncTileState) {
|
||||
val active = state as? AncTileState.Active ?: return
|
||||
val address = active.deviceAddress ?: return
|
||||
val target = pendingModes.value[address] ?: return
|
||||
|
||||
if (target !in active.visible || active.isConfirmed(target)) {
|
||||
clearPendingTarget(address, target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AncTileState.Active.isConfirmed(target: AapSetting.AncMode.Value): Boolean =
|
||||
pending == target || (current == target && pending == null)
|
||||
|
||||
private fun clearPendingTarget(
|
||||
address: BluetoothAddress,
|
||||
expectedMode: AapSetting.AncMode.Value,
|
||||
): Boolean = synchronized(lock) {
|
||||
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||
if (current != expectedMode) return@synchronized false
|
||||
|
||||
_pendingModes.value = _pendingModes.value - address
|
||||
pendingJobs.remove(address)?.cancel()
|
||||
timeoutJobs.remove(address)?.cancel()
|
||||
true
|
||||
}
|
||||
|
||||
private fun clearPendingTargetFromTimeout(
|
||||
address: BluetoothAddress,
|
||||
expectedMode: AapSetting.AncMode.Value,
|
||||
): Boolean = synchronized(lock) {
|
||||
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||
if (current != expectedMode) return@synchronized false
|
||||
|
||||
_pendingModes.value = _pendingModes.value - address
|
||||
pendingJobs.remove(address)?.cancel()
|
||||
timeoutJobs.remove(address)
|
||||
true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Tile", "Anc", "Coord")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.SystemClock
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.hasApiLevel
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||
import eu.darken.capod.main.ui.components.shortLabel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Quick Settings tile that cycles ANC modes for the user-perceived primary AirPods.
|
||||
* Mirrors [eu.darken.capod.main.ui.widget.AncGlanceWidget] but renders into the system
|
||||
* QS panel instead of a home-screen widget.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AncTileService : TileService() {
|
||||
|
||||
@Inject lateinit var dispatcherProvider: DispatcherProvider
|
||||
@Inject lateinit var sendCoordinator: AncTileSendCoordinator
|
||||
@Inject lateinit var stateStore: AncTileStateStore
|
||||
|
||||
private var listenScope: CoroutineScope? = null
|
||||
private val instanceId = Integer.toHexString(System.identityHashCode(this))
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
log(TAG, VERBOSE) { "onCreate(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||
}
|
||||
|
||||
override fun onTileAdded() {
|
||||
log(TAG, VERBOSE) { "onTileAdded(instance=$instanceId)" }
|
||||
// Render a static placeholder so we never block SystemUI's bind path on a
|
||||
// first-emission DataStore/Billing read. onStartListening will fill in real state.
|
||||
renderTile(AncTileState.Connecting)
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
log(TAG, VERBOSE) { "onStartListening(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||
listenScope?.cancel()
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.Default)
|
||||
listenScope = scope
|
||||
scope.launch {
|
||||
stateStore.state.throttleLatest(250).collect { state ->
|
||||
log(TAG, VERBOSE) { "collector: state=$state" }
|
||||
withContext(dispatcherProvider.Main) { renderTile(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
log(TAG, VERBOSE) { "onStopListening(instance=$instanceId)" }
|
||||
listenScope?.cancel()
|
||||
listenScope = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
log(TAG, VERBOSE) { "onDestroy(instance=$instanceId)" }
|
||||
lastDestroyAt = now
|
||||
listenScope?.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
log(TAG, VERBOSE) { "onClick(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()}) received tap" }
|
||||
val state = stateStore.currentState()
|
||||
log(TAG, VERBOSE) {
|
||||
"onClick: resolved from state store=$state"
|
||||
}
|
||||
dispatchClick(state)
|
||||
}
|
||||
|
||||
private fun dispatchClick(state: AncTileState) {
|
||||
log(TAG, VERBOSE) { "dispatchClick($state)" }
|
||||
when (state) {
|
||||
AncTileState.NotPro,
|
||||
AncTileState.PermissionRequired -> openMainActivityWithUpgrade()
|
||||
is AncTileState.Active -> sendNextMode(state)
|
||||
AncTileState.BluetoothOff,
|
||||
AncTileState.NoDevice,
|
||||
AncTileState.NoAncSupport,
|
||||
AncTileState.NotConnected,
|
||||
AncTileState.Connecting -> {
|
||||
// Tile state is STATE_UNAVAILABLE; system shouldn't deliver clicks here,
|
||||
// but defensively no-op so we don't crash on unexpected delivery.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNextMode(state: AncTileState.Active) {
|
||||
val nextMode = pickNextMode(state.visible, state.current, state.pending)
|
||||
log(TAG, VERBOSE) {
|
||||
"sendNextMode: visible=${state.visible} current=${state.current} pending=${state.pending} -> next=$nextMode"
|
||||
}
|
||||
if (nextMode == null || nextMode == (state.pending ?: state.current)) {
|
||||
log(TAG, VERBOSE) { "sendNextMode: no advance possible (visible=${state.visible})" }
|
||||
return
|
||||
}
|
||||
val address: BluetoothAddress = state.deviceAddress ?: run {
|
||||
log(TAG, WARN) { "sendNextMode: state has no device address" }
|
||||
return
|
||||
}
|
||||
|
||||
sendCoordinator.scheduleSetAncMode(address, nextMode, 1.seconds)
|
||||
|
||||
val optimisticState = state.copy(pending = nextMode)
|
||||
renderTile(optimisticState)
|
||||
}
|
||||
|
||||
private fun openMainActivityWithUpgrade() {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
|
||||
}
|
||||
if (hasApiLevel(34)) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderTile(state: AncTileState) {
|
||||
val tile = qsTile ?: run {
|
||||
log(TAG, VERBOSE) { "renderTile: skipped, qsTile is null (state=$state)" }
|
||||
return
|
||||
}
|
||||
log(TAG, VERBOSE) { "renderTile: $state" }
|
||||
val baseLabel = getString(R.string.tile_anc_label)
|
||||
|
||||
val (subtitle, iconRes, tileState) = when (state) {
|
||||
AncTileState.NotPro -> Triple(
|
||||
getString(R.string.common_upgrade_required_label),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_INACTIVE,
|
||||
)
|
||||
AncTileState.PermissionRequired -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_permission_required),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_INACTIVE,
|
||||
)
|
||||
AncTileState.BluetoothOff -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_bluetooth_off),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.NoDevice -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_no_device),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.NoAncSupport -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_no_anc_support),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.NotConnected -> Triple(
|
||||
getString(R.string.anc_widget_aap_not_connected_label),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.Connecting -> Triple(
|
||||
getString(R.string.anc_widget_aap_connecting_label),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
is AncTileState.Active -> {
|
||||
val displayMode = state.pending?.takeIf { it in state.visible } ?: state.current
|
||||
Triple(
|
||||
displayMode.shortLabel(this),
|
||||
displayMode.iconDrawableRes(),
|
||||
Tile.STATE_ACTIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tile.icon = Icon.createWithResource(this, iconRes)
|
||||
tile.state = tileState
|
||||
if (hasApiLevel(29)) {
|
||||
tile.label = baseLabel
|
||||
tile.subtitle = subtitle
|
||||
} else {
|
||||
// Pre-API 29 tiles can't show a subtitle; fold it into the label.
|
||||
tile.label = "$baseLabel · $subtitle"
|
||||
}
|
||||
if (hasApiLevel(30)) {
|
||||
tile.stateDescription = subtitle
|
||||
}
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Tile", "Anc")
|
||||
@Volatile private var lastDestroyAt: Long? = null
|
||||
|
||||
private fun elapsedSinceLastDestroy(): String {
|
||||
val destroyedAt = lastDestroyAt ?: return "n/a"
|
||||
return "${SystemClock.elapsedRealtime() - destroyedAt}ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Pure data → data mapper. Decides which [AncTileState] to render given a snapshot
|
||||
* of inputs. Mirrors [AncWidgetRenderStateMapper]'s precedence so the two surfaces
|
||||
* stay in sync, while remaining unit-testable without Android resources.
|
||||
*
|
||||
* Note: cached/offline-tier devices land in [AncTileState.NotConnected] (not
|
||||
* [AncTileState.Connecting]) — without an active AAP session, "Connecting…" would
|
||||
* be misleading because nothing is in progress.
|
||||
*/
|
||||
object AncTileStateMapper {
|
||||
|
||||
fun map(
|
||||
device: PodDevice?,
|
||||
isPro: Boolean,
|
||||
isBluetoothEnabled: Boolean,
|
||||
missingPermissions: Set<Permission>,
|
||||
): AncTileState {
|
||||
if (!isPro) return AncTileState.NotPro
|
||||
if (missingPermissions.any { it.isTileBlocking }) return AncTileState.PermissionRequired
|
||||
if (!isBluetoothEnabled) return AncTileState.BluetoothOff
|
||||
if (device == null) return AncTileState.NoDevice
|
||||
if (!device.hasAncControl) return AncTileState.NoAncSupport
|
||||
if (!device.isAapConnected) return AncTileState.NotConnected
|
||||
if (!device.isAapReady) return AncTileState.Connecting
|
||||
|
||||
val ancMode = device.ancMode ?: return AncTileState.Connecting
|
||||
val visible = device.visibleAncModes
|
||||
if (visible.isEmpty()) return AncTileState.Connecting
|
||||
|
||||
return AncTileState.Active(
|
||||
current = ancMode.current,
|
||||
pending = device.pendingAncMode,
|
||||
visible = visible,
|
||||
deviceLabel = device.label,
|
||||
deviceAddress = device.address,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A permission whose absence prevents the tile from working. Scan-blocking permissions
|
||||
* gate BLE; [Permission.BLUETOOTH_CONNECT] gates the AAP L2CAP socket — without either,
|
||||
* the tile cannot do useful work.
|
||||
*/
|
||||
private val Permission.isTileBlocking: Boolean
|
||||
get() = isScanBlocking || this == Permission.BLUETOOTH_CONNECT
|
||||
|
||||
sealed interface AncTileState {
|
||||
data object NotPro : AncTileState
|
||||
data object PermissionRequired : AncTileState
|
||||
data object BluetoothOff : AncTileState
|
||||
data object NoDevice : AncTileState
|
||||
data object NoAncSupport : AncTileState
|
||||
data object NotConnected : AncTileState
|
||||
data object Connecting : AncTileState
|
||||
data class Active(
|
||||
val current: AapSetting.AncMode.Value,
|
||||
val pending: AapSetting.AncMode.Value?,
|
||||
val visible: List<AapSetting.AncMode.Value>,
|
||||
val deviceLabel: String?,
|
||||
val deviceAddress: BluetoothAddress?,
|
||||
) : AncTileState
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles to the next mode in [visible]. Anchors on [pending] when it's still in
|
||||
* [visible] so rapid taps walk forward through the list rather than oscillate against
|
||||
* the device-echoed [current]. A [pending] that has been filtered out (e.g. user toggled
|
||||
* Allow Off mid-cycle) falls through to [current].
|
||||
*/
|
||||
internal fun pickNextMode(
|
||||
visible: List<AapSetting.AncMode.Value>,
|
||||
current: AapSetting.AncMode.Value?,
|
||||
pending: AapSetting.AncMode.Value?,
|
||||
): AapSetting.AncMode.Value? {
|
||||
if (visible.isEmpty()) return null
|
||||
val anchor = pending?.takeIf { it in visible } ?: current ?: return visible.first()
|
||||
val idx = visible.indexOf(anchor)
|
||||
return if (idx < 0) visible.first() else visible[(idx + 1) % visible.size]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.flow.combine
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.primaryByTier
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.combine as combineFlows
|
||||
|
||||
/**
|
||||
* Process-scoped state holder for the ANC QS tile.
|
||||
*
|
||||
* TileService instances are short-lived and can disappear between taps. Keeping
|
||||
* the latest state here lets a recreated service resolve clicks synchronously
|
||||
* while the app process is alive. A cold or hydrating process intentionally
|
||||
* reports [AncTileState.Connecting] so the tile UI is honest about not being
|
||||
* ready for input yet.
|
||||
*/
|
||||
@Singleton
|
||||
class AncTileStateStore @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
deviceMonitor: DeviceMonitor,
|
||||
profilesRepo: DeviceProfilesRepo,
|
||||
upgradeRepo: UpgradeRepo,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
permissionTool: PermissionTool,
|
||||
private val sendCoordinator: AncTileSendCoordinator,
|
||||
) {
|
||||
|
||||
private val rawState: StateFlow<AncTileState> = combine(
|
||||
deviceMonitor.devices,
|
||||
profilesRepo.profiles,
|
||||
upgradeRepo.upgradeInfo.map { it.isPro },
|
||||
bluetoothManager.isBluetoothEnabled,
|
||||
permissionTool.missingPermissions,
|
||||
) { devices, profiles, isPro, isBluetoothEnabled, missingPermissions ->
|
||||
val profileOrder = profiles.mapIndexed { idx, p -> p.id to idx }.toMap()
|
||||
val device = devices.primaryByTier(profileOrder)
|
||||
AncTileStateMapper.map(
|
||||
device = device,
|
||||
isPro = isPro,
|
||||
isBluetoothEnabled = isBluetoothEnabled,
|
||||
missingPermissions = missingPermissions,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = AncTileState.Connecting,
|
||||
)
|
||||
|
||||
init {
|
||||
appScope.launch {
|
||||
rawState.collect { state -> sendCoordinator.acknowledgeDeviceState(state) }
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<AncTileState> = combineFlows(
|
||||
rawState,
|
||||
sendCoordinator.pendingModes,
|
||||
) { rawState, _ ->
|
||||
sendCoordinator.applyPendingTarget(rawState)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = AncTileState.Connecting,
|
||||
)
|
||||
|
||||
fun currentState(): AncTileState = sendCoordinator.applyPendingTarget(rawState.value)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||
import eu.darken.capod.main.ui.components.shortLabel
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||
)
|
||||
|
||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
||||
)
|
||||
!device.isAapConnected -> {
|
||||
val secondaryRes = if (device.ble != null) {
|
||||
R.string.anc_widget_aap_not_connected_nearby_description
|
||||
} else {
|
||||
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||
}
|
||||
AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = profileLabel ?: device.getLabel(context),
|
||||
secondaryText = context.getString(secondaryRes),
|
||||
)
|
||||
}
|
||||
|
||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
@@ -121,10 +130,3 @@ object AncWidgetRenderStateMapper {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
||||
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
||||
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||
text = formatPercent(state.percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
@@ -311,7 +311,7 @@ private fun PodItemRow(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
@@ -354,7 +354,7 @@ private fun TinyPodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
@@ -382,9 +382,8 @@ private fun DeviceLabel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
private fun formatPercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
|
||||
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(state.percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
@@ -270,7 +270,7 @@ private fun GlancePodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(start = 4.dp),
|
||||
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
|
||||
|
||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||
|
||||
private fun formatGlancePercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
private fun formatGlancePercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@@ -14,10 +14,10 @@ internal data class WidgetDeviceKey(
|
||||
val profileId: String?,
|
||||
val profileLabel: String?,
|
||||
val model: PodModel,
|
||||
val batteryLeft: Float?,
|
||||
val batteryRight: Float?,
|
||||
val batteryCase: Float?,
|
||||
val batteryHeadset: Float?,
|
||||
val batteryLeft: Float,
|
||||
val batteryRight: Float,
|
||||
val batteryCase: Float,
|
||||
val batteryHeadset: Float,
|
||||
val isLeftPodCharging: Boolean?,
|
||||
val isRightPodCharging: Boolean?,
|
||||
val isCaseCharging: Boolean?,
|
||||
@@ -27,6 +27,7 @@ internal data class WidgetDeviceKey(
|
||||
val isBeingWorn: Boolean?,
|
||||
val isAapConnected: Boolean,
|
||||
val isAapReady: Boolean,
|
||||
val hasBleAdvertisement: Boolean,
|
||||
val ancMode: AapSetting.AncMode.Value?,
|
||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||
@@ -49,6 +50,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
isBeingWorn = isBeingWorn,
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
hasBleAdvertisement = ble != null,
|
||||
ancMode = ancMode?.current,
|
||||
pendingAncMode = pendingAncMode,
|
||||
visibleAncModes = visibleAncModes,
|
||||
|
||||
@@ -7,7 +7,6 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
|
||||
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||
leftPercent = device.batteryLeft,
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
||||
rightPercent = device.batteryRight,
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase.toBatteryFloat(),
|
||||
casePercent = device.batteryCase,
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
)
|
||||
|
||||
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadset.toBatteryFloat(),
|
||||
percent = device.batteryHeadset,
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == true,
|
||||
worn = device.isBeingWorn == true,
|
||||
|
||||
@@ -4,7 +4,10 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
@@ -20,6 +23,7 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.toReactionConfig
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
|
||||
connectedAddresses = connectedAddresses,
|
||||
)
|
||||
}.onEach { liveState ->
|
||||
persistLiveDevices(liveState.liveDevices)
|
||||
persistLiveDevices(liveState.liveDevices + aapOnlyForPersistence(liveState))
|
||||
}
|
||||
|
||||
/**
|
||||
* AAP-only profiles whose state didn't make it into [LiveMergeState.liveDevices] (no BLE pod
|
||||
* in the current scan). Without this, [persistLiveDevices] would only ever see BLE-backed
|
||||
* devices, and AAP-delivered DeviceInfo (earbud serials, marketing version) for an out-of-BLE
|
||||
* pod would never reach the cache even though the AAP socket is alive.
|
||||
*/
|
||||
private fun aapOnlyForPersistence(state: LiveMergeState): List<PodDevice> {
|
||||
val coveredProfileIds = state.liveDevices.mapNotNull { it.profileId }.toSet()
|
||||
return state.profiles.mapNotNull { profile ->
|
||||
if (profile.id in coveredProfileIds) return@mapNotNull null
|
||||
val aap = state.aapStates.forProfile(profile) ?: return@mapNotNull null
|
||||
PodDevice(
|
||||
profileId = profile.id,
|
||||
label = profile.label,
|
||||
ble = null,
|
||||
aap = aap,
|
||||
profileAddress = profile.address,
|
||||
profileModel = profile.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||
reactions = profile.toReactionConfig(),
|
||||
isSystemConnected = profile.address in state.connectedAddresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}.replayingShare(appScope)
|
||||
|
||||
private val reportedPersistFailures = mutableSetOf<String>()
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
val profileId = device.profileId ?: continue
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
try {
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to persist state for $profileId: ${e.asLog()}" }
|
||||
if (reportedPersistFailures.add(profileId)) {
|
||||
runCatching { Bugs.report(tag = TAG, message = "persistLiveDevices failed for $profileId", exception = e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||
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.main.core.MonitorMode
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.toReactionConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MonitorModeResolver @Inject constructor(
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||
) {
|
||||
|
||||
val effectiveMode: Flow<MonitorMode> = combine(
|
||||
profilesRepo.profiles,
|
||||
nudgeCapabilityStore.availability,
|
||||
) { profiles, nudge ->
|
||||
val primary = profiles.firstOrNull() ?: return@combine MonitorMode.MANUAL
|
||||
primary.requiredMode(nudge)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
|
||||
|
||||
private fun DeviceProfile.requiredMode(nudge: NudgeAvailability): MonitorMode = when {
|
||||
// Match AutoConnect.kt's isNullOrEmpty check — a blank/legacy "" address is no address.
|
||||
address.isNullOrBlank() -> MonitorMode.MANUAL
|
||||
toReactionConfig().autoConnect && nudge != NudgeAvailability.BROKEN -> MonitorMode.ALWAYS
|
||||
else -> MonitorMode.AUTOMATIC
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "ModeResolver")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
@@ -138,18 +139,24 @@ data class PodDevice(
|
||||
}
|
||||
}
|
||||
|
||||
// Battery — AAP preferred, BLE fallback, then cached
|
||||
val batteryLeft: Float?
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
||||
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
|
||||
// for unknown to keep the type primitive; this is the boundary that previously emitted
|
||||
// Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
|
||||
val batteryLeft: Float
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
?: cached?.left?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryRight: Float?
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
||||
val batteryRight: Float
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
?: cached?.right?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryCase: Float?
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
||||
val batteryCase: Float
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
?: cached?.case?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryHeadset: Float?
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
||||
val batteryHeadset: Float
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
/** True when at least one displayed battery value was filled from cache (not live). */
|
||||
val isBatteryCached: Boolean
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
/**
|
||||
* Connection tier rank used to sort devices by user-perceived "primary":
|
||||
* lower rank = higher priority. System-connected devices come first, then
|
||||
* any live device (BLE or AAP), then profiled-but-offline.
|
||||
*
|
||||
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
|
||||
* non-tier-ranked for reaction flows that want "any profiled device".
|
||||
*/
|
||||
fun PodDevice.tierRank(): Int = when {
|
||||
isSystemConnected -> 0
|
||||
isLive -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the user-perceived primary profiled device: lowest [tierRank], with
|
||||
* the user's profile-list order as the tiebreaker.
|
||||
*/
|
||||
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
|
||||
filter { it.profileId != null }
|
||||
.minWithOrNull(
|
||||
compareBy<PodDevice> { it.tierRank() }
|
||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||
)
|
||||
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
try {
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||
break
|
||||
} catch (retryException: Exception) {
|
||||
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP reconnected to $address" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
@@ -218,7 +211,7 @@ class AapAutoConnect @Inject constructor(
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded != null) {
|
||||
aapManager.connect(address, bonded.internal, detectedModel)
|
||||
aapManager.connect(address, bonded.internal!!, detectedModel)
|
||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
|
||||
companion object {
|
||||
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)
|
||||
private val CONNECT_TIMEOUT = 5.seconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class StemPressReaction @Inject constructor(
|
||||
is StemAction.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
||||
is StemAction.VolumeUp -> mediaControl.adjustVolumeUp()
|
||||
is StemAction.VolumeDown -> mediaControl.adjustVolumeDown()
|
||||
is StemAction.Stop -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
|
||||
is StemAction.Stop -> mediaControl.sendStop()
|
||||
is StemAction.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
|
||||
is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
|
||||
is StemAction.MuteToggle -> mediaControl.toggleMuteMusic()
|
||||
|
||||
@@ -8,7 +8,6 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -27,7 +26,10 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -50,14 +52,24 @@ class BlePodMonitor @Inject constructor(
|
||||
private val timeSource: TimeSource,
|
||||
private val generalSettings: GeneralSettings,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
permissionTool: PermissionTool,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
|
||||
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||
private val cacheLock = Mutex()
|
||||
|
||||
/**
|
||||
* Ephemeral override that disables the proximity-pairing scan filter so
|
||||
* the troubleshooter can collect raw BLE broadcasts. Resets to false on
|
||||
* every process start; the troubleshooter is the only writer.
|
||||
*/
|
||||
private val unfilteredOverride = MutableStateFlow(false)
|
||||
|
||||
fun setUnfilteredOverride(enabled: Boolean) {
|
||||
unfilteredOverride.value = enabled
|
||||
}
|
||||
|
||||
val devices: Flow<List<BlePodSnapshot>> = combine(
|
||||
permissionTool.missingScanPermissions,
|
||||
bluetoothManager.isBluetoothEnabled
|
||||
@@ -89,8 +101,10 @@ class BlePodMonitor @Inject constructor(
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.WARN
|
||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
false
|
||||
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
|
||||
permissionTool.recheck()
|
||||
delay(3000)
|
||||
true
|
||||
} else {
|
||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
delay(3000)
|
||||
@@ -130,7 +144,7 @@ class BlePodMonitor @Inject constructor(
|
||||
|
||||
private fun createBleScanner() = combine(
|
||||
bleScanModeController.scannerMode,
|
||||
debugSettings.showUnfiltered.flow,
|
||||
unfilteredOverride,
|
||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
generalSettings.useIndirectScanResultCallback.flow,
|
||||
@@ -160,13 +174,27 @@ class BlePodMonitor @Inject constructor(
|
||||
else -> ProximityPairing.getBleScanFilter()
|
||||
}
|
||||
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||
disableDirectScanCallback = options.disableDirectCallback,
|
||||
)
|
||||
flow {
|
||||
emitAll(
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||
disableDirectScanCallback = options.disableDirectCallback,
|
||||
)
|
||||
)
|
||||
}.catch { cause ->
|
||||
if (cause is SecurityException) {
|
||||
log(TAG, Logging.Priority.WARN) {
|
||||
"BLE scanner failed due to missing permission, rechecking permissions: ${cause.asLog()}"
|
||||
}
|
||||
permissionTool.recheck()
|
||||
emit(emptyList())
|
||||
} else {
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { it.onlyNewAndUnique() }
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ data class CachedDeviceState(
|
||||
) {
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() {
|
||||
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
|
||||
if (deviceName == null && serialNumber == null && firmwareVersion == null
|
||||
&& leftEarbudSerial == null && rightEarbudSerial == null && marketingVersion == null
|
||||
) return null
|
||||
return AapDeviceInfo(
|
||||
name = deviceName ?: "",
|
||||
modelNumber = "",
|
||||
|
||||
+30
-28
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
|
||||
import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@@ -14,7 +16,7 @@ import java.time.Instant
|
||||
* Returns null if:
|
||||
* - The device is not live (cached-only)
|
||||
* - The device has no profile
|
||||
* - All live battery values are null
|
||||
* - All live battery values AND live DeviceInfo are null (nothing fresh to persist)
|
||||
* - The state hasn't changed from [existing] (dedup)
|
||||
*/
|
||||
fun PodDevice.toCachedState(
|
||||
@@ -24,15 +26,18 @@ fun PodDevice.toCachedState(
|
||||
if (!isLive) return null
|
||||
val pid = profileId ?: return null
|
||||
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
||||
|
||||
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
|
||||
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
|
||||
val liveDeviceInfo = aap?.deviceInfo
|
||||
|
||||
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
|
||||
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
|
||||
) return null
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
@@ -60,40 +65,37 @@ fun PodDevice.toCachedState(
|
||||
}
|
||||
|
||||
private fun mergeBatterySlot(
|
||||
livePercent: Float?,
|
||||
livePercent: Float,
|
||||
existing: CachedBatterySlot?,
|
||||
now: Instant,
|
||||
): CachedBatterySlot? {
|
||||
if (livePercent == null) return existing
|
||||
if (existing == null) return CachedBatterySlot(livePercent, now)
|
||||
|
||||
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
|
||||
if (!isKnownBattery(livePercent)) return existing
|
||||
val current = existing ?: return CachedBatterySlot(livePercent, now)
|
||||
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
|
||||
}
|
||||
|
||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||
if (hasSlotTimestampChanged(old.left, new.left)) return true
|
||||
if (hasSlotTimestampChanged(old.right, new.right)) return true
|
||||
if (hasSlotTimestampChanged(old.case, new.case)) return true
|
||||
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
|
||||
return old.left?.percent != new.left?.percent
|
||||
|| old.right?.percent != new.right?.percent
|
||||
|| old.case?.percent != new.case?.percent
|
||||
|| old.headset?.percent != new.headset?.percent
|
||||
|| old.isLeftCharging != new.isLeftCharging
|
||||
if (hasSlotChanged(old.left, new.left)) return true
|
||||
if (hasSlotChanged(old.right, new.right)) return true
|
||||
if (hasSlotChanged(old.case, new.case)) return true
|
||||
if (hasSlotChanged(old.headset, new.headset)) return true
|
||||
return old.isLeftCharging != new.isLeftCharging
|
||||
|| old.isRightCharging != new.isRightCharging
|
||||
|| old.isCaseCharging != new.isCaseCharging
|
||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||
|| old.deviceName != new.deviceName
|
||||
|| old.serialNumber != new.serialNumber
|
||||
|| old.firmwareVersion != new.firmwareVersion
|
||||
|| old.leftEarbudSerial != new.leftEarbudSerial
|
||||
|| old.rightEarbudSerial != new.rightEarbudSerial
|
||||
|| old.marketingVersion != new.marketingVersion
|
||||
}
|
||||
|
||||
private fun hasSlotTimestampChanged(
|
||||
old: CachedBatterySlot?,
|
||||
new: CachedBatterySlot?,
|
||||
): Boolean {
|
||||
if (old == null || new == null) return false
|
||||
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
|
||||
if (old == null && new == null) return false
|
||||
if (old == null || new == null) return true
|
||||
if (old.percent != new.percent) return true
|
||||
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
@@ -30,6 +29,7 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
@@ -42,6 +42,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||
import eu.darken.capod.reaction.core.playpause.PlayPause
|
||||
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationReaction
|
||||
import eu.darken.capod.reaction.core.sleep.SleepReaction
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpWindow
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -78,9 +79,11 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var autoConnect: AutoConnect
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var sleepReaction: SleepReaction
|
||||
@Inject lateinit var conversationReaction: ConversationReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||
@Inject lateinit var monitorModeResolver: MonitorModeResolver
|
||||
|
||||
private val monitorScope = MonitorCoroutineScope()
|
||||
private var monitoringJob: Job? = null
|
||||
@@ -88,6 +91,10 @@ class MonitorService : Service() {
|
||||
private var foregroundStartFailed = false
|
||||
private var injectionComplete = false
|
||||
|
||||
@Volatile
|
||||
private var latestNotificationSettings: NotificationSettings =
|
||||
NotificationSettings(useExtraNotification = false, keepAfterDisconnect = false)
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun promoteToForeground(notification: Notification): Boolean {
|
||||
return try {
|
||||
@@ -188,27 +195,53 @@ class MonitorService : Service() {
|
||||
}
|
||||
|
||||
private suspend fun doMonitor() {
|
||||
// Seed snapshot eagerly so onDestroy() has correct values even if we abort below.
|
||||
latestNotificationSettings = NotificationSettings(
|
||||
useExtraNotification = generalSettings.useExtraMonitorNotification.flow.first(),
|
||||
keepAfterDisconnect = generalSettings.keepConnectedNotificationAfterDisconnect.flow.first(),
|
||||
)
|
||||
|
||||
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
|
||||
if (permissionsMissingOnStart.isNotEmpty()) {
|
||||
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
|
||||
return
|
||||
}
|
||||
|
||||
val monitorJob = deviceMonitor.primaryDevice()
|
||||
val deviceFlow = deviceMonitor.primaryDevice()
|
||||
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||
.distinctUntilChangedBy { it?.toNotificationKey() }
|
||||
.throttleLatest(1000)
|
||||
.onEach { currentDevice ->
|
||||
val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking
|
||||
|
||||
val notificationSettingsFlow = combine(
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { useExtra, keepAfter ->
|
||||
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
|
||||
}
|
||||
|
||||
val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings ->
|
||||
currentDevice to settings
|
||||
}
|
||||
.onEach { (currentDevice, settings) ->
|
||||
latestNotificationSettings = settings
|
||||
|
||||
notificationManager.notify(
|
||||
MonitorNotifications.NOTIFICATION_ID,
|
||||
notifications.getNotification(currentDevice, showHint = useExtraNotification),
|
||||
notifications.getNotification(
|
||||
currentDevice,
|
||||
showHint = settings.useExtraNotification,
|
||||
),
|
||||
)
|
||||
if (generalSettings.useExtraMonitorNotification.valueBlocking && currentDevice != null) {
|
||||
notificationManager.notify(
|
||||
|
||||
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
|
||||
is ExtraNotificationAction.Post -> notificationManager.notify(
|
||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
|
||||
notifications.getNotificationConnected(currentDevice),
|
||||
notifications.getNotificationConnected(action.device),
|
||||
)
|
||||
ExtraNotificationAction.Cancel -> notificationManager.cancel(
|
||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED
|
||||
)
|
||||
ExtraNotificationAction.KeepExisting -> Unit
|
||||
}
|
||||
}
|
||||
.catch {
|
||||
@@ -224,7 +257,7 @@ class MonitorService : Service() {
|
||||
emptyFlow()
|
||||
} else {
|
||||
combine(
|
||||
generalSettings.monitorMode.flow,
|
||||
monitorModeResolver.effectiveMode,
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
aapConnectionManager.allStates,
|
||||
@@ -300,6 +333,11 @@ class MonitorService : Service() {
|
||||
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
conversationReaction.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "conversationReaction" }
|
||||
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||
monitorJob.join()
|
||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||
@@ -310,7 +348,8 @@ class MonitorService : Service() {
|
||||
monitorScope.cancel("Service destroyed")
|
||||
|
||||
if (injectionComplete) {
|
||||
if (generalSettings.useExtraMonitorNotification.valueBlocking && !generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking) {
|
||||
val snapshot = latestNotificationSettings
|
||||
if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) {
|
||||
try {
|
||||
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
|
||||
} catch (e: Exception) {
|
||||
@@ -363,6 +402,7 @@ private data class NotificationDeviceKey(
|
||||
val profileId: String?,
|
||||
val label: String?,
|
||||
val model: PodModel,
|
||||
val isLive: Boolean,
|
||||
val hasDualPods: Boolean,
|
||||
val hasCase: Boolean,
|
||||
val hasEarDetection: Boolean,
|
||||
@@ -387,6 +427,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
||||
profileId = profileId,
|
||||
label = label,
|
||||
model = model,
|
||||
isLive = isLive,
|
||||
hasDualPods = hasDualPods,
|
||||
hasCase = hasCase,
|
||||
hasEarDetection = hasEarDetection,
|
||||
@@ -406,3 +447,24 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
||||
rightPodIcon = rightPodIcon,
|
||||
caseIcon = caseIcon,
|
||||
)
|
||||
|
||||
internal data class NotificationSettings(
|
||||
val useExtraNotification: Boolean,
|
||||
val keepAfterDisconnect: Boolean,
|
||||
)
|
||||
|
||||
internal sealed interface ExtraNotificationAction {
|
||||
object Cancel : ExtraNotificationAction
|
||||
data class Post(val device: PodDevice) : ExtraNotificationAction
|
||||
object KeepExisting : ExtraNotificationAction
|
||||
}
|
||||
|
||||
internal fun decideExtraNotificationAction(
|
||||
device: PodDevice?,
|
||||
settings: NotificationSettings,
|
||||
): ExtraNotificationAction = when {
|
||||
!settings.useExtraNotification -> ExtraNotificationAction.Cancel
|
||||
device != null && device.isLive -> ExtraNotificationAction.Post(device)
|
||||
settings.keepAfterDisconnect -> ExtraNotificationAction.KeepExisting
|
||||
else -> ExtraNotificationAction.Cancel
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
private fun percentToInt(percent: Float?): Int {
|
||||
if (percent == null) return 0
|
||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
private fun percentToInt(percent: Float): Int =
|
||||
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -73,6 +74,17 @@ class AapConnectionManager @Inject constructor(
|
||||
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Emits when a connected device reports a Conversational Awareness speaking transition
|
||||
* (START/STOP, AAP command 0x4B). Paired with the origin address so the conversation
|
||||
* reaction can gate on the primary device. Only the known 0x01/0x04 markers reach here —
|
||||
* the engine drops unknown raw values (see [AapSessionEngine]).
|
||||
*/
|
||||
private val _conversationalAwarenessEvents =
|
||||
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
|
||||
val conversationalAwarenessEvents: SharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>> =
|
||||
_conversationalAwarenessEvents.asSharedFlow()
|
||||
|
||||
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
|
||||
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
|
||||
@@ -140,6 +152,13 @@ class AapConnectionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Forward conversational-awareness speaking transitions from this connection (child coroutine).
|
||||
launch {
|
||||
connection.conversationalAwarenessEvents.collect { event ->
|
||||
_conversationalAwarenessEvents.tryEmit(address to event)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward OFF-rejection events from this connection (child coroutine)
|
||||
launch {
|
||||
connection.offRejected.collect {
|
||||
|
||||
@@ -14,8 +14,10 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -26,7 +28,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Manages a single AAP L2CAP connection to a device.
|
||||
@@ -39,6 +45,7 @@ internal class AapConnection(
|
||||
private val profile: AapDeviceProfile,
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
timeSource: TimeSource,
|
||||
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||
) {
|
||||
|
||||
private val engine = AapSessionEngine(profile, timeSource)
|
||||
@@ -47,6 +54,7 @@ internal class AapConnection(
|
||||
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
||||
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
|
||||
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> get() = engine.conversationalAwarenessEvents
|
||||
val offRejected: SharedFlow<Unit> get() = engine.offRejected
|
||||
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
|
||||
|
||||
@@ -68,7 +76,7 @@ internal class AapConnection(
|
||||
|
||||
try {
|
||||
val sock = socketFactory.createSocket(device, PSM)
|
||||
sock.connect()
|
||||
sock.connectCancellable()
|
||||
socket = sock
|
||||
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
||||
|
||||
@@ -212,15 +220,47 @@ internal class AapConnection(
|
||||
}
|
||||
|
||||
private fun cleanupSocket() {
|
||||
socket?.closeQuietly()
|
||||
socket = null
|
||||
}
|
||||
|
||||
private suspend fun BluetoothSocket.connectCancellable() {
|
||||
val result = CompletableDeferred<Result<Unit>>()
|
||||
val cancelled = AtomicBoolean(false)
|
||||
val connectThread = Thread(
|
||||
{
|
||||
val outcome = runCatching { connect() }
|
||||
result.complete(outcome)
|
||||
if (cancelled.get() && outcome.isSuccess) closeQuietly()
|
||||
},
|
||||
"AAP-L2CAP-connect-${device.address}",
|
||||
).apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
|
||||
try {
|
||||
socket?.close()
|
||||
withTimeout(connectTimeout) {
|
||||
result.await().getOrThrow()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
cancelled.set(true)
|
||||
closeQuietly()
|
||||
connectThread.interrupt()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun BluetoothSocket.closeQuietly() {
|
||||
try {
|
||||
close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
socket = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PSM = 0x1001
|
||||
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||
private val TAG = logTag("AAP", "Connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -54,6 +55,11 @@ internal class AapSessionEngine(
|
||||
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
|
||||
|
||||
private val _conversationalAwarenessEvents =
|
||||
MutableSharedFlow<ConversationAwarenessEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> =
|
||||
_conversationalAwarenessEvents.asSharedFlow()
|
||||
|
||||
private val _offRejected =
|
||||
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
|
||||
@@ -301,6 +307,13 @@ internal class AapSessionEngine(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// malformed frames (rawValue stays null only in that case). The raw payload remains logged.
|
||||
if (value is AapSetting.ConversationalAwarenessState) {
|
||||
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTimerFired(key: EngineTimerKey) {
|
||||
|
||||
@@ -99,9 +99,17 @@ sealed class AapSetting {
|
||||
val level: Int,
|
||||
) : AapSetting()
|
||||
|
||||
/** Push-only from device — reports speaking detection state (command 0x4B). */
|
||||
/**
|
||||
* Push-only from device — reports speaking detection state (command 0x4B).
|
||||
*
|
||||
* [rawValue] is the first payload byte, preserved so consumers can distinguish the known
|
||||
* speaking-start (0x01) and speaking-stop (0x04) markers from other values (e.g. 0x00, seen
|
||||
* in captures with unclear meaning). [speaking] collapses everything non-0x01 to false for
|
||||
* storage/UI; reaction logic must gate on [rawValue] to avoid acting on unknown values.
|
||||
*/
|
||||
data class ConversationalAwarenessState(
|
||||
val speaking: Boolean,
|
||||
val rawValue: Int? = null,
|
||||
) : AapSetting()
|
||||
|
||||
data class MicrophoneMode(
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
|
||||
*
|
||||
* Status-byte mapping (confirmed against a live AirPods Pro 3 capture and the librepods project):
|
||||
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
|
||||
* - `6`, `8`, `9` → [STOP] (wearer stopped → disengage)
|
||||
* - any other value (`3`, `4`, `0x0B`, …) → [HOLD] (intermediate "still in session" frame; the pod
|
||||
* streams these while speaking — they act as a keep-alive and must NOT disengage the reaction)
|
||||
*
|
||||
* The pod emits no `0x4B` frames at all during silence, so [HOLD] frames ceasing is itself a
|
||||
* reliable "speaking ended" signal (used as a stale-timeout fallback for a missed [STOP]).
|
||||
*/
|
||||
enum class ConversationAwarenessEvent {
|
||||
START,
|
||||
HOLD,
|
||||
STOP,
|
||||
;
|
||||
|
||||
companion object {
|
||||
val SPEAKING_STATUSES = setOf(1, 2)
|
||||
val STOPPED_STATUSES = setOf(6, 8, 9)
|
||||
|
||||
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
|
||||
in SPEAKING_STATUSES -> START
|
||||
in STOPPED_STATUSES -> STOP
|
||||
else -> HOLD
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -183,12 +183,24 @@ class DefaultAapDeviceProfile(
|
||||
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
|
||||
}
|
||||
|
||||
// Conversation Awareness State is a separate command type (push-only)
|
||||
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
|
||||
// are seen: a legacy single byte, and the 4-byte `02 00 01 <status>` form on Pro 3 fw 81.x.
|
||||
// The status (speaking marker) is the last byte in both — but we validate the shape rather
|
||||
// than blindly taking payload.last(), so a truncated/garbled frame can't be misread as a
|
||||
// speaking transition. Unrecognised shapes return null → logged as an unhandled message.
|
||||
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
|
||||
if (message.payload.isEmpty()) return null
|
||||
val value = message.payload[0].toInt() and 0xFF
|
||||
val speaking = value == 0x01
|
||||
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
|
||||
val p = message.payload
|
||||
val status = when {
|
||||
p.size == 1 -> p[0].toInt() and 0xFF
|
||||
p.size == 4 &&
|
||||
p[0] == 0x02.toByte() && p[1] == 0x00.toByte() && p[2] == 0x01.toByte() ->
|
||||
p[3].toInt() and 0xFF
|
||||
else -> return null
|
||||
}
|
||||
// speaking = the "started/active speaking" statuses (1,2); see ConversationAwarenessEvent.
|
||||
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES
|
||||
return AapSetting.ConversationalAwarenessState::class to
|
||||
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
|
||||
}
|
||||
|
||||
if (message.commandType != AapMessageType.CONTROL.value) return null
|
||||
|
||||
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
|
||||
|
||||
const val BATTERY_UNKNOWN = -1f
|
||||
|
||||
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
|
||||
fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f
|
||||
|
||||
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
|
||||
fun batteryProgress(percent: Float): Float =
|
||||
if (isKnownBattery(percent)) percent.coerceIn(0f, 1f) else 0f
|
||||
|
||||
fun formatBatteryPercent(context: Context, percent: Float?): String =
|
||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||
else context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
@DrawableRes
|
||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
||||
fun getBatteryDrawable(percent: Float): Int = when {
|
||||
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
||||
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
||||
@@ -42,8 +43,8 @@ fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||
}
|
||||
|
||||
fun getBatteryIcon(percent: Float?): ImageVector = when {
|
||||
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||
fun getBatteryIcon(percent: Float): ImageVector = when {
|
||||
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
||||
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
||||
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.ProximityEncryptionKey
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -25,11 +26,14 @@ data class AppleDeviceProfile(
|
||||
@SerialName("address") override val address: String? = null,
|
||||
@SerialName("reactionAutoPause") val autoPause: Boolean = false,
|
||||
@SerialName("reactionAutoPlay") val autoPlay: Boolean = false,
|
||||
@SerialName("reactionStartMusicOnWear") val startMusicOnWear: Boolean = false,
|
||||
@SerialName("reactionOnePodMode") val onePodMode: Boolean = false,
|
||||
@SerialName("reactionAutoConnect") val autoConnect: Boolean = false,
|
||||
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
||||
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
|
||||
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
|
||||
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
|
||||
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
|
||||
/**
|
||||
* 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
|
||||
@@ -49,11 +53,14 @@ data class AppleDeviceProfile(
|
||||
get() = ReactionConfig(
|
||||
autoPause = autoPause,
|
||||
autoPlay = autoPlay,
|
||||
startMusicOnWear = startMusicOnWear,
|
||||
onePodMode = onePodMode,
|
||||
autoConnect = autoConnect,
|
||||
autoConnectCondition = autoConnectCondition,
|
||||
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
|
||||
showPopUpOnConnection = showPopUpOnConnection,
|
||||
conversationAction = conversationAction,
|
||||
conversationVolumeReduction = conversationVolumeReduction,
|
||||
)
|
||||
|
||||
override fun toString(): String = "AppleDeviceProfile(" +
|
||||
@@ -62,10 +69,13 @@ data class AppleDeviceProfile(
|
||||
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
|
||||
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
|
||||
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
|
||||
"startMusicOnWear=$startMusicOnWear, " +
|
||||
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
|
||||
"autoConnectCondition=$autoConnectCondition, " +
|
||||
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
|
||||
"showPopUpOnConnection=$showPopUpOnConnection, " +
|
||||
"conversationAction=$conversationAction, " +
|
||||
"conversationVolumeReduction=$conversationVolumeReduction, " +
|
||||
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
|
||||
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
|
||||
"stemActions=$stemActions" +
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
package eu.darken.capod.profiles.core
|
||||
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
|
||||
data class ReactionConfig(
|
||||
val autoPause: Boolean = false,
|
||||
val autoPlay: Boolean = false,
|
||||
val startMusicOnWear: Boolean = false,
|
||||
val onePodMode: Boolean = false,
|
||||
val autoConnect: Boolean = false,
|
||||
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
||||
val showPopUpOnCaseOpen: Boolean = false,
|
||||
val showPopUpOnConnection: Boolean = false,
|
||||
)
|
||||
val conversationAction: ConversationAction = ConversationAction.NOTHING,
|
||||
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
|
||||
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
|
||||
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
|
||||
|
||||
/** 100% lowers media volume all the way to 0 — i.e. full mute while speaking. */
|
||||
const val MAX_CONVERSATION_VOLUME_REDUCTION = 100
|
||||
}
|
||||
}
|
||||
|
||||
interface HasReactionConfig {
|
||||
val reactionConfig: ReactionConfig
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package eu.darken.capod.reaction.core.autoconnect
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -26,6 +28,7 @@ import javax.inject.Singleton
|
||||
class AutoConnect @Inject constructor(
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||
) {
|
||||
|
||||
fun monitor(): Flow<Unit> = deviceMonitor.primaryDevice()
|
||||
@@ -98,13 +101,14 @@ class AutoConnect @Inject constructor(
|
||||
return@map
|
||||
}
|
||||
|
||||
if (!bluetoothManager.isNudgeAvailable) {
|
||||
log(TAG, WARN) { "nudgeConnection is not available on this device, skipping" }
|
||||
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
|
||||
log(TAG, WARN) { "nudgeConnection is known broken on this device, skipping" }
|
||||
return@map
|
||||
}
|
||||
|
||||
val result = bluetoothManager.nudgeConnection(bondedDevice)
|
||||
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
|
||||
nudgeCapabilityStore.record(result)
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.darken.capod.reaction.core.conversation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* What CAPod does when the pods report that the wearer started speaking (Conversational
|
||||
* Awareness). On Android the firmware does not duck audio on its own, so CAPod performs the
|
||||
* reaction itself. The action is reverted when speaking stops.
|
||||
*
|
||||
* Extend by adding entries — the persisted [SerialName] strings are stable identifiers.
|
||||
*/
|
||||
@Serializable
|
||||
enum class ConversationAction(
|
||||
val identifier: String,
|
||||
@StringRes val labelRes: Int,
|
||||
) {
|
||||
@SerialName("conversation.action.nothing")
|
||||
NOTHING("conversation.action.nothing", R.string.settings_conversation_action_nothing_label),
|
||||
|
||||
@SerialName("conversation.action.lower_volume")
|
||||
LOWER_VOLUME("conversation.action.lower_volume", R.string.settings_conversation_action_lower_volume_label),
|
||||
|
||||
@SerialName("conversation.action.pause")
|
||||
PAUSE("conversation.action.pause", R.string.settings_conversation_action_pause_label),
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package eu.darken.capod.reaction.core.conversation
|
||||
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
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.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
|
||||
* 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.
|
||||
*
|
||||
* The pod streams classified frames while you talk ([ConversationAwarenessEvent.START] at onset,
|
||||
* then [ConversationAwarenessEvent.HOLD] keep-alives) and an explicit [ConversationAwarenessEvent.STOP]
|
||||
* when you finish; no frames at all during silence. Disengage happens on STOP, or — if a STOP is
|
||||
* dropped — via a stale timeout that fires once frames stop arriving. Each frame (START or HOLD)
|
||||
* resets that timer, so a long conversation stays engaged.
|
||||
*
|
||||
* 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.
|
||||
* Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped
|
||||
* session never strands the user at low volume.
|
||||
*/
|
||||
@Singleton
|
||||
class ConversationReaction @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val mediaControl: MediaControl,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
private sealed interface Kind {
|
||||
data object Paused : Kind
|
||||
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
|
||||
}
|
||||
|
||||
private data class Active(
|
||||
val id: Long,
|
||||
val owner: BluetoothAddress,
|
||||
val kind: Kind,
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var active: Active? = null
|
||||
private var staleJob: Job? = null
|
||||
private var idCounter = 0L
|
||||
|
||||
fun monitor(): Flow<Unit> = merge(
|
||||
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
|
||||
// Reverts a stranded duck when the owning device leaves the AAP state map for any reason
|
||||
// (intentional or not) — disconnectEvents only fires for unintentional drops.
|
||||
aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) },
|
||||
)
|
||||
.map { }
|
||||
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
|
||||
.onCompletion { withContext(NonCancellable) { onMonitorCompleted() } }
|
||||
.setupCommonEventHandlers(TAG) { "conversationReaction" }
|
||||
|
||||
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
|
||||
ConversationAwarenessEvent.START -> onSpeakingStart(address)
|
||||
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
|
||||
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
|
||||
}
|
||||
|
||||
private suspend fun onSpeakingStart(address: BluetoothAddress) {
|
||||
val primary = deviceMonitor.primaryDevice().first()
|
||||
if (primary?.address != address) {
|
||||
log(TAG) { "START from $address ignored — not primary device (primary=${primary?.address})" }
|
||||
return
|
||||
}
|
||||
val action = primary.reactions.conversationAction
|
||||
if (action == ConversationAction.NOTHING) return
|
||||
|
||||
mutex.withLock {
|
||||
val current = active
|
||||
if (current != null && current.owner == address) {
|
||||
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
||||
restartStaleTimer(current)
|
||||
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
||||
return
|
||||
}
|
||||
// A different device started speaking while we were active — undo the old one first.
|
||||
if (current != null) revert(current, "superseded by $address")
|
||||
|
||||
when (action) {
|
||||
ConversationAction.PAUSE -> {
|
||||
val paused = mediaControl.sendPause(rememberForResume = false)
|
||||
if (paused) {
|
||||
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
|
||||
active = record
|
||||
restartStaleTimer(record)
|
||||
log(TAG, INFO) { "START on $address → paused media" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → nothing playing, no pause" }
|
||||
}
|
||||
}
|
||||
|
||||
ConversationAction.LOWER_VOLUME -> {
|
||||
val reduction = primary.reactions.conversationVolumeReduction
|
||||
.coerceIn(
|
||||
ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION,
|
||||
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION,
|
||||
)
|
||||
val duck = mediaControl.duckMusicVolume(reduction)
|
||||
if (duck != null) {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.Ducked(duck.priorVolume, duck.appliedVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
restartStaleTimer(record)
|
||||
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
}
|
||||
}
|
||||
|
||||
ConversationAction.NOTHING -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Intermediate keep-alive frame: the wearer is still speaking — refresh the stale timer. */
|
||||
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
|
||||
val current = active ?: return
|
||||
if (current.owner != address) return
|
||||
restartStaleTimer(current)
|
||||
}
|
||||
|
||||
private suspend fun onSpeakingStop(address: BluetoothAddress) {
|
||||
val primary = deviceMonitor.primaryDevice().first()
|
||||
mutex.withLock {
|
||||
val current = active ?: return
|
||||
if (current.owner != address) {
|
||||
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
|
||||
return
|
||||
}
|
||||
clearActive()
|
||||
disengage(current, primary, "STOP on $address")
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful disengage (STOP event or stale timeout). Must be called under [mutex]. */
|
||||
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String) {
|
||||
when (val kind = record.kind) {
|
||||
is Kind.Paused -> {
|
||||
// Resume the pause WE caused, regardless of the current action setting. Gating on
|
||||
// "action still == PAUSE" would strand media paused if the user switched the action
|
||||
// (or set NOTHING) mid-conversation — undoing our own side effect is the least
|
||||
// surprising behaviour. The remaining guards are about real device/playback state.
|
||||
val age = timeSource.elapsedRealtime() - record.at
|
||||
when {
|
||||
age > PAUSE_RESUME_WINDOW_MS ->
|
||||
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
|
||||
primary?.address != record.owner ->
|
||||
log(TAG) { "$reason — resume skipped (primary switched)" }
|
||||
primary.isBeingWorn == false ->
|
||||
log(TAG) { "$reason — resume skipped (not worn)" }
|
||||
mediaControl.isPlaying ->
|
||||
log(TAG) { "$reason — resume skipped (already playing)" }
|
||||
else -> {
|
||||
mediaControl.sendPlay()
|
||||
log(TAG, INFO) { "$reason → resumed media" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onActiveDevicesChanged(addresses: Set<BluetoothAddress>) = mutex.withLock {
|
||||
val current = active ?: return
|
||||
if (current.owner !in addresses) {
|
||||
clearActive()
|
||||
revert(current, "owner ${current.owner} gone")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onMonitorCompleted() = mutex.withLock {
|
||||
val current = active ?: return
|
||||
clearActive()
|
||||
revert(current, "monitor completed")
|
||||
}
|
||||
|
||||
/** Forced revert (disconnect / supersede / shutdown): undo volume ducks; leave pauses as-is. */
|
||||
private fun revert(record: Active, reason: String) {
|
||||
when (val kind = record.kind) {
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertDuck(kind: Kind.Ducked, reason: String) {
|
||||
// Restore to the pre-duck volume unconditionally — we do NOT gate on
|
||||
// currentMusicVolume() == appliedVolume. Such a guard skips the restore whenever anything
|
||||
// else moved the volume meanwhile (e.g. a concurrent volume-manager app), which lets the
|
||||
// baseline ratchet down across conversations. Restoring deterministically to the level we
|
||||
// saved at engage avoids that; the trade-off is overriding a deliberate mid-conversation
|
||||
// volume change (rare). Matches librepods' unconditional restore.
|
||||
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (ducked to ${kind.appliedVolume}, now ${mediaControl.currentMusicVolume()}, $reason)" }
|
||||
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||
}
|
||||
|
||||
/** Must be called under [mutex]. Clears the active slot and cancels its stale timer. */
|
||||
private fun clearActive() {
|
||||
staleJob?.cancel()
|
||||
staleJob = null
|
||||
active = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called under [mutex]. (Re)starts the stale timer for [record]. Reset on every frame
|
||||
* (START/HOLD); if no frame arrives for [STALE_TIMEOUT_MS] the speaking session is treated as
|
||||
* ended (recovers from a dropped STOP). Identity-checked so a late timer can't disengage a newer
|
||||
* session.
|
||||
*/
|
||||
private fun restartStaleTimer(record: Active) {
|
||||
staleJob?.cancel()
|
||||
staleJob = appScope.launch {
|
||||
delay(STALE_TIMEOUT_MS)
|
||||
val primary = deviceMonitor.primaryDevice().first()
|
||||
mutex.withLock {
|
||||
if (active?.id == record.id) {
|
||||
val current = active!!
|
||||
clearActive()
|
||||
disengage(current, primary, "stale timeout (frames ceased)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextId(): Long = ++idCounter
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "Conversation")
|
||||
|
||||
/**
|
||||
* Disengage if no `0x4B` frame arrives for this long while engaged. The pod streams frames
|
||||
* (~1/s) throughout active speech and none during silence, so this both recovers a dropped
|
||||
* STOP and bounds how long a duck can linger. Long enough not to disengage mid-conversation
|
||||
* between frames.
|
||||
*/
|
||||
private const val STALE_TIMEOUT_MS = 12L * 1000L
|
||||
|
||||
/** A disengage older than this no longer auto-resumes a pause — unexpected late playback is worse. */
|
||||
private const val PAUSE_RESUME_WINDOW_MS = 2L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,7 @@ class PlayPause @Inject constructor(
|
||||
onePodMode = reactions.onePodMode,
|
||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
startMusicOnWear = reactions.startMusicOnWear,
|
||||
)
|
||||
|
||||
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
|
||||
@@ -204,6 +205,7 @@ class PlayPause @Inject constructor(
|
||||
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
|
||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
startMusicOnWear = reactions.startMusicOnWear,
|
||||
)
|
||||
pendingPlayConfirmation = confirmation.pending
|
||||
|
||||
@@ -263,7 +265,7 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
|
||||
decision.shouldPause && reactions.autoPause -> {
|
||||
val pauseSent = mediaControl.sendPause()
|
||||
val pauseSent = mediaControl.sendPause(rememberForResume = true)
|
||||
log(TAG, INFO) {
|
||||
"autoPause triggered: source=$source, " +
|
||||
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
|
||||
@@ -288,10 +290,11 @@ class PlayPause @Inject constructor(
|
||||
onePodMode: Boolean,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean = false,
|
||||
startMusicOnWear: Boolean = false,
|
||||
): PlayPauseDecision = if (onePodMode) {
|
||||
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
||||
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
|
||||
} else {
|
||||
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
||||
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
|
||||
}
|
||||
|
||||
private fun evaluateOnePodMode(
|
||||
@@ -299,6 +302,7 @@ class PlayPause @Inject constructor(
|
||||
current: EarDetectionState,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean,
|
||||
startMusicOnWear: Boolean,
|
||||
): PlayPauseDecision {
|
||||
val netChange = current.podCount - previous.podCount
|
||||
|
||||
@@ -310,8 +314,13 @@ class PlayPause @Inject constructor(
|
||||
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
|
||||
)
|
||||
|
||||
// Net increase: pod(s) inserted → play
|
||||
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
|
||||
// Net increase: pod(s) inserted → play, but only when we paused recently OR the user
|
||||
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
|
||||
// so we never override a user-initiated pause or fire over silence.
|
||||
netChange > 0 && (
|
||||
wasRecentlyPausedByUs ||
|
||||
(startMusicOnWear && !isCurrentlyPlaying)
|
||||
) -> PlayPauseDecision(
|
||||
shouldPlay = true,
|
||||
shouldPause = false,
|
||||
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
|
||||
@@ -332,13 +341,19 @@ class PlayPause @Inject constructor(
|
||||
current: EarDetectionState,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByCap: Boolean,
|
||||
startMusicOnWear: Boolean,
|
||||
): PlayPauseDecision {
|
||||
val wasWorn = previous.bothInEar
|
||||
val isWorn = current.bothInEar
|
||||
|
||||
return when {
|
||||
// Transition: not worn → worn, and not playing → play
|
||||
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
|
||||
// Transition: not worn → worn → play, but only when we paused recently OR the user
|
||||
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
|
||||
// so we never override a user-initiated pause or fire over silence.
|
||||
!wasWorn && isWorn && (
|
||||
wasRecentlyPausedByCap ||
|
||||
(startMusicOnWear && !isCurrentlyPlaying)
|
||||
) -> PlayPauseDecision(
|
||||
shouldPlay = true,
|
||||
shouldPause = false,
|
||||
reason = "Normal mode: both pods in ear",
|
||||
@@ -371,6 +386,7 @@ class PlayPause @Inject constructor(
|
||||
shouldStageBleOnlyPlay: Boolean,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean,
|
||||
startMusicOnWear: Boolean = false,
|
||||
): PlayConfirmationResult {
|
||||
val activePending = pending?.takeIf {
|
||||
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
|
||||
@@ -378,7 +394,10 @@ class PlayPause @Inject constructor(
|
||||
|
||||
if (activePending != null &&
|
||||
currentState == activePending.targetState &&
|
||||
(!isCurrentlyPlaying || wasRecentlyPausedByUs)
|
||||
(
|
||||
wasRecentlyPausedByUs ||
|
||||
(startMusicOnWear && !isCurrentlyPlaying)
|
||||
)
|
||||
) {
|
||||
return PlayConfirmationResult(
|
||||
decision = PlayPauseDecision(
|
||||
|
||||
@@ -39,8 +39,6 @@ import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
|
||||
@Composable
|
||||
fun PopUpContent(
|
||||
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
// Left pod
|
||||
BatteryColumn(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
batteryPercent = device.batteryLeft,
|
||||
isCharging = device.isLeftPodCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
if (device.hasCase) {
|
||||
BatteryColumn(
|
||||
iconRes = device.caseIcon,
|
||||
batteryPercent = device.batteryCase.toBatteryFloat(),
|
||||
batteryPercent = device.batteryCase,
|
||||
isCharging = device.isCaseCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
// Right pod
|
||||
BatteryColumn(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
batteryPercent = device.batteryRight,
|
||||
isCharging = device.isRightPodCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
private fun SinglePodContent(device: PodDevice) {
|
||||
BatteryColumn(
|
||||
iconRes = device.iconRes,
|
||||
batteryPercent = device.batteryHeadset.toBatteryFloat(),
|
||||
batteryPercent = device.batteryHeadset,
|
||||
isCharging = device.isHeadsetBeingCharged ?: false,
|
||||
)
|
||||
}
|
||||
@@ -157,7 +155,6 @@ private fun BatteryColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val nullablePercent = batteryPercent.toBatteryOrNull()
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
@@ -184,14 +181,14 @@ private fun BatteryColumn(
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = getBatteryIcon(nullablePercent),
|
||||
imageVector = getBatteryIcon(batteryPercent),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = formatBatteryPercent(context, nullablePercent),
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -8,7 +8,6 @@ import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
@@ -43,7 +42,6 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
private val bleScanModeController: BleScanModeController,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val timeSource: TimeSource,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
@@ -87,6 +85,7 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
log(TAG, INFO) { "troubleShootBle()" }
|
||||
|
||||
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
|
||||
try {
|
||||
run {
|
||||
progress("Checking for headphones...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
@@ -114,7 +113,7 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
|
||||
debugSettings.showUnfiltered.valueBlocking = unfiltered
|
||||
blePodMonitor.setUnfilteredOverride(unfiltered)
|
||||
|
||||
val start = timeSource.elapsedRealtime()
|
||||
val devices = withTimeoutOrNull(STEP_TIME) {
|
||||
@@ -151,7 +150,6 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
debugSettings.showUnfiltered.valueBlocking = false
|
||||
|
||||
return@override
|
||||
}
|
||||
@@ -241,6 +239,9 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
blePodMonitor.setUnfilteredOverride(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">As u onlangs gekoop het, kan dit \'n oomblik neem vir Google Play om te sinkroniseer.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Probeer weer oor \'n paar minute as u aankoop nie verskyn nie.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Maak seker dat u met dieselfde Google-rekening aangemeld is wat vir die aankoop gebruik is.</string>
|
||||
<string name="settings_monitor_mode_label">Monitormodus</string>
|
||||
<string name="settings_monitor_mode_description">Onder watter omstandighede hierdie toepassing Bluetooth-data monitor.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Ekstra kennisgewing</string>
|
||||
<string name="settings_monitor_connected_notification_description">Wys \'n ekstra kennisgewing wanneer \'n toestel gekoppel is. Dit laat jou toe om die permanente \"Geen toestelle\" kennisgewing te versteek deur die \"Toestelstatus\" kanaal te deaktiveer.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Hou kennisgewing ná ontkoppeling</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Outo-pouse</string>
|
||||
<string name="settings_autopause_description">Pouseer oudio wanneer die toestel uit jou oor verwyder word.</string>
|
||||
<string name="settings_autopplay_label">Outo-speel</string>
|
||||
<string name="settings_autoplay_description">Begin oudio-terugspeel wanneer toestel gedra word.</string>
|
||||
<string name="settings_autoplay_description">Hervat musiek wanneer jy jou pods weer dra, maar slegs as Outopouseer dit gestop het. Pouses wat jy self geaktiveer het (foon, AirPods-steel, slaap) bly gepouseer.</string>
|
||||
<string name="settings_start_music_on_wear_label">Begin musiek by dra</string>
|
||||
<string name="settings_start_music_on_wear_description">Probeer altyd musiek speel wanneer jy jou pods opsit, selfs na \'n handmatige pouseer.</string>
|
||||
<string name="settings_eardetection_info_label">Nota oor oorbespeuring</string>
|
||||
<string name="settings_eardetection_info_description">As oorbespeuring net vir een pod werk, is dit \'n Apple-beperking. Slegs die primêre pod (wat vir die mikrofoon gebruik word) word bespeur. Stel op Apple-toestelle op: Settings → Bluetooth → AirPods → Microphone.</string>
|
||||
<string name="settings_fake_data_label">Vals data</string>
|
||||
<string name="settings_fake_data_description">Wys vals data, d.w.s. simuleer toestelle wat nie bestaan nie.</string>
|
||||
<string name="settings_debug_label">Ontfoutinstellings</string>
|
||||
<string name="settings_debug_description">Addisionele instellings om probleme met die toepassing te help oplos.</string>
|
||||
<string name="settings_signal_minimum_label">Minimum seingehalte</string>
|
||||
<string name="settings_signal_minimum_description">Die minimum seingehalte wat \'n toestel moet hê om as joune beskou te word.</string>
|
||||
<string name="settings_autoconnect_label">Outo-koppel</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Wys verbindingsopspringer</string>
|
||||
<string name="settings_popup_connected_description">Wys \'n opspringer wanneer die toestel vir die eerste keer koppel.</string>
|
||||
<string name="settings_popup_info_not_in_app">Opspringvensters word slegs gewys as jy nie in die program is nie.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Opspringvensters benodig die monitor om te loop. Jou monitormodus is ingestel op \"Wanneer program oop is\", wat die monitor stop wanneer jy die program verlaat.</string>
|
||||
<string name="general_fix_it_action">Herstel dit</string>
|
||||
<string name="device_settings_experimental_title">Eksperimentele kenmerk</string>
|
||||
<string name="device_settings_experimental_description">Hierdie kenmerk is nog nie op alle toestelle getoets nie. As iets nie korrek werk nie, open asseblief \'n kwessie met \'n foutopsporingsloglêer.</string>
|
||||
<string name="device_settings_experimental_action">Open Kwessiespoorder</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">Erkennings</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_mode_label">Ontfoutmodus</string>
|
||||
<string name="settings_debug_mode_description">Wys addisionele inligting om probleme op te los.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Ongefiltreerde BLE-data</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Verwyder enige filters van die BLE-skandeerder om alle uitgesaaide BLE-data te wys. Nuttig om ondersteuning vir nuwe koptelefoontipes by te voeg.</string>
|
||||
<string name="settings_compatibility_mode_label">Verenigbaarheidsmodus</string>
|
||||
<string name="settings_compatibility_mode_description">Deaktiveer optimalisasies om verenigbaarheid te verbeter. Probeer dit as jy geen data sien nie.</string>
|
||||
<string name="help_translate_label">Vertaling</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Hierdie widget vereis \'n direkte verbinding (AAP) met jou toestel. Maak seker jou toestel is gepaar en verbind in die program voor jy hierdie widget gebruik.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Hierdie toestel ondersteun nie geraasbeheersing nie</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Nie verbind nie</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">Naby maar nie gekoppel nie</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Nie naby nie</string>
|
||||
<string name="anc_widget_aap_connecting_label">Verbind…</string>
|
||||
<string name="tile_anc_label">Geraaskontrole</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Toestemming benodig</string>
|
||||
<string name="tile_anc_subtitle_no_device">Geen toestel</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Geen geraaskontrole</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth af</string>
|
||||
<string name="widget_config_screen_title">Widget-konfigurasie</string>
|
||||
<string name="widget_configuration_title">Kies toestel</string>
|
||||
<string name="widget_configuration_description">Kies watter toestelprofiel hierdie legstuk moet wys.</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">Die volgende toestemming is vereis:</string>
|
||||
<string name="permission_system_alert_window_label">Stelselwaarskuwingsvenster</string>
|
||||
<string name="permission_system_alert_window_description">Laat CAPod toe om oor ander toepassings te teken om die kenmerk Wys opspringer moontlik te maak.</string>
|
||||
<string name="settings_monitor_mode_manual_label">Wanneer toepassing oop is</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Wanneer toestel gekoppel is</string>
|
||||
<string name="settings_monitor_mode_always_label">Altyd</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Wanneer gesien</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Kassie is oop</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">In oor</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Deursigtigheid</string>
|
||||
<string name="anc_mode_adaptive">Aanpasbaar</string>
|
||||
<string name="conversation_awareness_label">Gespreksbewustheid</string>
|
||||
<string name="settings_conversation_action_label">Wanneer jy begin praat</string>
|
||||
<string name="settings_conversation_action_nothing_label">Doen niks</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Verlaag volume</string>
|
||||
<string name="settings_conversation_action_pause_label">Pouseer media</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Volumevermindering</string>
|
||||
<string name="signal_badge_ble_cd">BLE-skandering aktief</string>
|
||||
<string name="signal_badge_key_irk_cd">Identiteit geverifieer</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Versleutelde verbinding</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Amber</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Gebruik muurpapierkleure</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Monitering</string>
|
||||
<string name="settings_category_appearance_label">Voorkoms</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Ongestoorde veranderinge</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Algemeen</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofoon</string>
|
||||
<string name="device_settings_microphone_mode_description">Watter AirPod as mikrofoon gebruik word</string>
|
||||
<string name="device_settings_microphone_mode_description">Watter oorfoon as mikrofoon gebruik word</string>
|
||||
<string name="device_settings_microphone_mode_auto">Outomaties</string>
|
||||
<string name="device_settings_microphone_mode_right">Regs</string>
|
||||
<string name="device_settings_microphone_mode_left">Links</string>
|
||||
@@ -508,7 +506,7 @@
|
||||
<string name="device_settings_category_connections_label">Gekoppelde toestelle</string>
|
||||
<string name="device_settings_connected_devices_description">Ander toestelle wat tans aan hierdie AirPods gekoppel is</string>
|
||||
<string name="device_settings_connected_device_label">Toestel %d</string>
|
||||
<string name="device_settings_connected_device_call">In ’n oproep</string>
|
||||
<string name="device_settings_connected_device_call">In \’n oproep</string>
|
||||
<string name="device_settings_connected_device_media">Speel media</string>
|
||||
<string name="device_settings_noise_control_label">Geraasbeheer</string>
|
||||
<string name="device_settings_eq_label">Egaliseerder</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">በቅርቡ ካዘዙ፣ Google Play ለማመሳሰል ትንሽ ጊዜ ሊወስድ ይችላል።</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">ግዢዎ ካልታየ ከጥቂት ደቂቃዎች በኋላ እንደገና ሞክር።</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">ለግዢው ጥቅም ላይ ከዋለው ጋር ተመሳሳይ Google መለያ ተጠቅመህ ገብተሃል ወይም ብለህ አረጋግጥ።</string>
|
||||
<string name="settings_monitor_mode_label">የክትትል ሁነታ</string>
|
||||
<string name="settings_monitor_mode_description">ይህ መተግበሪያ የብሉቱዝ መረጃን የሚከታተልበት ሁኔታዎች።</string>
|
||||
<string name="settings_monitor_connected_notification_label">ተጨማሪ ማሳወቂያ</string>
|
||||
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">ራስ-ሰር ለአፍታ ማቆም</string>
|
||||
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
|
||||
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
|
||||
<string name="settings_autoplay_description">መሳሪያው ሲለበስ የድምጽ መልሶ ማጫወት ይጀምሩ።</string>
|
||||
<string name="settings_autoplay_description">ፖዶቹን እንደገና ሲያጠቁ ሙዚቃን ቀጥል፣ ግን ራስ-አቆም ካቆመው ብቻ። ራስዎ ያቆሙት (ስልክ፣ የ AirPods ቀጭኑ፣ ወይም እንቅልፍ) ቆሞ ይቆያል።</string>
|
||||
<string name="settings_start_music_on_wear_label">ሲያጠቁ ሙዚቃ ጀምር</string>
|
||||
<string name="settings_start_music_on_wear_description">ፖዶቹን ሲያጠቁ ሁልጊዜ ሙዚቃ ለማጫወት ይሞክራል፣ እጅ ሲቆም ካቆሙ በኋላ እንኳን።</string>
|
||||
<string name="settings_eardetection_info_label">የጆሮ ማወቂያ ማስታወሻ</string>
|
||||
<string name="settings_eardetection_info_description">የጆሮ ማወቂያ ለአንድ ፖድ ብቻ የሚሰራ ከሆነ ይህ የApple ገደብ ነው። \"ዋና ፖድ\" (ለማይክሮፎን የሚጠቅም) ብቻ ይታወቃል። በApple መሳሪያዎች ላይ ያዋቅሩ: Settings → Bluetooth → AirPods → Microphone.</string>
|
||||
<string name="settings_fake_data_label">የውሸት መረጃ</string>
|
||||
<string name="settings_fake_data_description">የውሸት መረጃ ያሳዩ፣ ማለትም የሌሉ መሳሪያዎችን አስመስለው ያሳዩ።</string>
|
||||
<string name="settings_debug_label">የማረሚያ ቅንብሮች</string>
|
||||
<string name="settings_debug_description">በመተግበሪያው ላይ ያሉ ችግሮችን ለመፍታት የሚረዱ ተጨማሪ ቅንብሮች።</string>
|
||||
<string name="settings_signal_minimum_label">ዝቅተኛ የሲግናል ጥራት</string>
|
||||
<string name="settings_signal_minimum_description">አንድ መሣሪያ የእርስዎ ተደርጎ እንዲቆጠር ሊኖረው የሚገባው ዝቅተኛው የሲግናል ጥራት።</string>
|
||||
<string name="settings_autoconnect_label">ራስ-ሰር ግንኙነት</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">የግንኙነት ብቅ-ባይ አሳይ</string>
|
||||
<string name="settings_popup_connected_description">መሳሪያው ለመጀመሪያ ጊዜ ሲገናኝ ብቅ-ባይ አሳይ።</string>
|
||||
<string name="settings_popup_info_not_in_app">ፖፕ-አፕ መልዕክቶች በፕሮግራሙ ውስጥ ሳታሉ ብቻ ይታያሉ።</string>
|
||||
<string name="settings_popup_warning_manual_mode">ፖፕ-አፕ መልዕክቶች ሞኒተሩ እየሮጠ እንዲሆን ይፈልጋሉ። የሞኒተር ሁነታዎ \"ፕሮግራሙ ሲከፈት\" ተዘጋጅቷል፣ ይህም ፕሮግራሙን ሲለቁ ሞኒተሩን ያቆማል።</string>
|
||||
<string name="general_fix_it_action">አስተካክለው</string>
|
||||
<string name="device_settings_experimental_title">ሙከራዊ ባህሪ</string>
|
||||
<string name="device_settings_experimental_description">ይህ ባህሪ በሁሉም መሳሪያዎች ላይ ገና አልተሞከረም። ሆኖም ካልሰራ፣ እባክዎ የዲበግ ምዝግብ ማስታወሻ ጨምረው ጉዳዩን ይክፈቱ።</string>
|
||||
<string name="device_settings_experimental_action">የጉዳይ መከታተያ ክፈት</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">ምስጋናዎች</string>
|
||||
<string name="settings_debug_autoreports_label">ራስ-ሰር የሳንካ ሪፖርቶች</string>
|
||||
<string name="settings_debug_autoreports_description">ችግሮችን በራስ-ሰር ሪፖርት ያደርጋል፣ ለምሳሌ የመተግበሪያ ብልሽት ዝርዝሮችን እንዴት ማስተካከል እንዳለብኝ እንድገነዘብ።</string>
|
||||
<string name="settings_debug_mode_label">የማረሚያ ሁነታ</string>
|
||||
<string name="settings_debug_mode_description">ችግሮችን ለመፍታት ተጨማሪ መረጃ ያሳዩ።</string>
|
||||
<string name="settings_blescanner_unfiltered_label">ያልተጣራ የBLE መረጃ</string>
|
||||
<string name="settings_blescanner_unfiltered_description">ሁሉንም የተላለፉ የBLE መረጃዎችን ለማሳየት ማንኛውንም ማጣሪያ ከBLE ቃኚው ያስወግዱ። ለአዲስ የጆሮ ማዳመጫ አይነቶች ድጋፍ ለመጨመር ጠቃሚ ነው።</string>
|
||||
<string name="settings_compatibility_mode_label">የተኳኋኝነት ሁነታ</string>
|
||||
<string name="settings_compatibility_mode_description">ተኳኋኝነትን ለማሻሻል ማመቻቸቶችን አሰናክል። ምንም መረጃ ካላዩ ይህንን ይሞክሩ።</string>
|
||||
<string name="help_translate_label">ትርጉም</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">ይህ ዊጄት ወደ መሳሪያዎ ቀጥታ ግንኙነት (AAP) ይፈልጋል። ይህን ዊጄት ከመጠቀምዎ በፊት መሳሪያዎ ጥምር ሆኖ በአፕ ውስጥ መገናኘቱን ያረጋግጡ።</string>
|
||||
<string name="anc_widget_no_anc_support_label">ይህ መሳሪያ የጫጫታ ቁጥጥርን አይደግፍም</string>
|
||||
<string name="anc_widget_aap_not_connected_label">አልተገናኘም</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">አቅራቢያ ነው ግን አልተገናኘም</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">አቅራቢያ የለም</string>
|
||||
<string name="anc_widget_aap_connecting_label">በመገናኘት ላይ…</string>
|
||||
<string name="tile_anc_label">ጫጫታ ቁጥጥር</string>
|
||||
<string name="tile_anc_subtitle_permission_required">ፈቃድ ያስፈልጋል</string>
|
||||
<string name="tile_anc_subtitle_no_device">መሣሪያ የለም</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">ጫጫታ ቁጥጥር የለም</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">ብሉቱዝ ጠፍቷል</string>
|
||||
<string name="widget_config_screen_title">ዊጀት ውቅር</string>
|
||||
<string name="widget_configuration_title">መሣሪያ ይምረጡ</string>
|
||||
<string name="widget_configuration_description">ይህ መግብር የትኛውን የመሣሪያ መገለጫ ማሳየት እንዳለበት ይምረጡ።</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string>
|
||||
<string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string>
|
||||
<string name="permission_system_alert_window_description">\"ብቅ-ባይ አሳይ\" ባህሪውን ለማስቻል CAPod በሌሎች መተግበሪያዎች ላይ እንዲሳል ፍቀድ።</string>
|
||||
<string name="settings_monitor_mode_manual_label">መተግበሪያው ሲከፈት</string>
|
||||
<string name="settings_monitor_mode_automatic_label">መሣሪያ ሲገናኝ</string>
|
||||
<string name="settings_monitor_mode_always_label">ሁልጊዜ</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">ሲታይ</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">ኬዝ ክፍት ነው</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">በጆሮ ውስጥ</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">ግልጽነት</string>
|
||||
<string name="anc_mode_adaptive">መላሚ</string>
|
||||
<string name="conversation_awareness_label">የውይይት ግንዛቤ</string>
|
||||
<string name="settings_conversation_action_label">መናገር ሲጀምሩ</string>
|
||||
<string name="settings_conversation_action_nothing_label">ምንም አታድርግ</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">ድምፅ ዝቅ አድርግ</string>
|
||||
<string name="settings_conversation_action_pause_label">ሚዲያ አቆም</string>
|
||||
<string name="settings_conversation_volume_reduction_label">የድምፅ ቅናሽ</string>
|
||||
<string name="signal_badge_ble_cd">BLE ቅኝት ንቁ ነው</string>
|
||||
<string name="signal_badge_key_irk_cd">ማንነት ተረጋግጧል</string>
|
||||
<string name="signal_badge_key_encrypted_cd">ምስጢራዊ ግንኙነት</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">ህብረ-ቀለማዊ</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">የዳሰሳ ምስል ቀለሞችን ይጠቀማል</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">ክትትል</string>
|
||||
<string name="settings_category_appearance_label">ገጽታ</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">ያልተቀመጡ ለውጦች</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">አጠቃላይ</string>
|
||||
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
|
||||
<string name="device_settings_microphone_mode_description">የትኛው AirPod እንደ ማይክሮፎን ንእ ላይ ይወላል</string>
|
||||
<string name="device_settings_microphone_mode_description">የትኛው ኢርባድ እንደ ማይክሮፎን ጥቅም ላይ እንደሚውል</string>
|
||||
<string name="device_settings_microphone_mode_auto">ራስ-ሰር</string>
|
||||
<string name="device_settings_microphone_mode_right">ቀኝ</string>
|
||||
<string name="device_settings_microphone_mode_left">ግራ</string>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<string name="general_copy_action">نسخ</string>
|
||||
<string name="general_thank_you_label">شكرًا لك</string>
|
||||
<string name="general_upgrade_action">ترقية</string>
|
||||
<string name="common_feature_requires_pro_msg">قم بالترقية للفتح.</string>
|
||||
<string name="common_upgrade_required_label">يتطلب ترقية</string>
|
||||
<string name="common_feature_requires_pro_msg">رقّي للفتح.</string>
|
||||
<string name="common_upgrade_required_label">يتطلب الترقية</string>
|
||||
<string name="general_donate_action">تبرع</string>
|
||||
<string name="general_check_action">تحقق</string>
|
||||
<string name="general_close_action">إغلاق</string>
|
||||
@@ -20,28 +20,26 @@
|
||||
<string name="general_example_label">مثال: %s</string>
|
||||
<string name="upgrade_capod_label">ترقية CAPod</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_autoplay">تشغيل تلقائي وإيقاف مؤقت تلقائي</string>
|
||||
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح الغطاء والاتصال</string>
|
||||
<string name="upgrade_benefit_widgets">عناصر واجهة الشاشة الرئيسية</string>
|
||||
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدمة</string>
|
||||
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز — الضوضاء والعصا والمزيد</string>
|
||||
<string name="upgrade_benefit_support">دعم المطور</string>
|
||||
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز الخاص بك.</string>
|
||||
<string name="upgrade_screen_options_description">نفس الميزات، بتسعير مختلف. يشمل الاشتراك تجربة مجانية ويمكن إلغاؤه في أي وقت.</string>
|
||||
<string name="upgrade_screen_subscription_trial_action">ابدأ الإصدار التجريبي المجاني</string>
|
||||
<string name="upgrade_screen_subscription_action">اشترك سنوياً</string>
|
||||
<string name="upgrade_benefit_autoplay">التشغيل التلقائي & الإيقاف التلقائي</string>
|
||||
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح العلبة & الاتصال</string>
|
||||
<string name="upgrade_benefit_widgets">عناصر الشاشة الرئيسة</string>
|
||||
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدّمة</string>
|
||||
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز — الضوضاء، & السيقان، & المزيد</string>
|
||||
<string name="upgrade_benefit_support">دعم المطوّر</string>
|
||||
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز المستخدَم.</string>
|
||||
<string name="upgrade_screen_options_description">نفس الميزات، أسعار مختلفة. يشمل الاشتراك فترة تجريبية مجانية ويمكن إلغاؤه في أي وقت.</string>
|
||||
<string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string>
|
||||
<string name="upgrade_screen_subscription_action">الاشتراك سنويًا</string>
|
||||
<string name="upgrade_screen_subscription_action_hint">%s/سنة</string>
|
||||
<string name="upgrade_screen_iap_action">شراء لمرة واحدة</string>
|
||||
<string name="upgrade_screen_iap_action_hint">شراء لمرة واحدة: %s</string>
|
||||
<string name="upgrade_screen_restore_purchase_action">استعادة المُشتَرَى</string>
|
||||
<string name="upgrade_screen_iap_action">اشترِ مرة واحدة</string>
|
||||
<string name="upgrade_screen_iap_action_hint">الشراء لمرة واحدة: %s</string>
|
||||
<string name="upgrade_screen_restore_purchase_action">استعادة المشتريات</string>
|
||||
<string name="upgrade_screen_restore_purchase_message">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">إذا اشتريت مؤخراً، قد يستغرق Google Play لحظة للمزامنة.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">حاول مرة أخرى بعد بضع دقائق إذا لم يظهر شراؤك.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">تأكد من تسجيل دخولك بنفس حساب Google المستخدم للشراء.</string>
|
||||
<string name="settings_monitor_mode_label">وضع المراقبة</string>
|
||||
<string name="settings_monitor_mode_description">تحت أي ظروف يراقب هذا التطبيق بيانات البلوتوث.</string>
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">إذا اشتريت مؤخّرًا، فقد يستغرق الأمر بعض الوقت حتى تتم مزامنة حسابك على Google Play.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">حاول مرة أخرى بعد بضع دقائق إذا لم يظهر طلبك.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">تأكّد من تسجيل دخولك باستخدام حساب Google نفسه المستخدَم في عملية الشراء.</string>
|
||||
<string name="settings_monitor_connected_notification_label">إشعار إضافي</string>
|
||||
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string>
|
||||
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
|
||||
<string name="settings_autopplay_label">تشغيل تلقائي</string>
|
||||
<string name="settings_autoplay_description">بدء تشغيل الصوت عند ارتداء الجهاز.</string>
|
||||
<string name="settings_autoplay_description">استئناف الموسيقى عند ارتداء السماعات مجددًا، ولكن فقط إذا أوقفها الإيقاف التلقائي. الإيقافات التي قمت بتشغيلها بنفسك (الهاتف، ذراع AirPods، وضع السكون) تبقى متوقفة.</string>
|
||||
<string name="settings_start_music_on_wear_label">تشغيل الموسيقى عند الارتداء</string>
|
||||
<string name="settings_start_music_on_wear_description">يحاول دائمًا تشغيل الموسيقى عند وضع السماعات، حتى بعد الإيقاف اليدوي.</string>
|
||||
<string name="settings_eardetection_info_label">ملاحظة اكتشاف الأذن</string>
|
||||
<string name="settings_eardetection_info_description">إذا كان اكتشاف الأذن يعمل لسماعة واحدة فقط، فهذا قيد من Apple. يتم اكتشاف \"السماعة الأساسية\" فقط (المستخدمة للميكروفون). قم بالتكوين على أجهزة Apple: الإعدادات → Bluetooth → AirPods → الميكروفون.</string>
|
||||
<string name="settings_fake_data_label">بيانات وهمية</string>
|
||||
<string name="settings_fake_data_description">عرض بيانات وهمية، أي محاكاة الأجهزة غير الموجودة.</string>
|
||||
<string name="settings_debug_label">إعدادات التصحيح</string>
|
||||
<string name="settings_debug_description">إعدادات إضافية للمساعدة في استكشاف المشكلات المتعلقة بالتطبيق وإصلاحها.</string>
|
||||
<string name="settings_signal_minimum_label">الحد الأدنى لجودة الإشارة</string>
|
||||
<string name="settings_signal_minimum_description">الحد الأدنى لجودة الإشارة التي يجب أن يتمتع بها الجهاز حتى يعتبر ملكك.</string>
|
||||
<string name="settings_autoconnect_label">اتصال تلقائي</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">إظهار نافذة منبثقة للاتصال</string>
|
||||
<string name="settings_popup_connected_description">إظهار نافذة منبثقة عند اتصال الجهاز لأول مرة.</string>
|
||||
<string name="settings_popup_info_not_in_app">تظهر النوافذ المنبثقة فقط عندما لا تكون داخل التطبيق.</string>
|
||||
<string name="settings_popup_warning_manual_mode">تتطلب النوافذ المنبثقة تشغيل المراقب. وضع المراقبة الخاص بك مضبوط على \"عند فتح التطبيق\"، مما يوقف المراقب عند مغادرة التطبيق.</string>
|
||||
<string name="general_fix_it_action">إصلاحه</string>
|
||||
<string name="device_settings_experimental_title">ميزة تجريبية</string>
|
||||
<string name="device_settings_experimental_description">لم يتم اختبار هذه الميزة على جميع الأجهزة بعد. إذا كان هناك شيء لا يعمل بشكل صحيح، يرجى فتح تقرير مشكلة مع سجل تصحيح.</string>
|
||||
<string name="device_settings_experimental_action">فتح متتبع المشكلات</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">شكر وتقدير</string>
|
||||
<string name="settings_debug_autoreports_label">تقارير الأخطاء التلقائية</string>
|
||||
<string name="settings_debug_autoreports_description">الإبلاغ تلقائيًا عن المشكلات، على سبيل المثال تفاصيل حول تعطل التطبيق حتى أتمكن من معرفة كيفية إصلاحه.</string>
|
||||
<string name="settings_debug_mode_label">وضع التصحيح</string>
|
||||
<string name="settings_debug_mode_description">عرض معلومات إضافية لاستكشاف المشكلات وإصلاحها.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">بيانات BLE غير المصفاة</string>
|
||||
<string name="settings_blescanner_unfiltered_description">إزالة أي عوامل تصفية من ماسح BLE لعرض جميع بيانات BLE المذاعة. مفيد لإضافة دعم لأنواع سماعات الرأس الجديدة.</string>
|
||||
<string name="settings_compatibility_mode_label">وضع التوافق</string>
|
||||
<string name="settings_compatibility_mode_description">تعطيل التحسينات لتحسين التوافق. جرب هذا إذا كنت لا ترى أي بيانات.</string>
|
||||
<string name="help_translate_label">الترجمة</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">يتطلب هذا الودجت اتصالاً مباشراً (AAP) بجهازك. تأكد من إقران جهازك وتوصيله في التطبيق قبل استخدام هذا الودجت.</string>
|
||||
<string name="anc_widget_no_anc_support_label">هذا الجهاز لا يدعم التحكم في الضوضاء</string>
|
||||
<string name="anc_widget_aap_not_connected_label">غير متصل</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">قريب ولكن غير متصل</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">غير قريب</string>
|
||||
<string name="anc_widget_aap_connecting_label">جارٍ الاتصال…</string>
|
||||
<string name="tile_anc_label">التحكم في الضوضاء</string>
|
||||
<string name="tile_anc_subtitle_permission_required">الإذن مطلوب</string>
|
||||
<string name="tile_anc_subtitle_no_device">لا يوجد جهاز</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">لا يوجد تحكم في الضوضاء</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">البلوتوث مُغلق</string>
|
||||
<string name="widget_config_screen_title">إعداد عنصر الواجهة</string>
|
||||
<string name="widget_configuration_title">اختيار الجهاز</string>
|
||||
<string name="widget_configuration_description">اختر ملف تعريف الجهاز الذي يجب أن تعرضه هذه الأداة.</string>
|
||||
@@ -228,9 +225,6 @@
|
||||
<string name="permission_required_title">الإذن التالي مطلوب:</string>
|
||||
<string name="permission_system_alert_window_label">نافذة تنبيه النظام</string>
|
||||
<string name="permission_system_alert_window_description">السماح لـ CAPod بالرسم فوق التطبيقات الأخرى لتمكين ميزة \"إظهار النافذة المنبثقة\".</string>
|
||||
<string name="settings_monitor_mode_manual_label">عند فتح التطبيق</string>
|
||||
<string name="settings_monitor_mode_automatic_label">عند اتصال الجهاز</string>
|
||||
<string name="settings_monitor_mode_always_label">دائمًا</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">عند الرؤية</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">العلبة مفتوحة</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">في الأذن</string>
|
||||
@@ -262,6 +256,11 @@
|
||||
<string name="anc_mode_transparency">الشفافية</string>
|
||||
<string name="anc_mode_adaptive">تكيفي</string>
|
||||
<string name="conversation_awareness_label">الوعي بالمحادثة</string>
|
||||
<string name="settings_conversation_action_label">عند بدء الكلام</string>
|
||||
<string name="settings_conversation_action_nothing_label">لا تفعل شيئًا</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">خفض مستوى الصوت</string>
|
||||
<string name="settings_conversation_action_pause_label">إيقاف الوسائط</string>
|
||||
<string name="settings_conversation_volume_reduction_label">تخفيض مستوى الصوت</string>
|
||||
<string name="signal_badge_ble_cd">مسح BLE نشط</string>
|
||||
<string name="signal_badge_key_irk_cd">تم التحقق من الهوية</string>
|
||||
<string name="signal_badge_key_encrypted_cd">اتصال مشفر</string>
|
||||
@@ -330,7 +329,6 @@
|
||||
<string name="ui_theme_color_amber_label">كهرماني</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">يستخدم ألوان الخلفية</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">المراقبة</string>
|
||||
<string name="settings_category_appearance_label">المظهر</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">تغييرات غير محفوظة</string>
|
||||
@@ -490,7 +488,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">عام</string>
|
||||
<string name="device_settings_microphone_mode_label">ميكروفون</string>
|
||||
<string name="device_settings_microphone_mode_description">أي AirPod يُستخدم كميكروفون</string>
|
||||
<string name="device_settings_microphone_mode_description">سماعة الأذن المستخدمة كميكروفون</string>
|
||||
<string name="device_settings_microphone_mode_auto">تلقائي</string>
|
||||
<string name="device_settings_microphone_mode_right">يمين</string>
|
||||
<string name="device_settings_microphone_mode_left">يسار</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Əgər bu yaxınlarda satın almısınızsa, Google Play-in sinxronlaşması bir az vaxt ala bilər.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Alışınız görünmürsə bir neçə dəqiqədən sonra yenidən cəhd edin.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Alış üçün istifadə edilmiş eyni Google hesabı ilə daxil olduğunuzu yoxlayın.</string>
|
||||
<string name="settings_monitor_mode_label">İzləmə rejimi</string>
|
||||
<string name="settings_monitor_mode_description">Bu tətbiqin Bluetooth məlumatlarını hansı şəraitdə izlədiyi.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Əlavə bildiriş</string>
|
||||
<string name="settings_monitor_connected_notification_description">Cihaz qoşulduqda əlavə bildiriş göstərir. Bu, \"Cihaz statusu\" kanalını deaktiv etməklə daimi \"Cihaz yoxdur\" bildirişini gizlətməyə imkan verir.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Ayrıldıqdan sonra bildirişi saxla</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Avtomatik fasilə</string>
|
||||
<string name="settings_autopause_description">Cihazı qulağınızdan çıxardıqda səsi dayandırın.</string>
|
||||
<string name="settings_autopplay_label">Avtomatik oxutma</string>
|
||||
<string name="settings_autoplay_description">Cihaz taxıldıqda səs oxutmasını başladın.</string>
|
||||
<string name="settings_autoplay_description">Podlarınızı yenidən taxdıqda musiqini davam etdirin, lakin yalnız Avtomatik Fasilə onu dayandırdıqda. Özünüz dayandırdığınız fasilələr (telefon, AirPods sapı, yuxu) fasilə vəziyyətində qalır.</string>
|
||||
<string name="settings_start_music_on_wear_label">Taxdıqda musiqini başlat</string>
|
||||
<string name="settings_start_music_on_wear_description">Podlarınızı taxdıqda həmişə musiqi çalmağa çalışır, hətta əl ilə fasilə verdikdən sonra belə.</string>
|
||||
<string name="settings_eardetection_info_label">Qulaq aşkarlama qeydi</string>
|
||||
<string name="settings_eardetection_info_description">Qulaq aşkarlama yalnız bir qulaqlıq üçün işləyirsə, bu Apple məhdudiyyətidir. Yalnız \"əsas qulaqlıq\" (mikrofon üçün istifadə olunan) aşkarlanır. Apple cihazlarında tənzimləyin: Parametrlər → Bluetooth → AirPods → Mikrofon.</string>
|
||||
<string name="settings_fake_data_label">Saxta məlumat</string>
|
||||
<string name="settings_fake_data_description">Saxta məlumat göstər, yəni mövcud olmayan cihazları simulyasiya et.</string>
|
||||
<string name="settings_debug_label">Sazlama parametrləri</string>
|
||||
<string name="settings_debug_description">Tətbiqlə bağlı problemlərin aradan qaldırılmasına kömək edən əlavə parametrlər.</string>
|
||||
<string name="settings_signal_minimum_label">Minimum siqnal keyfiyyəti</string>
|
||||
<string name="settings_signal_minimum_description">Bir cihazın sizə aid hesab edilməsi üçün malik olmalı olduğu minimum siqnal keyfiyyəti.</string>
|
||||
<string name="settings_autoconnect_label">Avtomatik qoşulma</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Qoşulma pop-up-ını göstər</string>
|
||||
<string name="settings_popup_connected_description">Cihaz ilk dəfə qoşulduqda pop-up göstər.</string>
|
||||
<string name="settings_popup_info_not_in_app">Açılan pəncərələr yalnız tətbiqdə olmadığınız zaman göstərilir.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Açılan pəncərələr monitorun işləməsini tələb edir. Monitor rejiminizdə \"Tətbiq açıq olduqda\" seçilmişdir — bu, tətbiqdən çıxdığınız zaman monitoru dayandırır.</string>
|
||||
<string name="general_fix_it_action">Düzəlt</string>
|
||||
<string name="device_settings_experimental_title">Eksperimental funksiya</string>
|
||||
<string name="device_settings_experimental_description">Bu funksiya hələ bütün cihazlarda sınaqdan keçirilməyib. Bir şey düzgün işləmirsə, debug loqu ilə problem bildirin.</string>
|
||||
<string name="device_settings_experimental_action">Problem İzləyicisini Aç</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<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_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_mode_label">Sazlama rejimi</string>
|
||||
<string name="settings_debug_mode_description">Problemlərin aradan qaldırılması üçün əlavə məlumat göstərin.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Filtrlənməmiş BLE məlumatları</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Bütün yayımlanan BLE məlumatlarını göstərmək üçün BLE skanerindən hər hansı bir filtri çıxarın. Yeni qulaqlıq növləri üçün dəstək əlavə etmək üçün faydalıdır.</string>
|
||||
<string name="settings_compatibility_mode_label">Uyğunluq rejimi</string>
|
||||
<string name="settings_compatibility_mode_description">Uyğunluğu yaxşılaşdırmaq üçün optimallaşdırmaları deaktiv edin. Heç bir məlumat görmürsünüzsə, bunu sınayın.</string>
|
||||
<string name="help_translate_label">Tərcümə</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Bu vidcet cihazınızla birbaşa bağlantı (AAP) tələb edir. Bu vidceti istifadə etməzdən əvvəl cihazınızın tətbiqdə cütləşdirilmiş və qoşulu olduğuna əmin olun.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Bu cihaz səs-küy idarəetməsini dəstəkləmir</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Qoşulmayıb</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">Yaxındadır, lakin qoşulmayıb</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Yaxında deyil</string>
|
||||
<string name="anc_widget_aap_connecting_label">Qoşulur…</string>
|
||||
<string name="tile_anc_label">Səs-küy Nəzarəti</string>
|
||||
<string name="tile_anc_subtitle_permission_required">İcazə tələb olunur</string>
|
||||
<string name="tile_anc_subtitle_no_device">Cihaz yoxdur</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Səs-küy nəzarəti yoxdur</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth söndürülüb</string>
|
||||
<string name="widget_config_screen_title">Vidcet Konfiqurasiyası</string>
|
||||
<string name="widget_configuration_title">Cihaz seçin</string>
|
||||
<string name="widget_configuration_description">Bu vidcetin hansı cihaz profilini göstərəcəyini seçin.</string>
|
||||
@@ -220,16 +217,13 @@
|
||||
<string name="permission_required_title">Aşağıdakı icazə tələb olunur:</string>
|
||||
<string name="permission_system_alert_window_label">Sistem xəbərdarlıq pəncərəsi</string>
|
||||
<string name="permission_system_alert_window_description">\"Pop-up göstər\" xüsusiyyətini mümkün etmək üçün CAPod-a digər tətbiqlərin üzərində çəkməyə icazə verin.</string>
|
||||
<string name="settings_monitor_mode_manual_label">Tətbiq açıq olduqda</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cihaz qoşulduqda</string>
|
||||
<string name="settings_monitor_mode_always_label">Həmişə</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Görüldüyündə</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Qutu açıqdır</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">Qulaqda</string>
|
||||
<string name="pods_dual_left_label">Sol qulaqlıq</string>
|
||||
<string name="pods_dual_right_label">Sağ qulaqlıq</string>
|
||||
<string name="pods_dual_left_short_label">S</string>
|
||||
<string name="pods_dual_right_short_label">SĚ</string>
|
||||
<string name="pods_dual_right_short_label">Sğ</string>
|
||||
<string name="pods_case_label">Qutu</string>
|
||||
<string name="battery_unavailable_label">Batareya məlumatı yoxdur</string>
|
||||
<string name="pods_case_status_open_label">Açıq</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Şəffaflıq</string>
|
||||
<string name="anc_mode_adaptive">Adaptiv</string>
|
||||
<string name="conversation_awareness_label">Söhbət Məlumatlılığı</string>
|
||||
<string name="settings_conversation_action_label">Danışmağa başladıqda</string>
|
||||
<string name="settings_conversation_action_nothing_label">Heç nə etmə</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Səsi azalt</string>
|
||||
<string name="settings_conversation_action_pause_label">Medianı dayandır</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Səs azaldılması</string>
|
||||
<string name="signal_badge_ble_cd">BLE skanı aktiv</string>
|
||||
<string name="signal_badge_key_irk_cd">Kimlik təsdiqləndi</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Şifrəli bağlantı</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Kəhrəba</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Divar kăğizi rənglərini istifadə edir</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Monitorinq</string>
|
||||
<string name="settings_category_appearance_label">Görünüş</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Saxlanmamış dəyişikliklər</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Ümumi</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_description">Mikrofonun hansı AirPod tərəfindən istifadə edildiyini</string>
|
||||
<string name="device_settings_microphone_mode_description">Mikrofon kimi hansı qulaqlıq istifadə olunur</string>
|
||||
<string name="device_settings_microphone_mode_auto">Avtomatik</string>
|
||||
<string name="device_settings_microphone_mode_right">Sağ</string>
|
||||
<string name="device_settings_microphone_mode_left">Sol</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Калі вы нядаўна зрабілі пакупку, Google Play можа спатрэбіцца некаторы час для сінхранізацыі.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Паспрабуйце яшчэ раз праз некалькі хвілін, калі ваша пакупка не з\'яўляецца.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Пераканайцеся, што вы ўвайшлі ў той самы акаўнт Google, які быў выкарыстаны для пакупкі.</string>
|
||||
<string name="settings_monitor_mode_label">Рэжым маніторынгу</string>
|
||||
<string name="settings_monitor_mode_description">Пры якіх абставінах гэта праграма сочыць за данымі Bluetooth.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Дадатковае апавяшчэнне</string>
|
||||
<string name="settings_monitor_connected_notification_description">Паказвае дадатковае апавяшчэнне пры падключэнні прылады. Гэта дазваляе схаваць пастаяннае апавяшчэнне \"Няма прылад\" шляхам адключэння канала \"Стан прылады\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Захаваць апавяшчэнне пасля адключэння</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Аўтаматычная паўза</string>
|
||||
<string name="settings_autopause_description">Прыпыняць аўдыя пры выманні прылады з вуха.</string>
|
||||
<string name="settings_autopplay_label">Аўтаматычнае прайграванне</string>
|
||||
<string name="settings_autoplay_description">Пачынаць прайграванне аўдыя пры надзяванні прылады.</string>
|
||||
<string name="settings_autoplay_description">Аднавіць музыку пры паўторным надзяванні навушнікаў, але толькі калі яе спыніла аўтапаўза. Паўзы, якія вы самі ўключылі (тэлефон, ножка AirPods, сон), застаюцца.</string>
|
||||
<string name="settings_start_music_on_wear_label">Пачынаць музыку пры надзяванні</string>
|
||||
<string name="settings_start_music_on_wear_description">Заўсёды спрабуе прайграць музыку, калі вы надзяваеце навушнікі, нават пасля ручной паўзы.</string>
|
||||
<string name="settings_eardetection_info_label">Заўвага пра вызначэнне вуха</string>
|
||||
<string name="settings_eardetection_info_description">Калі вызначэнне вуха працуе толькі для аднаго навушніка, гэта абмежаванне Apple. Вызначаецца толькі \"асноўны навушнік\" (выкарыстоўваецца для мікрафона). Наладзьце на прыладах Apple: Налады → Bluetooth → AirPods → Мікрафон.</string>
|
||||
<string name="settings_fake_data_label">Падробленыя даныя</string>
|
||||
<string name="settings_fake_data_description">Паказваць падробленыя даныя, г.зн. імітаваць неіснуючыя прылады.</string>
|
||||
<string name="settings_debug_label">Налады адладкі</string>
|
||||
<string name="settings_debug_description">Дадатковыя налады для ліквідацыі непаладак з праграмай.</string>
|
||||
<string name="settings_signal_minimum_label">Мінімальная якасць сігналу</string>
|
||||
<string name="settings_signal_minimum_description">Мінімальная якасць сігналу, якую павінна мець прылада, каб лічыцца вашай.</string>
|
||||
<string name="settings_autoconnect_label">Аўтаматычнае падключэнне</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Паказаць усплывальнае акно падключэння</string>
|
||||
<string name="settings_popup_connected_description">Паказваць усплывальнае акно пры першым падключэнні прылады.</string>
|
||||
<string name="settings_popup_info_not_in_app">Усплывальныя вокны паказваюцца толькі тады, калі вы не знаходзіцеся ў прыкладанні.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Усплывальныя вокны патрабуюць працы манітора. Рэжым манітора ўсталяваны на «Калі прыкладанне адкрыта», які спыняе маніторынг пры выхадзе з прыкладання.</string>
|
||||
<string name="general_fix_it_action">Выправіць</string>
|
||||
<string name="device_settings_experimental_title">Эксперыментальная функцыя</string>
|
||||
<string name="device_settings_experimental_description">Гэта функцыя яшчэ не была праверана на ўсіх прыладах. Калі нешта не працуе правільна, калі ласка, стварыце запыт з журналам адладкі.</string>
|
||||
<string name="device_settings_experimental_action">Адкрыць трэкер задач</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">Падзякі</string>
|
||||
<string name="settings_debug_autoreports_label">Аўтаматычныя справаздачы пра памылкі</string>
|
||||
<string name="settings_debug_autoreports_description">Аўтаматычна паведамляе пра праблемы, напрыклад, звесткі пра збой праграмы, каб я мог зразумець, як гэта выправіць.</string>
|
||||
<string name="settings_debug_mode_label">Рэжым адладкі</string>
|
||||
<string name="settings_debug_mode_description">Паказваць дадатковую інфармацыю для ліквідацыі непаладак.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Нефільтраваныя даныя BLE</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Выдаліце ўсе фільтры са сканера BLE, каб паказаць усе трансляваныя даныя BLE. Карысна для дадання падтрымкі новых тыпаў навушнікаў.</string>
|
||||
<string name="settings_compatibility_mode_label">Рэжым сумяшчальнасці</string>
|
||||
<string name="settings_compatibility_mode_description">Адключыце аптымізацыі для паляпшэння сумяшчальнасці. Паспрабуйце гэта, калі вы не бачыце ніякіх даных.</string>
|
||||
<string name="help_translate_label">Пераклад</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Гэты віджэт патрабуе прамога злучэння (AAP) з вашай прыладай. Пераканайцеся, што ваша прылада спалучана і падключана ў праграме перад выкарыстаннем гэтага віджэта.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Гэта прылада не падтрымлівае кіраванне шумам</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Не падключана</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">Побач, але не падключана</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Не побач</string>
|
||||
<string name="anc_widget_aap_connecting_label">Падключэнне…</string>
|
||||
<string name="tile_anc_label">Кіраванне шумам</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Патрабуецца дазвол</string>
|
||||
<string name="tile_anc_subtitle_no_device">Няма прылады</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Няма кіравання шумам</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth выключаны</string>
|
||||
<string name="widget_config_screen_title">Канфігурацыя віджэта</string>
|
||||
<string name="widget_configuration_title">Выбраць прыладу</string>
|
||||
<string name="widget_configuration_description">Выберыце, які профіль прылады павінен адлюстроўваць гэты віджэт.</string>
|
||||
@@ -224,9 +221,6 @@
|
||||
<string name="permission_required_title">Патрабуецца наступны дазвол:</string>
|
||||
<string name="permission_system_alert_window_label">Сістэмнае акно папярэджання</string>
|
||||
<string name="permission_system_alert_window_description">Дазвольце CAPod маляваць паверх іншых праграм, каб зрабіць магчымай функцыю \"Паказаць усплывальнае акно\".</string>
|
||||
<string name="settings_monitor_mode_manual_label">Калі праграма адкрыта</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Калі прылада падключана</string>
|
||||
<string name="settings_monitor_mode_always_label">Заўсёды</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Калі бачна</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Корпус адкрыты</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">У вуху</string>
|
||||
@@ -258,6 +252,11 @@
|
||||
<string name="anc_mode_transparency">Празрыстасць</string>
|
||||
<string name="anc_mode_adaptive">Адаптыўны</string>
|
||||
<string name="conversation_awareness_label">Усведамленне размовы</string>
|
||||
<string name="settings_conversation_action_label">Калі вы пачынаеце гаварыць</string>
|
||||
<string name="settings_conversation_action_nothing_label">Нічога не рабіць</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Зменшыць гучнасць</string>
|
||||
<string name="settings_conversation_action_pause_label">Паставіць медыя на паўзу</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Зніжэнне гучнасці</string>
|
||||
<string name="signal_badge_ble_cd">BLE-сканаванне актыўнае</string>
|
||||
<string name="signal_badge_key_irk_cd">Асоба пацверджана</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Зашыфраванае злучэнне</string>
|
||||
@@ -326,7 +325,6 @@
|
||||
<string name="ui_theme_color_amber_label">Бурштынавы</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Выкарыстоўвае колеры шпалеры</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Маніторынг</string>
|
||||
<string name="settings_category_appearance_label">Знешні выгляд</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Незахаваныя змены</string>
|
||||
@@ -480,7 +478,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Агульныя</string>
|
||||
<string name="device_settings_microphone_mode_label">Мікрафон</string>
|
||||
<string name="device_settings_microphone_mode_description">Які AirPod выкарыстоўваецца як мікрафон</string>
|
||||
<string name="device_settings_microphone_mode_description">Які навушнік выкарыстоўваецца як мікрафон</string>
|
||||
<string name="device_settings_microphone_mode_auto">Аўта</string>
|
||||
<string name="device_settings_microphone_mode_right">Правы</string>
|
||||
<string name="device_settings_microphone_mode_left">Левы</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Ако наскоро сте направили покупка, може да отнеме малко, докато Google Play синхронизира.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Опитайте отново след няколко минути, ако покупката ви не се появи.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Уверете се, че сте влезли с акаунта в Google, използван за покупката.</string>
|
||||
<string name="settings_monitor_mode_label">Режим на наблюдение</string>
|
||||
<string name="settings_monitor_mode_description">При какви обстоятелства това приложение следи Bluetooth данните.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Допълнително известие</string>
|
||||
<string name="settings_monitor_connected_notification_description">Показва допълнително известие, когато устройство е свързано. Това ви позволява да скриете постоянното известие \"Няма устройства\" чрез деактивиране на канала \"Състояние на устройството\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Запази известието след прекъсване</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Автоматична пауза</string>
|
||||
<string name="settings_autopause_description">Поставяне на аудио на пауза при премахване на устройството от ухото ви.</string>
|
||||
<string name="settings_autopplay_label">Автоматично възпроизвеждане</string>
|
||||
<string name="settings_autoplay_description">Стартиране на аудио възпроизвеждане, когато устройството се носи.</string>
|
||||
<string name="settings_autoplay_description">Възобновява музиката, когато отново сложите слушалките, но само ако е спряна от Автоматично пауза. Паузите, направени от вас (телефон, дръжка на AirPods, заспиване), остават.</string>
|
||||
<string name="settings_start_music_on_wear_label">Стартиране на музика при поставяне</string>
|
||||
<string name="settings_start_music_on_wear_description">Винаги се опитва да пусне музика, когато сложите слушалките, дори след ръчна пауза.</string>
|
||||
<string name="settings_eardetection_info_label">Бележка за разпознаване на ухото</string>
|
||||
<string name="settings_eardetection_info_description">Ако разпознаването на ухото работи само за една слушалка, това е ограничение на Apple. Само \"основната слушалка\" (използвана за микрофон) се разпознава. Конфигурирайте на Apple устройства: Настройки → Bluetooth → AirPods → Микрофон.</string>
|
||||
<string name="settings_fake_data_label">Фалшиви данни</string>
|
||||
<string name="settings_fake_data_description">Показване на фалшиви данни, т.е. симулиране на устройства, които не съществуват.</string>
|
||||
<string name="settings_debug_label">Настройки за отстраняване на грешки</string>
|
||||
<string name="settings_debug_description">Допълнителни настройки за подпомагане на отстраняването на проблеми с приложението.</string>
|
||||
<string name="settings_signal_minimum_label">Минимално качество на сигнала</string>
|
||||
<string name="settings_signal_minimum_description">Минималното качество на сигнала, което устройството трябва да има, за да се счита за ваше.</string>
|
||||
<string name="settings_autoconnect_label">Автоматично свързване</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Показване на изскачащ прозорец за връзка</string>
|
||||
<string name="settings_popup_connected_description">Показване на изскачащ прозорец, когато устройството се свърже за първи път.</string>
|
||||
<string name="settings_popup_info_not_in_app">Изскачащите прозорци се показват само когато не сте в приложението.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Изскачащите прозорци изискват мониторът да работи. Режимът на монитора е зададен на „Когато приложението е отворено“, което спира монитора, когато напуснете приложението.</string>
|
||||
<string name="general_fix_it_action">Поправи го</string>
|
||||
<string name="device_settings_experimental_title">Експериментална функция</string>
|
||||
<string name="device_settings_experimental_description">Тази функция все още не е тествана на всички устройства. Ако нещо не работи правилно, моля отворете проблем с дневник за отстраняване на грешки.</string>
|
||||
<string name="device_settings_experimental_action">Отвори проследявач на проблеми</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">Благодарности</string>
|
||||
<string name="settings_debug_autoreports_label">Автоматични доклади за грешки</string>
|
||||
<string name="settings_debug_autoreports_description">Автоматично докладва проблеми, напр. подробности за срив на приложението, за да мога да разбера как да го поправя.</string>
|
||||
<string name="settings_debug_mode_label">Режим за отстраняване на грешки</string>
|
||||
<string name="settings_debug_mode_description">Показване на допълнителна информация за отстраняване на проблеми.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Нефилтрирани BLE данни</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Премахнете всички филтри от BLE скенера, за да се покажат всички излъчвани BLE данни. Полезно за добавяне на поддръжка за нови типове слушалки.</string>
|
||||
<string name="settings_compatibility_mode_label">Режим на съвместимост</string>
|
||||
<string name="settings_compatibility_mode_description">Деактивирайте оптимизациите, за да подобрите съвместимостта. Опитайте това, ако не виждате никакви данни.</string>
|
||||
<string name="help_translate_label">Превод</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Този уиджет изисква директна връзка (AAP) с вашето устройство. Уверете се, че устройството е сдвоено и свързано в приложението преди да използвате този уиджет.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Това устройство не поддържа контрол на шума</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Не е свързано</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">Наблизо, но не свързано</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Не е наблизо</string>
|
||||
<string name="anc_widget_aap_connecting_label">Свързване…</string>
|
||||
<string name="tile_anc_label">Шумов контрол</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Необходимо е разрешение</string>
|
||||
<string name="tile_anc_subtitle_no_device">Няма устройство</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Без шумов контрол</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth е изключен</string>
|
||||
<string name="widget_config_screen_title">Настройка на джаджата</string>
|
||||
<string name="widget_configuration_title">Избор на устройство</string>
|
||||
<string name="widget_configuration_description">Изберете кой профил на устройство да показва това приспособление.</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">Необходимо е следното разрешение:</string>
|
||||
<string name="permission_system_alert_window_label">Системен изскачащ прозорец</string>
|
||||
<string name="permission_system_alert_window_description">Позволете на CAPod да рисува върху други приложения, за да направи функцията \"Покажи изскачащ прозорец\" възможна.</string>
|
||||
<string name="settings_monitor_mode_manual_label">Когато приложението е отворено</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Когато устройството е свързано</string>
|
||||
<string name="settings_monitor_mode_always_label">Винаги</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Когато е видяно</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Калъфът е отворен</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">В ухото</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Прозрачност</string>
|
||||
<string name="anc_mode_adaptive">Адаптивен</string>
|
||||
<string name="conversation_awareness_label">Разговорна осъзнатост</string>
|
||||
<string name="settings_conversation_action_label">Когато започнете да говорите</string>
|
||||
<string name="settings_conversation_action_nothing_label">Без действие</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Намаляване на силата на звука</string>
|
||||
<string name="settings_conversation_action_pause_label">Пауза на медията</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Намаляване на силата на звука</string>
|
||||
<string name="signal_badge_ble_cd">BLE сканирането е активно</string>
|
||||
<string name="signal_badge_key_irk_cd">Самоличността е потвърдена</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Шифрована връзка</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Къхъленожълт</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Използва цветовете на тапета</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Мониторинг</string>
|
||||
<string name="settings_category_appearance_label">Изглед</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Незапазени промени</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Общи</string>
|
||||
<string name="device_settings_microphone_mode_label">Микрофон</string>
|
||||
<string name="device_settings_microphone_mode_description">Кой AirPod се използва като микрофон</string>
|
||||
<string name="device_settings_microphone_mode_description">Коя слушалка се използва като микрофон</string>
|
||||
<string name="device_settings_microphone_mode_auto">Автом.</string>
|
||||
<string name="device_settings_microphone_mode_right">Десен</string>
|
||||
<string name="device_settings_microphone_mode_left">Ляв</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">আপনি সম্প্রতি কিনে থাকলে, Google Play সিঙ্ক করতে কিছু সময় লাগতে পারে।</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">কয়েক মিনিট পরে আবার চেষ্টা করুন যদি আপনার ক্রয় না দেখায়।</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">নিশ্চিত করুন যে আপনি ক্রয়ের জন্য ব্যবহৃত একই Google অ্যাকাউন্ট দিয়ে সাইন ইন করেছেন।</string>
|
||||
<string name="settings_monitor_mode_label">মনিটর মোড</string>
|
||||
<string name="settings_monitor_mode_description">এই অ্যাপটি কোন পরিস্থিতিতে ব্লুটুথ ডেটা পর্যবেক্ষণ করবে।</string>
|
||||
<string name="settings_monitor_connected_notification_label">অতিরিক্ত বিজ্ঞপ্তি</string>
|
||||
<string name="settings_monitor_connected_notification_description">একটি ডিভাইস সংযুক্ত হলে একটি অতিরিক্ত বিজ্ঞপ্তি দেখায়। এটি আপনাকে \"ডিভাইস স্ট্যাটাস\" চ্যানেল নিষ্ক্রিয় করে স্থায়ী \"কোনো ডিভাইস নেই\" বিজ্ঞপ্তি লুকাতে দেয়।</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">সংযোগ বিচ্ছিন্নের পরে বিজ্ঞপ্তি রাখুন</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">স্বয়ংক্রিয় বিরতি</string>
|
||||
<string name="settings_autopause_description">আপনার কান থেকে ডিভাইসটি সরানোর সময় অডিওতে বিরতি দিন।</string>
|
||||
<string name="settings_autopplay_label">স্বয়ংক্রিয় প্লে</string>
|
||||
<string name="settings_autoplay_description">ডিভাইস পরিধান করার সময় অডিও প্লেব্যাক শুরু করুন।</string>
|
||||
<string name="settings_autoplay_description">আপনার পড পুনরায় পরলে সংগীত চালু করুন, তবে শুধুমাত্র যদি অটো পজ এটি থামিয়ে দিয়ে থাকে। আপনি নিজে যে পজ করেছেন (ফোন, AirPods স্টেম, স্লিপ) সেগুলো পজ থাকবে।</string>
|
||||
<string name="settings_start_music_on_wear_label">পরলে সংগীত শুরু করুন</string>
|
||||
<string name="settings_start_music_on_wear_description">আপনি পড পরলে সবসময় সংগীত চালানোর চেষ্টা করে, এমনকি ম্যানুয়াল পজের পরেও।</string>
|
||||
<string name="settings_eardetection_info_label">কান সনাক্তকরণ নোট</string>
|
||||
<string name="settings_eardetection_info_description">যদি কান সনাক্তকরণ শুধুমাত্র একটি পডের জন্য কাজ করে, এটি একটি Apple সীমাবদ্ধতা। শুধুমাত্র \"প্রাথমিক পড\" (মাইক্রোফোনের জন্য ব্যবহৃত) সনাক্ত করা হয়। Apple ডিভাইসে কনফিগার করুন: সেটিংস → Bluetooth → AirPods → মাইক্রোফোন।</string>
|
||||
<string name="settings_fake_data_label">ভুয়া ডেটা</string>
|
||||
<string name="settings_fake_data_description">ভুয়া ডেটা দেখান, অর্থাৎ, এমন ডিভাইস সিমুলেট করুন যা বিদ্যমান নেই।</string>
|
||||
<string name="settings_debug_label">ডিবাগ সেটিংস</string>
|
||||
<string name="settings_debug_description">অ্যাপের সমস্যা সমাধানে সহায়তার জন্য অতিরিক্ত সেটিংস।</string>
|
||||
<string name="settings_signal_minimum_label">ন্যূনতম সংকেত গুণমান</string>
|
||||
<string name="settings_signal_minimum_description">একটি ডিভাইসকে আপনার হিসাবে বিবেচনা করার জন্য ন্যূনতম সংকেত গুণমান থাকতে হবে।</string>
|
||||
<string name="settings_autoconnect_label">স্বয়ংক্রিয় সংযোগ</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">সংযোগ পপআপ দেখান</string>
|
||||
<string name="settings_popup_connected_description">ডিভাইস প্রথমবার সংযুক্ত হলে একটি পপআপ দেখান।</string>
|
||||
<string name="settings_popup_info_not_in_app">পপআপগুলি শুধুমাত্র তখনই দেখানো হয় যখন আপনি অ্যাপে থাকেন না।</string>
|
||||
<string name="settings_popup_warning_manual_mode">পপআপের জন্য মনিটর চালু থাকা প্রয়োজন। আপনার মনিটর মোড \"যখন অ্যাপ খোলা থাকে\" তে সেট করা আছে, যা আপনি অ্যাপ ছেড়ে গেলে মনিটর বন্ধ করে দেয়।</string>
|
||||
<string name="general_fix_it_action">ঠিক করুন</string>
|
||||
<string name="device_settings_experimental_title">পরীক্ষামূলক ফিচার</string>
|
||||
<string name="device_settings_experimental_description">এই ফিচারটি এখনো সব ডিভাইসে পরীক্ষা করা হয়নি। যদি কিছু সঠিকভাবে কাজ না করে, দয়া করে একটি ডিবাগ লগ সহ একটি ইস্যু খুলুন।</string>
|
||||
<string name="device_settings_experimental_action">ইস্যু ট্র্যাকার খুলুন</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">স্বীকৃতি</string>
|
||||
<string name="settings_debug_autoreports_label">স্বয়ংক্রিয় বাগ রিপোর্ট</string>
|
||||
<string name="settings_debug_autoreports_description">স্বয়ংক্রিয়ভাবে সমস্যাগুলি রিপোর্ট করে, যেমন অ্যাপ ক্র্যাশের বিবরণ যাতে আমি এটি কীভাবে ঠিক করতে পারি তা বের করতে পারি।</string>
|
||||
<string name="settings_debug_mode_label">ডিবাগ মোড</string>
|
||||
<string name="settings_debug_mode_description">সমস্যা সমাধানের জন্য অতিরিক্ত তথ্য দেখান।</string>
|
||||
<string name="settings_blescanner_unfiltered_label">ফিল্টারবিহীন BLE ডেটা</string>
|
||||
<string name="settings_blescanner_unfiltered_description">সমস্ত সম্প্রচারিত BLE ডেটা দেখানোর জন্য BLE স্ক্যানার থেকে যেকোনো ফিল্টার সরান। নতুন হেডফোন প্রকারের জন্য সমর্থন যোগ করার জন্য দরকারী।</string>
|
||||
<string name="settings_compatibility_mode_label">সামঞ্জস্যতা মোড</string>
|
||||
<string name="settings_compatibility_mode_description">সামঞ্জস্যতা উন্নত করতে অপ্টিমাইজেশান নিষ্ক্রিয় করুন। যদি আপনি কোনো ডেটা দেখতে না পান তবে এটি চেষ্টা করুন।</string>
|
||||
<string name="help_translate_label">অনুবাদ</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">এই উইজেটটি আপনার ডিভাইসে একটি সরাসরি সংযোগ (AAP) প্রয়োজন। এই উইজেটটি ব্যবহার করার আগে নিশ্চিত করুন যে আপনার ডিভাইসটি অ্যাপে পেয়ার এবং সংযুক্ত আছে।</string>
|
||||
<string name="anc_widget_no_anc_support_label">এই ডিভাইসটি নয়েজ কন্ট্রোল সমর্থন করে না</string>
|
||||
<string name="anc_widget_aap_not_connected_label">সংযুক্ত নয়</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">কাছাকাছি কিন্তু সংযুক্ত নয়</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">কাছাকাছি নয়</string>
|
||||
<string name="anc_widget_aap_connecting_label">সংযুক্ত হচ্ছে…</string>
|
||||
<string name="tile_anc_label">নয়েজ কন্ট্রোল</string>
|
||||
<string name="tile_anc_subtitle_permission_required">অনুমতি প্রয়োজন</string>
|
||||
<string name="tile_anc_subtitle_no_device">কোনও ডিভাইস নেই</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">নয়েজ কন্ট্রোল নেই</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">ব্লুটুথ বন্ধ</string>
|
||||
<string name="widget_config_screen_title">উইজেট কনফিগারেশন</string>
|
||||
<string name="widget_configuration_title">ডিভাইস নির্বাচন করুন</string>
|
||||
<string name="widget_configuration_description">এই উইজেটটি কোন ডিভাইস প্রোফাইল প্রদর্শন করবে তা চয়ন করুন।</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">নিম্নলিখিত অনুমতি প্রয়োজন:</string>
|
||||
<string name="permission_system_alert_window_label">সিস্টেম অ্যালার্ট উইন্ডো</string>
|
||||
<string name="permission_system_alert_window_description">\"পপআপ দেখান\" বৈশিষ্ট্যটি সম্ভব করতে CAPod-কে অন্যান্য অ্যাপের উপরে আঁকতে অনুমতি দিন।</string>
|
||||
<string name="settings_monitor_mode_manual_label">অ্যাপ খোলা থাকলে</string>
|
||||
<string name="settings_monitor_mode_automatic_label">ডিভাইস সংযুক্ত থাকলে</string>
|
||||
<string name="settings_monitor_mode_always_label">সর্বদা</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">দেখা গেলে</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">কেস খোলা</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">কানে</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">ট্রান্সপারেন্সি</string>
|
||||
<string name="anc_mode_adaptive">অ্যাডাপটিভ</string>
|
||||
<string name="conversation_awareness_label">কথোপকথন সচেতনতা</string>
|
||||
<string name="settings_conversation_action_label">যখন আপনি কথা বলতে শুরু করেন</string>
|
||||
<string name="settings_conversation_action_nothing_label">কিছু করবেন না</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">ভলিউম কমান</string>
|
||||
<string name="settings_conversation_action_pause_label">মিডিয়া পজ করুন</string>
|
||||
<string name="settings_conversation_volume_reduction_label">ভলিউম হ্রাস</string>
|
||||
<string name="signal_badge_ble_cd">BLE স্ক্যান সক্রিয়</string>
|
||||
<string name="signal_badge_key_irk_cd">পরিচয় যাচাই করা হয়েছে</string>
|
||||
<string name="signal_badge_key_encrypted_cd">এনক্রিপ্টেড সংযোগ</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">অ্যাম্বার</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">ওয়ালপেপার রঙ ব্যবহার করে</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">মনিটরিং</string>
|
||||
<string name="settings_category_appearance_label">উপস্থিতি</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">অসংরক্ষিত পরিবর্তন</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">সাধারণ</string>
|
||||
<string name="device_settings_microphone_mode_label">মাইক্রোফোন</string>
|
||||
<string name="device_settings_microphone_mode_description">কোন AirPod মাইক্রোফোন হিসেবে ব্যবহৃত হয়</string>
|
||||
<string name="device_settings_microphone_mode_description">কোন ইয়ারবাড মাইক্রোফোন হিসেবে ব্যবহার করা হয়</string>
|
||||
<string name="device_settings_microphone_mode_auto">স্বয়ংক্রিয়</string>
|
||||
<string name="device_settings_microphone_mode_right">ডান</string>
|
||||
<string name="device_settings_microphone_mode_left">বাঁ</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Si heu comprat alguna cosa recentment, és possible que el Google Play trigui una estona a sincronitzar-se.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Torneu-ho a provar en uns minuts si la vostra compra no apareix.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Assegureu-vos d\'haver iniciat la sessió amb el mateix compte de Google que heu utilitzat per a la compra.</string>
|
||||
<string name="settings_monitor_mode_label">Mode monitor</string>
|
||||
<string name="settings_monitor_mode_description">En quines circumstàncies aquesta aplicació supervisa les dades Bluetooth.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Notificació addicional</string>
|
||||
<string name="settings_monitor_connected_notification_description">Mostra una notificació addicional quan hi ha un dispositiu connectat. Això permet ocultar la notificació permanent «Sense dispositius» desactivant el canal «Estat del dispositiu».</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Mantén les notificacions després de la desconnexió</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Pausa automàtica</string>
|
||||
<string name="settings_autopause_description">Pausa l\'àudio quan ús tragueu el dispositiu de l\'orella.</string>
|
||||
<string name="settings_autopplay_label">Reproducció automàtica</string>
|
||||
<string name="settings_autoplay_description">Inicia la reproducció d\'àudio en utilitzar el dispositiu.</string>
|
||||
<string name="settings_autoplay_description">Repren la música quan tornes a posar-te els auriculars, però només si la pausa automàtica l\'havia aturat. Les pauses que has activat tu mateix (telèfon, pal dels AirPods, son) romanen en pausa.</string>
|
||||
<string name="settings_start_music_on_wear_label">Inicia la música en posar-se els auriculars</string>
|
||||
<string name="settings_start_music_on_wear_description">Sempre intenta reproduir música quan et poses els auriculars, fins i tot després d\'una pausa manual.</string>
|
||||
<string name="settings_eardetection_info_label">Nota de detecció de l\'oïda</string>
|
||||
<string name="settings_eardetection_info_description">Si la detecció de l\'oïda només funciona per a un auricular, això és degut a una limitació d\'Apple. Només es detecta «l\'auricular principal» (utilitzat per al micròfon). Configureu-ho en dispositius Apple: Configuració → Bluetooth → AirPods → Micròfon.</string>
|
||||
<string name="settings_fake_data_label">Dades falses</string>
|
||||
<string name="settings_fake_data_description">Mostra dades falses. Per exemple: simula dispositius que no existeixen.</string>
|
||||
<string name="settings_debug_label">Configuració de depuració</string>
|
||||
<string name="settings_debug_description">Configuració addicional per ajudar a resoldre problemes amb l\'aplicació.</string>
|
||||
<string name="settings_signal_minimum_label">Qualitat de senyal mínima</string>
|
||||
<string name="settings_signal_minimum_description">La qualitat de senyal mínima que ha de tenir un dispositiu per considerar-se vostre.</string>
|
||||
<string name="settings_autoconnect_label">Connexió automàtica</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Mostra la finestra emergent de connexió</string>
|
||||
<string name="settings_popup_connected_description">Mostra una finestra emergent quan el dispositiu es connecti per primera vegada.</string>
|
||||
<string name="settings_popup_info_not_in_app">Les finestres emergents només es mostren quan no esteu a l\'aplicació.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Les finestres emergents requereixen que el monitor estigui en execució. El mode del monitor està configurat com a «quan l\'aplicació és oberta», que atura el monitor quan sortiu de l\'aplicació.</string>
|
||||
<string name="general_fix_it_action">Corregeix-ho</string>
|
||||
<string name="device_settings_experimental_title">Funció experimental</string>
|
||||
<string name="device_settings_experimental_description">Aquesta funció encara no s\'ha provat a tots els dispositius. Si alguna cosa no funciona correctament, obriu una incidència amb un registre de depuració.</string>
|
||||
<string name="device_settings_experimental_action">Obre el seguidor d\'incidències</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<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_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_mode_label">Mode depuració</string>
|
||||
<string name="settings_debug_mode_description">Mostra informació addicional per resoldre problemes.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Dades BLE sense filtrar</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Elimina els filtres de l\'escàner BLE per mostrar totes les dades BLE emeses. Útil per afegir suport a nous tipus d\'auriculars.</string>
|
||||
<string name="settings_compatibility_mode_label">Mode de compatibilitat</string>
|
||||
<string name="settings_compatibility_mode_description">Desactiva les optimitzacions per millorar la compatibilitat. Proveu això si no veieu cap dada.</string>
|
||||
<string name="help_translate_label">Traducció</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Aquest giny requereix una connexió directa (AAP) al dispositiu. Assegureu-vos que el dispositiu estigui emparellat i connectat a l\'aplicació abans d\'utilitzar aquest giny.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Aquest dispositiu no admet el control de soroll</string>
|
||||
<string name="anc_widget_aap_not_connected_label">No connectat</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">A prop però no connectat</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">No és a prop</string>
|
||||
<string name="anc_widget_aap_connecting_label">S\'està connectant…</string>
|
||||
<string name="tile_anc_label">Control de soroll</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Permís requerit</string>
|
||||
<string name="tile_anc_subtitle_no_device">Cap dispositiu</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Sense control de soroll</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth desactivat</string>
|
||||
<string name="widget_config_screen_title">Configuració del giny</string>
|
||||
<string name="widget_configuration_title">Seleccioneu un dispositiu</string>
|
||||
<string name="widget_configuration_description">Trieu quin perfil de dispositiu ha de mostrar aquest giny.</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">Cal el permís següent:</string>
|
||||
<string name="permission_system_alert_window_label">Finestra d\'alerta del sistema</string>
|
||||
<string name="permission_system_alert_window_description">Permet que el CAPod dibuixi sobre altres aplicacions per fer possible la característica «Mostra emergent».</string>
|
||||
<string name="settings_monitor_mode_manual_label">Quan l\'aplicació està oberta</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Quan el dispositiu està connectat</string>
|
||||
<string name="settings_monitor_mode_always_label">Sempre</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Quan es veu</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">La funda està oberta</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">A l\'orella</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Transparència</string>
|
||||
<string name="anc_mode_adaptive">Adaptatiu</string>
|
||||
<string name="conversation_awareness_label">Consciència de conversa</string>
|
||||
<string name="settings_conversation_action_label">Quan comences a parlar</string>
|
||||
<string name="settings_conversation_action_nothing_label">No facis res</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Baixa el volum</string>
|
||||
<string name="settings_conversation_action_pause_label">Posa en pausa el reproductor</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Reducció del volum</string>
|
||||
<string name="signal_badge_ble_cd">Escaneig BLE actiu</string>
|
||||
<string name="signal_badge_key_irk_cd">Identitat verificada</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Connexió xifrada</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Ambre</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Utilitza els colors del fons de pantalla</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Monitoratge</string>
|
||||
<string name="settings_category_appearance_label">Aparença</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Canvis no desats</string>
|
||||
@@ -391,7 +389,7 @@
|
||||
<string name="support_contact_send_action">Obre l\'aplicació de correu</string>
|
||||
<string name="support_contact_no_email_app">No s\'ha trobat cap aplicació de correu en aquest dispositiu.</string>
|
||||
<string name="support_contact_debuglog_delete_title">Voleu suprimir el registre de depuració?</string>
|
||||
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimirà permanentment.</string>
|
||||
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimrirà permanentment.</string>
|
||||
<!-- Post-share confirmation -->
|
||||
<string name="support_debuglog_sent_title">S\'ha enviat el registre?</string>
|
||||
<string name="support_debuglog_sent_message">El registre de depuració s\'ha enviat correctament? El registre s\'eliminarà.</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
<string name="device_settings_microphone_mode_label">Micròfon</string>
|
||||
<string name="device_settings_microphone_mode_description">Quin AirPod s\'utilitza com a micròfon</string>
|
||||
<string name="device_settings_microphone_mode_description">Quin auricular s\'utilitza com a micròfon</string>
|
||||
<string name="device_settings_microphone_mode_auto">Automàtic</string>
|
||||
<string name="device_settings_microphone_mode_right">Dreta</string>
|
||||
<string name="device_settings_microphone_mode_left">Esquerre</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Pokud jste produkt zakoupili nedávno, synchronizace Google Play může chvíli trvat.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Pokud se váš nákup nezobrazí, zkuste to znovu za několik minut.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Ujistěte se, že jste přihlášeni ke stejnému účtu Google, který jste použili při nákupu.</string>
|
||||
<string name="settings_monitor_mode_label">Režim monitorování</string>
|
||||
<string name="settings_monitor_mode_description">Za jakých okolností tato aplikace monitoruje data Bluetooth.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Dodatečné oznámení</string>
|
||||
<string name="settings_monitor_connected_notification_description">Zobrazí dodatečné oznámení, když je zařízení připojeno. To umožňuje skrýt trvalé oznámení \"Žádné zařízení\" vypnutím kanálu \"Stav zařízení\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Uchovat oznámení po odpojení</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Autom. pozastavení</string>
|
||||
<string name="settings_autopause_description">Pozastavení zvuku při vyjmutí zařízení z ucha.</string>
|
||||
<string name="settings_autopplay_label">Autom. přehrávání</string>
|
||||
<string name="settings_autoplay_description">Spuštění přehrávání zvuku při nošení zařízení.</string>
|
||||
<string name="settings_autoplay_description">Obnoví přehrávání hudby po opětovném nasazení sluchátek, ale pouze pokud ji zastavilo Auto pozastavení. Pauzy, které jste spustili sami (telefon, ovladač AirPods, spánek), zůstanou pozastaveny.</string>
|
||||
<string name="settings_start_music_on_wear_label">Spustit hudbu při nasazení</string>
|
||||
<string name="settings_start_music_on_wear_description">Vždy se pokusí přehrát hudbu při nasazení sluchátek, i po ručním pozastavení.</string>
|
||||
<string name="settings_eardetection_info_label">Poznámka k detekci ucha</string>
|
||||
<string name="settings_eardetection_info_description">Pokud detekce sluchátek funguje pouze pro jedno sluchátko, jedná se o omezení společnosti Apple. Detekováno je pouze \"primární sluchátko\" (používané jako mikrofon). Konfigurace na zařízeních Apple: Nastavení → Bluetooth → AirPods → Mikrofon.</string>
|
||||
<string name="settings_fake_data_label">Falešná data</string>
|
||||
<string name="settings_fake_data_description">Zobrazit falešná data např. simulovat zařízení, které neexistuje.</string>
|
||||
<string name="settings_debug_label">Nastavení ladění</string>
|
||||
<string name="settings_debug_description">Další nastavení, která pomáhají řešit problémy s aplikací.</string>
|
||||
<string name="settings_signal_minimum_label">Minimální kvalita signálu</string>
|
||||
<string name="settings_signal_minimum_description">Minimální kvalita signálu, kterou musí zařízení mít, aby bylo považováno za vaše.</string>
|
||||
<string name="settings_autoconnect_label">Automatické připojení</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Zobrazit vyskakovací okno při připojení</string>
|
||||
<string name="settings_popup_connected_description">Zobrazit vyskakovací okno při prvním připojení zařízení.</string>
|
||||
<string name="settings_popup_info_not_in_app">Vyskakovací okna jsou zobrazována pouze tehdy, když nejste v aplikaci.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Vyskakovací okna vyžadují, aby monitor běžel. Váš režim monitoru je nastaven na \"Když je aplikace otevřena\", což zastaví monitor, když aplikaci opustíte.</string>
|
||||
<string name="general_fix_it_action">Opravit</string>
|
||||
<string name="device_settings_experimental_title">Experimentální funkce</string>
|
||||
<string name="device_settings_experimental_description">Tato funkce ještě nebyla testována na všech zařízeních. Pokud něco nefunguje správně, nahlaste problém s logem ladění.</string>
|
||||
<string name="device_settings_experimental_action">Otevřít sledovač problémů</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<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_description">Automatické hlášení chyb např. podrobnosti o pádech aplikace.</string>
|
||||
<string name="settings_debug_mode_label">Režim ladění</string>
|
||||
<string name="settings_debug_mode_description">Zobrazit další informace pro řešení problémů.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Nefiltrovaná data BLE</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Aby se zobrazovala všechna vysílaná data BLE, odstraňte ze skenování BLE všechny filtry. Užitečné pro přidání podpory nových typů sluchátek.</string>
|
||||
<string name="settings_compatibility_mode_label">Režim kompatibility</string>
|
||||
<string name="settings_compatibility_mode_description">Vypnutí optimalizace pro zlepšení kompatibility. Zkuste to, pokud se vám nezobrazují žádná data.</string>
|
||||
<string name="help_translate_label">Překlad</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Tento widget vyžaduje přímé připojení (AAP) k vašemu zařízení. Před použitím tohoto widgetu se ujistěte, že je vaše zařízení spárováno a připojeno v aplikaci.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Toto zařízení nepodporuje kontrolu hluku</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Nepřipojeno</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">V dosahu, ale nepřipojeno</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Není v dosahu</string>
|
||||
<string name="anc_widget_aap_connecting_label">Připojování…</string>
|
||||
<string name="tile_anc_label">Ovládání zvuku</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Vyžadováno oprávnění</string>
|
||||
<string name="tile_anc_subtitle_no_device">Žádné zařízení</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Žádné ovládání hluku</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth je vypnuto</string>
|
||||
<string name="widget_config_screen_title">Konfigurace widgetu</string>
|
||||
<string name="widget_configuration_title">Vybrat zařízení</string>
|
||||
<string name="widget_configuration_description">Vyberte, který profil zařízení má tento widget zobrazovat.</string>
|
||||
@@ -224,9 +221,6 @@
|
||||
<string name="permission_required_title">Je vyžadováno následující oprávnění:</string>
|
||||
<string name="permission_system_alert_window_label">Okno systémových upozornění</string>
|
||||
<string name="permission_system_alert_window_description">Aby bylo možné použít unkci \"Zobrazit vyskakovací okno\", je nutné umožnit aplikaci CAPod vykreslení přes jiné aplikace.</string>
|
||||
<string name="settings_monitor_mode_manual_label">Při otevření aplikace</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Při připojení zařízení</string>
|
||||
<string name="settings_monitor_mode_always_label">Vždy</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Když je vidět</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Pouzdro je otevřeno</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">V uchu</string>
|
||||
@@ -258,6 +252,11 @@
|
||||
<string name="anc_mode_transparency">Průhlednost</string>
|
||||
<string name="anc_mode_adaptive">Adaptivní</string>
|
||||
<string name="conversation_awareness_label">Uvědomění si konverzace</string>
|
||||
<string name="settings_conversation_action_label">Když začnete mluvit</string>
|
||||
<string name="settings_conversation_action_nothing_label">Nedělat nic</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Snížit hlasitost</string>
|
||||
<string name="settings_conversation_action_pause_label">Pozastavit média</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Snížení hlasitosti</string>
|
||||
<string name="signal_badge_ble_cd">BLE skenování aktivní</string>
|
||||
<string name="signal_badge_key_irk_cd">Identita ověřena</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Šifrované připojení</string>
|
||||
@@ -326,7 +325,6 @@
|
||||
<string name="ui_theme_color_amber_label">Jantarová</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Používá barvy tapety</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Monitorování</string>
|
||||
<string name="settings_category_appearance_label">Vzhled</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Neuložené změny</string>
|
||||
@@ -421,8 +419,8 @@
|
||||
<string name="device_settings_info_build_label">Sestavení</string>
|
||||
<string name="device_settings_info_left_serial_label">Sériové číslo levé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ána</string>
|
||||
<string name="device_settings_info_right_bonded_label">Pravá spárována</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_details_label">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>
|
||||
@@ -460,43 +458,43 @@
|
||||
<string name="device_settings_press_hold_default">Výchozí</string>
|
||||
<string name="device_settings_press_hold_shorter">Kratší</string>
|
||||
<string name="device_settings_press_hold_shortest">Nejkratší</string>
|
||||
<string name="device_settings_volume_swipe_label">Přejetí pro hlasitost</string>
|
||||
<string name="device_settings_volume_swipe_label">Hlasitost přejetím</string>
|
||||
<string name="device_settings_volume_swipe_description">Přejetím upravte hlasitost</string>
|
||||
<string name="device_settings_volume_swipe_length_label">Čekací doba tažení pro hlasitost</string>
|
||||
<string name="device_settings_volume_swipe_length_description">Chcete-li zabránit nechtěným úpravám hlasitosti, zvolte, jak dlouho čekat mezi tahy.</string>
|
||||
<string name="device_settings_volume_swipe_length_label">Doba čekání hlasitosti přejetím</string>
|
||||
<string name="device_settings_volume_swipe_length_description">Chcete-li zabránit nechtěným úpravám hlasitosti, zvolte, jak dlouho čekat mezi přejetími.</string>
|
||||
<string name="device_settings_volume_swipe_length_default">Výchozí</string>
|
||||
<string name="device_settings_volume_swipe_length_longer">Delší</string>
|
||||
<string name="device_settings_volume_swipe_length_longest">Nejdelší</string>
|
||||
<string name="device_settings_end_call_mute_mic_label">Ovládání hovorů</string>
|
||||
<string name="device_settings_end_call_mute_mic_description">Zvolte, co dělá stisk stonku během hovorů</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_a_title">Jednorázový stisk pro ukončení hovoru</string>
|
||||
<string name="device_settings_end_call_mute_mic_description">Vyberte, jaká akce se má spustit stisknutím tlačítka během hovoru</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_a_title">Jeden stisk pro ukončení hovoru</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_a_subtitle">Dvojitý stisk pro ztlumení mikrofonu</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_title">Jednorázový stisk pro ztlumení mikrofonu</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_title">Jeden stisk pro ztlumení mikrofonu</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dvojitý stisk pro ukončení hovoru</string>
|
||||
<string name="device_settings_open_cd">Otevřít nastavení zařízení</string>
|
||||
<string name="overview_card_missing_paired_device">Tento profil nemá spárované zařízení Bluetooth.</string>
|
||||
<string name="overview_reactions_hint_title">Reakce jsou nastaveny pro každé zařízení</string>
|
||||
<string name="overview_reactions_hint_body">Automatické přehrávání, automatické pozastavení a vyskakovací okna jsou nyní konfigurována pro každé zařízení. Klepněte na ikonu nastavení na kartě, když jsou vaše sluchátka připojena.</string>
|
||||
<string name="overview_reactions_hint_body">Automatické přehrávání, automatické pozastavení a vyskakovací okna se nyní nastavují pro každé zařízení zvlášť. Klepněte na ikonu nastavení na kartě, když máte připojená sluchátka.</string>
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Obecné</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_description">Který AirPod je použit jako mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_auto">Auto</string>
|
||||
<string name="device_settings_microphone_mode_description">Které sluchátko je použito jako mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_auto">Autom.</string>
|
||||
<string name="device_settings_microphone_mode_right">Pravý</string>
|
||||
<string name="device_settings_microphone_mode_left">Levý</string>
|
||||
<string name="device_settings_noise_control_current_mode_label">Aktuální režim</string>
|
||||
<string name="device_settings_listening_mode_cycle_label">Cyklus dlouhého stisku</string>
|
||||
<string name="device_settings_listening_mode_cycle_description">Vyberte, které režimy jsou dostupné při držení stopky a v rychlém přepínači.</string>
|
||||
<string name="device_settings_listening_mode_cycle_description">Vyberte, které režimy budou k dispozici při podržení tlačítka a v rychlém přepínači.</string>
|
||||
<string name="device_settings_listening_mode_cycle_dialog_title">Přizpůsobit cyklus dlouhého stisku</string>
|
||||
<string name="device_settings_listening_mode_cycle_summary_helper">Používá se při držení stopky a v rychlém přepínači</string>
|
||||
<string name="device_settings_listening_mode_cycle_summary_helper_override">Používá se v rychlém přepínači. Vlastní akce dlouhého stisku stopky přepisuje cyklování AirPods.</string>
|
||||
<string name="device_settings_listening_mode_cycle_summary_helper">Používá se při držení tlačítka a v rychlém přepínači</string>
|
||||
<string name="device_settings_listening_mode_cycle_summary_helper_override">Používá se v rychlém přepínači. Vlastní akce dlouhého stisku přepínače přepisuje cyklování AirPods.</string>
|
||||
<string name="device_settings_listening_mode_cycle_minimum">Musí zůstat povoleny alespoň 2 režimy.</string>
|
||||
<string name="device_settings_listening_mode_cycle_off">Vypnuto</string>
|
||||
<string name="device_settings_listening_mode_cycle_off">Vyp.</string>
|
||||
<string name="device_settings_listening_mode_cycle_anc">Potlačení hluku</string>
|
||||
<string name="device_settings_listening_mode_cycle_transparency">Průhlednost</string>
|
||||
<string name="device_settings_listening_mode_cycle_transparency">Propustnost</string>
|
||||
<string name="device_settings_listening_mode_cycle_adaptive">Adaptivní</string>
|
||||
<string name="device_settings_allow_off_label">Povolit režim Vypnuto</string>
|
||||
<string name="device_settings_allow_off_description">Povolit Vypnuto jako volitelný režim kontroly hluku. Pokud je zakázáno, stopky při cyklování přeskočí Vypnuto.</string>
|
||||
<string name="device_settings_allow_off_description">Povolit Vypnuto jako volitelný režim kontroly hluku. Pokud je zakázáno, tlačítka při cyklování přeskočí Vypnuto.</string>
|
||||
<string name="device_settings_sleep_detection_label">Detekce spánku</string>
|
||||
<string name="device_settings_sleep_detection_description">Automaticky pozastavit zvuk, když usnete</string>
|
||||
<string name="reaction_sleep_channel_label">Detekce spánku</string>
|
||||
@@ -510,7 +508,7 @@
|
||||
<string name="device_settings_rename_system_unavailable">Android nám nedovolil přejmenovat zařízení zde. Chcete-li aktualizovat název v nastavení Bluetooth, přejmenujte ho tam nebo zařízení znovu spárujte.</string>
|
||||
<string name="device_settings_rename_system_unavailable_bt_settings_action">Nastavení Bluetooth</string>
|
||||
<string name="device_settings_send_failed">Nastavení nelze použít: %1$s</string>
|
||||
<string name="device_settings_anc_off_rejected_message">Režim Vypnuto není na tomto zařízení povolen. Povolte „Povolit režim Vypnuto“ v části Kontrola hluku.</string>
|
||||
<string name="device_settings_anc_off_rejected_message">Režim Vypnuto není na tomto zařízení povolen. Povolte \"Povolit režim Vypnuto\" v části Kontrola hluku.</string>
|
||||
<string name="device_settings_category_battery_label">Baterie</string>
|
||||
<string name="device_settings_charge_cap_label">Optimalizovaný limit nabíjení</string>
|
||||
<string name="device_settings_charge_cap_description">Naučí se vaše návyky a pozastaví nabíjení přibližně na 80 %%, aby prodloužilo životnost baterie, a dobíjí sluchátka těsně před jejich použitím.</string>
|
||||
@@ -530,7 +528,7 @@
|
||||
<string name="press_controls_double_press">Dvojitý stisk</string>
|
||||
<string name="press_controls_triple_press">Trojitý stisk</string>
|
||||
<string name="press_controls_long_press">Dlouhý stisk</string>
|
||||
<string name="press_controls_long_press_anc_cycle_info">Přiřazení akce dlouhého stisku také zakáže cyklování režimů Kontroly hluku (např. Potlačení hluku, Transparentnost) stisknutím a podržením AirPods.</string>
|
||||
<string name="press_controls_long_press_anc_cycle_info">Přiřazení akce dlouhého stisku také zakáže cyklování režimů Kontroly hluku (např. Potlačení hluku, propustnost) stisknutím a podržením AirPods.</string>
|
||||
<string name="press_controls_mappings_section_title">Mapování stisku</string>
|
||||
<string name="press_controls_left">Levé</string>
|
||||
<string name="press_controls_right">Pravé</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Hvis du for nylig har købt, kan det tage et øjeblik for Google Play at synkronisere.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Prøv igen om et par minutter, hvis dit køb ikke vises.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Sørg for, at du er logget ind med den samme Google-konto, der blev brugt til købet.</string>
|
||||
<string name="settings_monitor_mode_label">Overvågningstilstand</string>
|
||||
<string name="settings_monitor_mode_description">Under hvilke omstændigheder denne app overvåger Bluetooth-data.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Ekstra notifikation</string>
|
||||
<string name="settings_monitor_connected_notification_description">Viser en ekstra notifikation, når en enhed er tilsluttet. Dette lader dig skjule den permanente \"Ingen enheder\"-notifikation ved at deaktivere \"Enhedsstatus\"-kanalen.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Behold notifikation efter afbrydelse</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Auto-pause</string>
|
||||
<string name="settings_autopause_description">Sæt lyd på pause, når du fjerner enheden fra dit øre.</string>
|
||||
<string name="settings_autopplay_label">Auto-afspilning</string>
|
||||
<string name="settings_autoplay_description">Start lydafspilning, når enheden bæres.</string>
|
||||
<string name="settings_autoplay_description">Genoptag musik, når du sætter dine pods på igen, men kun hvis Auto pause stoppede det. Pauser du selv udløste (telefon, AirPods-stift, søvn) forbliver sat på pause.</string>
|
||||
<string name="settings_start_music_on_wear_label">Start musik ved iføring</string>
|
||||
<string name="settings_start_music_on_wear_description">Forsøger altid at afspille musik, når du sætter dine pods på, selv efter en manuel pause.</string>
|
||||
<string name="settings_eardetection_info_label">Bemærkning om øredetektion</string>
|
||||
<string name="settings_eardetection_info_description">Hvis øredetektion kun virker for én pod, er dette en Apple-begrænsning. Kun den \"primære pod\" (brugt til mikrofon) detekteres. Konfigurer på Apple-enheder: Indstillinger → Bluetooth → AirPods → Mikrofon.</string>
|
||||
<string name="settings_fake_data_label">Falske data</string>
|
||||
<string name="settings_fake_data_description">Vis falske data, dvs. simuler enheder, der ikke eksisterer.</string>
|
||||
<string name="settings_debug_label">Fejlfindingsindstillinger</string>
|
||||
<string name="settings_debug_description">Yderligere indstillinger til at hjælpe med at fejlfinde problemer med appen.</string>
|
||||
<string name="settings_signal_minimum_label">Minimum signalkvalitet</string>
|
||||
<string name="settings_signal_minimum_description">Den minimum signalkvalitet, en enhed skal have for at blive betragtet som din.</string>
|
||||
<string name="settings_autoconnect_label">Auto-tilslut</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Vis forbindelses-pop op</string>
|
||||
<string name="settings_popup_connected_description">Vis en pop op, når enheden tilsluttes for første gang.</string>
|
||||
<string name="settings_popup_info_not_in_app">Pop-ups vises kun, når du ikke er i appen.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Pop-ups kræver, at monitoren kører. Din monitortilstand er indstillet til \"Når appen er åben\", som stopper monitoren, når du forlader appen.</string>
|
||||
<string name="general_fix_it_action">Ret det</string>
|
||||
<string name="device_settings_experimental_title">Eksperimentel funktion</string>
|
||||
<string name="device_settings_experimental_description">Denne funktion er endnu ikke testet på alle enheder. Hvis noget ikke fungerer korrekt, bedes du åbne en sag med en fejlretningslog.</string>
|
||||
<string name="device_settings_experimental_action">Åbn fejlsporer</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">Anerkendelser</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_mode_label">Fejlfindingstilstand</string>
|
||||
<string name="settings_debug_mode_description">Vis yderligere oplysninger for at fejlfinde problemer.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Ufiltrerede BLE-data</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Fjern alle filtre fra BLE-scanneren for at vise alle udsendte BLE-data. Nyttigt til at tilføje understøttelse af nye hovedtelefontyper.</string>
|
||||
<string name="settings_compatibility_mode_label">Kompatibilitetstilstand</string>
|
||||
<string name="settings_compatibility_mode_description">Deaktiver optimeringer for at forbedre kompatibiliteten. Prøv dette, hvis du ikke ser nogen data.</string>
|
||||
<string name="help_translate_label">Oversættelse</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Denne widget kræver en direkte forbindelse (AAP) til din enhed. Sørg for, at din enhed er parret og forbundet i appen, inden du bruger denne widget.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Denne enhed understøtter ikke støjkontrol</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Ikke forbundet</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">I nærheden, men ikke tilsluttet</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Ikke i nærheden</string>
|
||||
<string name="anc_widget_aap_connecting_label">Forbinder…</string>
|
||||
<string name="tile_anc_label">Støjstyring</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Tilladelse påkrævet</string>
|
||||
<string name="tile_anc_subtitle_no_device">Ingen enhed</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Ingen støjstyring</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth slået fra</string>
|
||||
<string name="widget_config_screen_title">Widget-konfiguration</string>
|
||||
<string name="widget_configuration_title">Vælg enhed</string>
|
||||
<string name="widget_configuration_description">Vælg hvilken enhedsprofil denne widget skal vise.</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">Følgende tilladelse er påkrævet:</string>
|
||||
<string name="permission_system_alert_window_label">Systemalarmvindue</string>
|
||||
<string name="permission_system_alert_window_description">Tillad CAPod at tegne over andre apps for at gøre funktionen \"Vis pop op\" mulig.</string>
|
||||
<string name="settings_monitor_mode_manual_label">Når appen er åben</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Når enheden er tilsluttet</string>
|
||||
<string name="settings_monitor_mode_always_label">Altid</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Når den ses</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Etui er åbent</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">I øret</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Gennemsigtighed</string>
|
||||
<string name="anc_mode_adaptive">Adaptiv</string>
|
||||
<string name="conversation_awareness_label">Samtaleopmærksomhed</string>
|
||||
<string name="settings_conversation_action_label">Når du begynder at tale</string>
|
||||
<string name="settings_conversation_action_nothing_label">Gør intet</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Sænk lydstyrken</string>
|
||||
<string name="settings_conversation_action_pause_label">Sæt medie på pause</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Lydstyrkeregulering</string>
|
||||
<string name="signal_badge_ble_cd">BLE-scanning aktiv</string>
|
||||
<string name="signal_badge_key_irk_cd">Identitet bekræftet</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Krypteret forbindelse</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Rav</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Bruger tapetkfarver</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Overvågning</string>
|
||||
<string name="settings_category_appearance_label">Udseende</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Ikke-gemte ændringer</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Generel</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_description">Hvilken AirPod der bruges som mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_description">Hvilken øretelefon der bruges som mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_auto">Auto</string>
|
||||
<string name="device_settings_microphone_mode_right">Højre</string>
|
||||
<string name="device_settings_microphone_mode_left">Venstre</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Wenn du kürzlich gekauft hast, kann es einen Moment dauern, bis Google Play synchronisiert.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Versuche es in wenigen Minuten erneut, falls dein Kauf nicht erscheint.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Stelle sicher, dass du mit demselben Google-Konto angemeldet bist, das für den Kauf verwendet wurde.</string>
|
||||
<string name="settings_monitor_mode_label">Überwachungsmodus</string>
|
||||
<string name="settings_monitor_mode_description">Unter welchen Umständen überwacht diese App Bluetooth-Daten.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Zusätzliche Benachrichtigung</string>
|
||||
<string name="settings_monitor_connected_notification_description">Zeigt eine zusätzliche Benachrichtigung an, wenn ein Gerät verbunden ist. Dadurch kannst du die permanente Benachrichtigung „Keine Geräte“ ausblenden, indem du den Kanal „Gerätestatus“ deaktivierst.</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Benachrichtigung nach Trennung beibehalten</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Automatische Pause</string>
|
||||
<string name="settings_autopause_description">Musik pausieren, wenn der Kopfhörer vom Ohr entfernt wird.</string>
|
||||
<string name="settings_autopplay_label">Automatisches abspielen</string>
|
||||
<string name="settings_autoplay_description">Audiowiedergabe starten, wenn das Gerät getragen wird.</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_description">Versucht immer, Musik abzuspielen, wenn du deine Pods aufsetzt – auch nach einer manuellen Pause.</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_fake_data_label">Gefälschte Daten</string>
|
||||
<string name="settings_fake_data_description">Gefälschte Test-Daten anzeigen, d.h. nicht existierende Geräte simulieren.</string>
|
||||
<string name="settings_debug_label">Debug-Einstellungen</string>
|
||||
<string name="settings_debug_description">Zusätzliche Einstellungen zur Behebung von Problemen mit der App.</string>
|
||||
<string name="settings_signal_minimum_label">Minimale Signalqualität</string>
|
||||
<string name="settings_signal_minimum_description">Die minimale Signalqualität, die ein Gerät haben muss, damit als Deins erkannt wird.</string>
|
||||
<string name="settings_autoconnect_label">Automatisch verbinden</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Verbindungs-Popup anzeigen</string>
|
||||
<string name="settings_popup_connected_description">Zeigt ein Popup an, wenn das Gerät zum ersten Mal eine Verbindung herstellt.</string>
|
||||
<string name="settings_popup_info_not_in_app">Popups werden nur angezeigt, wenn du nicht in der App bist.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Popups erfordern, dass der Monitor läuft. Dein Monitor-Modus ist auf \"Wenn App geöffnet\" eingestellt, was den Monitor stoppt, wenn du die App verlässt.</string>
|
||||
<string name="general_fix_it_action">Beheben</string>
|
||||
<string name="device_settings_experimental_title">Experimentelle Funktion</string>
|
||||
<string name="device_settings_experimental_description">Diese Funktion wurde noch nicht auf allen Geräten getestet. Wenn etwas nicht korrekt funktioniert, öffne bitte ein Issue mit einem Debug-Log.</string>
|
||||
<string name="device_settings_experimental_action">Issue-Tracker öffnen</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">Danksagungen</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_mode_label">Debug-Modus</string>
|
||||
<string name="settings_debug_mode_description">Zeige zusätzliche Informationen an, um Probleme zu beheben.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Ungefilterte BLE-Daten</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Entfernt alle Filter aus dem BLE-Scanner, um alle übertragenen BLE-Daten anzuzeigen. Nützlich, um Unterstützung für neue Kopfhörertypen hinzuzufügen.</string>
|
||||
<string name="settings_compatibility_mode_label">Kompatibilitätsmodus</string>
|
||||
<string name="settings_compatibility_mode_description">Deaktiviere Optimierungen, um die Kompatibilität zu verbessern. Probier diese Option, wenn gar keine Daten angezeigt werden.</string>
|
||||
<string name="help_translate_label">Übersetzung</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Dieses Widget erfordert eine direkte Verbindung (AAP) zu deinem Gerät. Stelle sicher, dass dein Gerät in der App gekoppelt und verbunden ist, bevor du dieses Widget verwendest.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Dieses Gerät unterstützt keine Geräuschsteuerung</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Nicht verbunden</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">In der Nähe, aber nicht verbunden</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Nicht in der Nähe</string>
|
||||
<string name="anc_widget_aap_connecting_label">Verbinde…</string>
|
||||
<string name="tile_anc_label">Geräuschkontrolle</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Berechtigung erforderlich</string>
|
||||
<string name="tile_anc_subtitle_no_device">Kein Gerät</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Keine Geräuschkontrolle</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth aus</string>
|
||||
<string name="widget_config_screen_title">Widget-Konfiguration</string>
|
||||
<string name="widget_configuration_title">Gerät auswählen</string>
|
||||
<string name="widget_configuration_description">Wähle aus, welches Geräteprofil dieses Widget anzeigen soll.</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">Die folgende Genehmigung ist erforderlich:</string>
|
||||
<string name="permission_system_alert_window_label">Systemwarnungsfenster</string>
|
||||
<string name="permission_system_alert_window_description">Erlaube CAPod, PopUp Benachrichtigungen über anderen Apps anzuzeigen.</string>
|
||||
<string name="settings_monitor_mode_manual_label">Wenn die App geöffnet ist</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Wenn das Gerät verbunden ist</string>
|
||||
<string name="settings_monitor_mode_always_label">Immer</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Wann gesehen</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Ladecase ist offen</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">Im Ohr</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Transparenz</string>
|
||||
<string name="anc_mode_adaptive">Adaptiv</string>
|
||||
<string name="conversation_awareness_label">Gesprächserkennung</string>
|
||||
<string name="settings_conversation_action_label">Wenn du anfängst zu sprechen</string>
|
||||
<string name="settings_conversation_action_nothing_label">Nichts tun</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Lautstärke verringern</string>
|
||||
<string name="settings_conversation_action_pause_label">Medien pausieren</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Lautstärkereduzierung</string>
|
||||
<string name="signal_badge_ble_cd">BLE-Scan aktiv</string>
|
||||
<string name="signal_badge_key_irk_cd">Identität verifiziert</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Verschlüsselte Verbindung</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Bernstein</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Verwendet Hintergrundbildfarben</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Überwachung</string>
|
||||
<string name="settings_category_appearance_label">Erscheinungsbild</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Nicht gespeicherte Änderungen</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Allgemein</string>
|
||||
<string name="device_settings_microphone_mode_label">Mikrofon</string>
|
||||
<string name="device_settings_microphone_mode_description">Welcher AirPod als Mikrofon verwendet wird</string>
|
||||
<string name="device_settings_microphone_mode_description">Welches Ohrstöpsel als Mikrofon verwendet wird</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_left">Links</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Εάν έχετε αγοράσει πρόσφατα, ενδέχεται να χρειαστεί λίγος χρόνος για συγχρονισμό με το Google Play.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Δοκιμάστε ξανά σε λίγα λεπτά αν η αγορά σας δεν εμφανιστεί.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Βεβαιωθείτε ότι έχετε συνδεθεί με τον ίδιο λογαριασμό Google που χρησιμοποιήσατε για την αγορά.</string>
|
||||
<string name="settings_monitor_mode_label">Λειτουργία παρακολούθησης</string>
|
||||
<string name="settings_monitor_mode_description">Υπό ποιες συνθήκες αυτή η εφαρμογή παρακολουθεί δεδομένα Bluetooth.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Επιπλέον ειδοποίηση</string>
|
||||
<string name="settings_monitor_connected_notification_description">Εμφανίζει μια επιπλέον ειδοποίηση όταν μια συσκευή είναι συνδεδεμένη. Αυτό σας επιτρέπει να αποκρύψετε τη μόνιμη ειδοποίηση \"Δεν υπάρχουν συσκευές\" απενεργοποιώντας το κανάλι \"Κατάσταση συσκευής\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Διατήρηση ειδοποίησης μετά την αποσύνδεση</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Αυτόματη παύση</string>
|
||||
<string name="settings_autopause_description">Παύση ήχου κατά την αφαίρεση της συσκευής από το αυτί σας.</string>
|
||||
<string name="settings_autopplay_label">Αυτόματη αναπαραγωγή</string>
|
||||
<string name="settings_autoplay_description">Έναρξη αναπαραγωγής ήχου όταν φοριέται η συσκευή.</string>
|
||||
<string name="settings_autoplay_description">Συνέχισε τη μουσική όταν ξαναφοράς τα pods, αλλά μόνο αν την σταμάτησε η Αυτόματη παύση. Οι παύσεις που έκανες εσύ (τηλέφωνο, στέλεχος AirPods, ύπνος) παραμένουν σε παύση.</string>
|
||||
<string name="settings_start_music_on_wear_label">Εκκίνηση μουσικής κατά τη χρήση</string>
|
||||
<string name="settings_start_music_on_wear_description">Προσπαθεί πάντα να αναπαράγει μουσική όταν φοράς τα pods, ακόμα και μετά από μη αυτόματη παύση.</string>
|
||||
<string name="settings_eardetection_info_label">Σημείωση ανίχνευσης αυτιού</string>
|
||||
<string name="settings_eardetection_info_description">Εάν η ανίχνευση αυτιού λειτουργεί μόνο για ένα pod, αυτός είναι περιορισμός της Apple. Ανιχνεύεται μόνο το \"κύριο pod\" (που χρησιμοποιείται για μικρόφωνο). Ρυθμίστε σε συσκευές Apple: Ρυθμίσεις → Bluetooth → AirPods → Μικρόφωνο.</string>
|
||||
<string name="settings_fake_data_label">Ψευδή δεδομένα</string>
|
||||
<string name="settings_fake_data_description">Εμφάνιση ψευδών δεδομένων, π.χ., προσομοίωση συσκευών που δεν υπάρχουν.</string>
|
||||
<string name="settings_debug_label">Ρυθμίσεις εντοπισμού σφαλμάτων</string>
|
||||
<string name="settings_debug_description">Πρόσθετες ρυθμίσεις για την αντιμετώπιση προβλημάτων με την εφαρμογή.</string>
|
||||
<string name="settings_signal_minimum_label">Ελάχιστη ποιότητα σήματος</string>
|
||||
<string name="settings_signal_minimum_description">Η ελάχιστη ποιότητα σήματος που πρέπει να έχει μια συσκευή για να θεωρηθεί δική σας.</string>
|
||||
<string name="settings_autoconnect_label">Αυτόματη σύνδεση</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Εμφάνιση αναδυόμενου παραθύρου σύνδεσης</string>
|
||||
<string name="settings_popup_connected_description">Εμφάνιση αναδυόμενου παραθύρου όταν η συσκευή συνδέεται για πρώτη φορά.</string>
|
||||
<string name="settings_popup_info_not_in_app">Τα αναδυόμενα παράθυρα εμφανίζονται μόνο όταν δεν βρίσκεστε στην εφαρμογή.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Τα αναδυόμενα παράθυρα απαιτούν η παρακολούθηση να εκτελείται. Η λειτουργία παρακολούθησης έχει οριστεί σε «Όταν η εφαρμογή είναι ανοιχτή», η οποία διακόπτει την παρακολούθηση όταν φεύγετε από την εφαρμογή.</string>
|
||||
<string name="general_fix_it_action">Διόρθωση</string>
|
||||
<string name="device_settings_experimental_title">Πειραματική λειτουργία</string>
|
||||
<string name="device_settings_experimental_description">Αυτή η λειτουργία δεν έχει δοκιμαστεί ακόμα σε όλες τις συσκευές. Αν κάτι δεν λειτουργεί σωστά, παρακαλώ ανοίξτε ένα ζήτημα με ένα αρχείο καταγραφής εντοπισμού σφαλμάτων.</string>
|
||||
<string name="device_settings_experimental_action">Άνοιγμα Παρακολούθησης Ζητημάτων</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">Ευχαριστίες</string>
|
||||
<string name="settings_debug_autoreports_label">Αυτόματες αναφορές σφαλμάτων</string>
|
||||
<string name="settings_debug_autoreports_description">Αναφέρει αυτόματα προβλήματα, π.χ. λεπτομέρειες σχετικά με μια κατάρρευση της εφαρμογής, ώστε να μπορώ να καταλάβω πώς να το διορθώσω.</string>
|
||||
<string name="settings_debug_mode_label">Λειτουργία εντοπισμού σφαλμάτων</string>
|
||||
<string name="settings_debug_mode_description">Εμφάνιση πρόσθετων πληροφοριών για την αντιμετώπιση προβλημάτων.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Αφιλτράριστα δεδομένα BLE</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Αφαιρέστε τυχόν φίλτρα από τον σαρωτή BLE για να εμφανιστούν όλα τα μεταδιδόμενα δεδομένα BLE. Χρήσιμο για την προσθήκη υποστήριξης για νέους τύπους ακουστικών.</string>
|
||||
<string name="settings_compatibility_mode_label">Λειτουργία συμβατότητας</string>
|
||||
<string name="settings_compatibility_mode_description">Απενεργοποιήστε τις βελτιστοποιήσεις για να βελτιώσετε τη συμβατότητα. Δοκιμάστε το εάν δεν βλέπετε δεδομένα.</string>
|
||||
<string name="help_translate_label">Μετάφραση</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Αυτό το widget απαιτεί άμεση σύνδεση (AAP) με τη συσκευή σας. Βεβαιωθείτε ότι η συσκευή σας είναι συζευγμένη και συνδεδεμένη στην εφαρμογή πριν χρησιμοποιήσετε αυτό το widget.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Αυτή η συσκευή δεν υποστηρίζει έλεγχο θορύβου</string>
|
||||
<string name="anc_widget_aap_not_connected_label">Μη συνδεδεμένο</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">Κοντά αλλά δεν είναι συνδεδεμένο</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">Μη κοντά</string>
|
||||
<string name="anc_widget_aap_connecting_label">Σύνδεση…</string>
|
||||
<string name="tile_anc_label">Έλεγχος θορύβου</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Απαιτείται άδεια</string>
|
||||
<string name="tile_anc_subtitle_no_device">Καμία συσκευή</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Χωρίς έλεγχο θορύβου</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth απενεργοποιημένο</string>
|
||||
<string name="widget_config_screen_title">Διαμόρφωση γραφικού στοιχείου</string>
|
||||
<string name="widget_configuration_title">Επιλογή Συσκευής</string>
|
||||
<string name="widget_configuration_description">Επιλέξτε ποιο προφίλ συσκευής θα εμφανίζει αυτό το widget.</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">Απαιτείται η ακόλουθη άδεια:</string>
|
||||
<string name="permission_system_alert_window_label">Παράθυρο ειδοποίησης συστήματος</string>
|
||||
<string name="permission_system_alert_window_description">Επιτρέψτε στο CAPod να σχεδιάζει πάνω από άλλες εφαρμογές για να καταστεί δυνατή η λειτουργία \"Εμφάνιση αναδυόμενου\".</string>
|
||||
<string name="settings_monitor_mode_manual_label">Όταν η εφαρμογή είναι ανοιχτή</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Όταν η συσκευή είναι συνδεδεμένη</string>
|
||||
<string name="settings_monitor_mode_always_label">Πάντα</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Όταν εντοπιστεί</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">Η θήκη είναι ανοιχτή</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">Στο αυτί</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Διαφάνεια</string>
|
||||
<string name="anc_mode_adaptive">Προσαρμοστικό</string>
|
||||
<string name="conversation_awareness_label">Ενημερότητα Συνομιλίας</string>
|
||||
<string name="settings_conversation_action_label">Όταν αρχίζεις να μιλάς</string>
|
||||
<string name="settings_conversation_action_nothing_label">Να μην γίνει τίποτα</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Μείωση έντασης</string>
|
||||
<string name="settings_conversation_action_pause_label">Παύση μέσων</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Μείωση έντασης</string>
|
||||
<string name="signal_badge_ble_cd">Σάρωση BLE ενεργή</string>
|
||||
<string name="signal_badge_key_irk_cd">Η ταυτότητα επαληθεύτηκε</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Κρυπτογραφημένη σύνδεση</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Κεχριμπάρι</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Χρησιμοποιεί χρώματα ταπετσαρίας</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Παρακολούθηση</string>
|
||||
<string name="settings_category_appearance_label">Εμφάνιση</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Μη αποθηκευμένες αλλαγές</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">Γενικά</string>
|
||||
<string name="device_settings_microphone_mode_label">Μικρόφωνο</string>
|
||||
<string name="device_settings_microphone_mode_description">Ποιο AirPod χρησιμοποιείται ως μικρόφωνο</string>
|
||||
<string name="device_settings_microphone_mode_description">Ποιο ακουστικό χρησιμοποιείται ως μικρόφωνο</string>
|
||||
<string name="device_settings_microphone_mode_auto">Αυτόματο</string>
|
||||
<string name="device_settings_microphone_mode_right">Δεξί</string>
|
||||
<string name="device_settings_microphone_mode_left">Αριστερό</string>
|
||||
|
||||
@@ -40,8 +40,6 @@
|
||||
<string name="upgrade_screen_restore_troubleshooting_msg">Si compraste recientemente, puede demorar un momento para que Google Play sincronice.</string>
|
||||
<string name="upgrade_screen_restore_sync_patience_hint">Intentá de nuevo en unos minutos si tu compra no aparece.</string>
|
||||
<string name="upgrade_screen_restore_multiaccount_hint">Asegurate de haber iniciado sesión con la misma cuenta de Google usada para la compra.</string>
|
||||
<string name="settings_monitor_mode_label">Modo monitor</string>
|
||||
<string name="settings_monitor_mode_description">Bajo qué circunstancias esta app monitorea los datos de Bluetooth.</string>
|
||||
<string name="settings_monitor_connected_notification_label">Notificación extra</string>
|
||||
<string name="settings_monitor_connected_notification_description">Muestra una notificación extra cuando un dispositivo está conectado. Esto te permite ocultar la notificación permanente \"Sin dispositivos\" desactivando el canal \"Estado del dispositivo\".</string>
|
||||
<string name="settings_keep_notification_after_disconnect_label">Mantener la notificación después de la desconexión</string>
|
||||
@@ -49,13 +47,11 @@
|
||||
<string name="settings_autopause_label">Pausa automática</string>
|
||||
<string name="settings_autopause_description">Pausar el audio al quitar el dispositivo de tu oreja.</string>
|
||||
<string name="settings_autopplay_label">Reproducción automática</string>
|
||||
<string name="settings_autoplay_description">Iniciar la reproducción de audio cuando se usa el dispositivo.</string>
|
||||
<string name="settings_autoplay_description">Reanuda la música al volver a ponerte los pods, pero solo si la pausa automática la detuvo. Las pausas que activaste vos mismo (teléfono, palanca de AirPods, suspensión) permanecen en pausa.</string>
|
||||
<string name="settings_start_music_on_wear_label">Iniciar música al ponérselos</string>
|
||||
<string name="settings_start_music_on_wear_description">Siempre intenta reproducir música cuando te ponés los pods, incluso después de una pausa manual.</string>
|
||||
<string name="settings_eardetection_info_label">Nota sobre la detección de oídos</string>
|
||||
<string name="settings_eardetection_info_description">Si la detección de auriculares solo funciona para un pod, se trata de una limitación de Apple. Solo se detecta el «pod principal» (utilizado para el micrófono). Configuración en dispositivos Apple: Ajustes → Bluetooth → AirPods → Micrófono.</string>
|
||||
<string name="settings_fake_data_label">Datos falsos</string>
|
||||
<string name="settings_fake_data_description">Mostrar datos falsos, es decir, simular dispositivos que no existen.</string>
|
||||
<string name="settings_debug_label">Ajustes de depuración</string>
|
||||
<string name="settings_debug_description">Ajustes adicionales para ayudar a solucionar problemas con la app.</string>
|
||||
<string name="settings_signal_minimum_label">Calidad de señal mínima</string>
|
||||
<string name="settings_signal_minimum_description">La calidad de señal mínima que un dispositivo necesita tener para ser considerado tuyo.</string>
|
||||
<string name="settings_autoconnect_label">Conexión automática</string>
|
||||
@@ -80,8 +76,6 @@
|
||||
<string name="settings_popup_connected_label">Mostrar pop-up de conexión</string>
|
||||
<string name="settings_popup_connected_description">Mostrar un pop-up cuando el dispositivo se conecta por primera vez.</string>
|
||||
<string name="settings_popup_info_not_in_app">Los popups solo se muestran cuando no estás en la app.</string>
|
||||
<string name="settings_popup_warning_manual_mode">Los popups requieren que el monitor esté en ejecución. El modo de monitor está configurado como \"Cuando la app está abierta\", lo que detiene el monitor cuando salís de la app.</string>
|
||||
<string name="general_fix_it_action">Corregirlo</string>
|
||||
<string name="device_settings_experimental_title">Función experimental</string>
|
||||
<string name="device_settings_experimental_description">Esta función aún no se ha probado en todos los dispositivos. Si algo no funciona correctamente, por favor abrí un problema con un registro de depuración.</string>
|
||||
<string name="device_settings_experimental_action">Abrir seguimiento de problemas</string>
|
||||
@@ -122,10 +116,6 @@
|
||||
<string name="settings_acknowledgements_label">Agradecimientos</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_mode_label">Modo depuración</string>
|
||||
<string name="settings_debug_mode_description">Mostrar información adicional para solucionar problemas.</string>
|
||||
<string name="settings_blescanner_unfiltered_label">Datos BLE sin filtrar</string>
|
||||
<string name="settings_blescanner_unfiltered_description">Eliminar cualquier filtro del escáner BLE para mostrar todos los datos BLE transmitidos. Útil para agregar soporte para nuevos tipos de auriculares.</string>
|
||||
<string name="settings_compatibility_mode_label">Modo de compatibilidad</string>
|
||||
<string name="settings_compatibility_mode_description">Desactivar optimizaciones para mejorar la compatibilidad. Probá esto si no estás viendo ningún dato.</string>
|
||||
<string name="help_translate_label">Traducción</string>
|
||||
@@ -137,7 +127,14 @@
|
||||
<string name="anc_widget_config_aap_required_hint">Este widget requiere una conexión directa (AAP) a tu dispositivo. Asegurate de que tu dispositivo esté emparejado y conectado en la app antes de usar este widget.</string>
|
||||
<string name="anc_widget_no_anc_support_label">Este dispositivo no admite control de ruido</string>
|
||||
<string name="anc_widget_aap_not_connected_label">No conectado</string>
|
||||
<string name="anc_widget_aap_not_connected_nearby_description">Cerca pero sin conexión</string>
|
||||
<string name="anc_widget_aap_not_connected_not_nearby_description">No está cerca</string>
|
||||
<string name="anc_widget_aap_connecting_label">Conectando…</string>
|
||||
<string name="tile_anc_label">Control de ruido</string>
|
||||
<string name="tile_anc_subtitle_permission_required">Permiso requerido</string>
|
||||
<string name="tile_anc_subtitle_no_device">Sin dispositivo</string>
|
||||
<string name="tile_anc_subtitle_no_anc_support">Sin control de ruido</string>
|
||||
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth desactivado</string>
|
||||
<string name="widget_config_screen_title">Configuración del widget</string>
|
||||
<string name="widget_configuration_title">Selecciona el dispositivo</string>
|
||||
<string name="widget_configuration_description">Elige qué perfil del dispositivo debe mostrar este complemento.</string>
|
||||
@@ -220,9 +217,6 @@
|
||||
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
|
||||
<string name="permission_system_alert_window_label">Ventana emergente</string>
|
||||
<string name="permission_system_alert_window_description">Permite que CAPod se superponga a otras aplicaciones para que la función «Mostrar ventana emergente».</string>
|
||||
<string name="settings_monitor_mode_manual_label">Cuando la aplicación está abierta</string>
|
||||
<string name="settings_monitor_mode_automatic_label">Cuando el dispositivo está conectado</string>
|
||||
<string name="settings_monitor_mode_always_label">Siempre</string>
|
||||
<string name="settings_reaction_autoconnect_whenseen_label">Cuando está visible</string>
|
||||
<string name="settings_reaction_autoconnect_caseopen_label">El estuche está abierto</string>
|
||||
<string name="settings_reaction_autoconnect_inear_label">En el oído</string>
|
||||
@@ -254,6 +248,11 @@
|
||||
<string name="anc_mode_transparency">Transparencia</string>
|
||||
<string name="anc_mode_adaptive">Adaptivo</string>
|
||||
<string name="conversation_awareness_label">Conciencia de conversación</string>
|
||||
<string name="settings_conversation_action_label">Cuando empezás a hablar</string>
|
||||
<string name="settings_conversation_action_nothing_label">No hacer nada</string>
|
||||
<string name="settings_conversation_action_lower_volume_label">Bajar el volumen</string>
|
||||
<string name="settings_conversation_action_pause_label">Pausar el contenido</string>
|
||||
<string name="settings_conversation_volume_reduction_label">Reducción de volumen</string>
|
||||
<string name="signal_badge_ble_cd">Escaneo BLE activo</string>
|
||||
<string name="signal_badge_key_irk_cd">Identidad verificada</string>
|
||||
<string name="signal_badge_key_encrypted_cd">Conexión encriptada</string>
|
||||
@@ -322,7 +321,6 @@
|
||||
<string name="ui_theme_color_amber_label">Ámbar</string>
|
||||
<string name="ui_theme_color_disabled_materialyou">Usa los colores del fondo de pantalla</string>
|
||||
<!-- Settings categories -->
|
||||
<string name="settings_category_monitoring_label">Monitoreo</string>
|
||||
<string name="settings_category_appearance_label">Apariencia</string>
|
||||
<!-- Unsaved changes dialog -->
|
||||
<string name="general_unsaved_changes_title">Cambios sin guardar</string>
|
||||
@@ -470,7 +468,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
<string name="device_settings_microphone_mode_label">Micrófono</string>
|
||||
<string name="device_settings_microphone_mode_description">Qué AirPod se usa como micrófono</string>
|
||||
<string name="device_settings_microphone_mode_description">Qué auricular se usa como micrófono</string>
|
||||
<string name="device_settings_microphone_mode_auto">Auto</string>
|
||||
<string name="device_settings_microphone_mode_right">Derecho</string>
|
||||
<string name="device_settings_microphone_mode_left">Izquierdo</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user