Compare commits

...
26 Commits
Author SHA1 Message Date
darken 83b24cced2 Release: 4.0.2-rc0 2026-03-22 23:16:51 +01:00
darken d913e72c5c General: Update app translations from Crowdin (retranslated strings) 2026-03-22 21:45:33 +00:00
darken 5c16c08dee General: Update hy-AM and th fastlane translations from Crowdin 2026-03-22 21:45:33 +00:00
darken 20e22c0910 General: Update app translations from Crowdin 2026-03-22 21:45:33 +00:00
darken fa3e56b0f3 General: Update fastlane translations from Crowdin 2026-03-22 21:45:33 +00:00
darken f25718ab2c fix: Catch Hilt DI failure in MonitorService.onCreate()
Wrap super.onCreate() (Hilt injection) in try-catch to prevent

DI failures from crashing the process. The service is already

foreground at this point, so a graceful stopSelf() satisfies the

FGS timeout requirement without killing the app.
2026-03-20 19:17:56 +00:00
darken 6e9b275936 fix: Replace nullable Float composable params to avoid ART native crash
ComposerImpl.changed() boxes Float? and calls Float.equals() at the ART native level, triggering a known crash. Non-null Float params use the primitive overload with no boxing.

Introduces BATTERY_UNKNOWN sentinel, toBatteryFloat(), and toBatteryOrNull() extensions to eliminate Float? from all composable signatures and WidgetRenderState data classes.
2026-03-20 19:12:22 +00:00
darken c6c7cd7858 chore: Add Fastlane metadata length validation to CI 2026-03-20 18:11:30 +00:00
darken 4d7027a9b8 chore: Add R8 mapping file upload to Fastlane supply lanes 2026-03-20 18:04:18 +00:00
darken 992cb5e695 fix: Remove premature startForegroundService call from App.onCreate()
Calling startMonitor() in App.onCreate() started the foreground service timeout

clock before the BroadcastReceiver even ran, consuming timeout budget

with post-init work and receiver processing. The service is already

started by BluetoothEventReceiver, BootCompletedReceiver, and

OverviewViewModel, making this call redundant.
2026-03-20 17:49:15 +00:00
darken 039d20d7aa fix(billing): Use billing-ktx suspend APIs instead of manual callback bridging 2026-03-20 17:49:05 +00:00
darken 52433460be fix: Bump Compose BOM to fix ExposedDropdownMenuBox crash on detach 2026-03-20 17:48:55 +00:00
darken 95bb225e64 chore: Update GitHub Actions to Node.js 24-compatible versions 2026-03-12 15:37:07 +00:00
darken ac477113cf Release: 4.0.1-rc0 2026-03-12 13:42:29 +01:00
darken 5e21941765 General: Update app translations from Crowdin 2026-03-12 12:12:40 +00:00
darken e5032ee35a General: Update fastlane translations from Crowdin 2026-03-12 12:12:40 +00:00
darken 1ceac349ab chore: Add .claude/.gitignore for worktrees 2026-03-12 11:59:07 +00:00
darken 2db01b410b fix(billing): Prevent crash when billing service disconnects 2026-03-12 11:59:07 +00:00
darken 193a1030fb feat: Start BLE scanning with only core permissions granted
Allow the app to scan for AirPods when only BLE scan permissions are granted, without waiting for all optional permissions (notifications, overlay, etc.).

Add isScanBlocking flag to Permission enum. Gate monitor service and pod scanning on scan permissions only. Show scan-blocking permission cards with error color and sorted first. Wrap BLUETOOTH_CONNECT-dependent calls in try-catch for graceful degradation. Fix POST_NOTIFICATIONS minApiLevel from S (31) to TIRAMISU (33).
2026-03-10 16:37:43 +00:00
darken 12e35631d6 fix: Scope ViewModels to navigation entries via NavDisplay decorator 2026-03-10 15:11:10 +00:00
darken 67474a30da fix(debug): Rename recorder screen button from Close to Keep
The keep button's onClick handler was already named onKeep but the label said Close. Renamed to Keep for clarity alongside the Delete/Share buttons. Added dedicated string resource with translations for all 75 locales.
2026-03-10 07:50:36 +00:00
darken 44cd0eec2a test: Add missing AutoConnectCondition round-trip and Model encode tests 2026-03-09 20:29:19 +00:00
darken a79f6a0535 refactor: Replace Moshi with kotlinx.serialization
Remove Moshi dependency entirely, completing the migration to kotlinx.serialization. All JSON serialization now uses kotlinx with explicit @SerialName annotations for wire format stability.

- Migrate PodDeviceCache from Moshi to kotlinx Json injection

- Add MapIntByteArrayBase64Serializer for BleScanResult cache compat

- Strip @JsonClass/@Json annotations from all dual-annotated classes

- Delete Moshi adapters, ProGuard rules, and build config

- Convert compat tests to pure kotlinx round-trip tests
2026-03-09 20:29:19 +00:00
darken 8675d73a98 fix(debug): Persist recording session across app restarts
The debug recording trigger file now stores the session directory path and start timestamp. On app restart, the recorder resumes into the same session directory and log file instead of creating a new one, preserving the original start time so the short-recording guard doesn't reset.
2026-03-09 17:34:29 +00:00
darken 3423786f06 chore: Remove redundant package name from manifest 2026-03-09 17:41:08 +01:00
darken 00083707c6 fix(debug): Harden debug session recording and sharing 2026-03-09 16:39:56 +00:00
109 changed files with 1065 additions and 731 deletions
+1
View File
@@ -0,0 +1 @@
worktrees/
+1 -1
View File
@@ -80,5 +80,5 @@ Code shared between phone and Wear OS apps is placed in `app-common`. When modif
- **Hilt**: Dependency injection framework
- **AndroidX Navigation**: Fragment navigation with SafeArgs
- **Moshi**: JSON serialization for configuration and debugging
- **kotlinx.serialization**: JSON serialization for configuration and caching
- **Material Design**: UI components following Material Design guidelines
+4 -4
View File
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Set up JDK 17
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
with:
java-version: '17'
distribution: 'temurin'
@@ -14,7 +14,7 @@ runs:
run: chmod +x gradlew
- name: Cache Gradle Wrapper
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -24,7 +24,7 @@ runs:
${{ runner.os }}-gradle-wrapper-
- name: Cache Gradle Dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.gradle/caches
@@ -33,7 +33,7 @@ runs:
${{ runner.os }}-gradle-caches-
- name: Cache Android Global Build-Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.android/build-cache
+13 -4
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -53,9 +53,18 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Test modules
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
check-fastlane-metadata:
name: Fastlane metadata
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Validate metadata lengths
run: bash fastlane/check_metadata_length.sh
@@ -13,5 +13,5 @@ jobs:
name: "Validation"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
+21 -31
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Build only, skip release/upload'
type: boolean
default: true
jobs:
release-github:
@@ -24,23 +30,15 @@ jobs:
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Assemble beta APK
if: contains(steps.tagger.outputs.tag, '-beta')
if: contains(github.ref_name, '-beta')
run: ./gradlew assembleFossBeta
env:
VERSION: ${{ github.ref }}
@@ -49,7 +47,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production APK
if: "!contains(steps.tagger.outputs.tag, '-beta')"
if: "!contains(github.ref_name, '-beta')"
run: ./gradlew assembleFossRelease
env:
VERSION: ${{ github.ref }}
@@ -58,24 +56,24 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Create pre-release
if: contains(steps.tagger.outputs.tag, '-beta')
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
with:
prerelease: true
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
if: "!contains(steps.tagger.outputs.tag, '-beta')"
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
with:
prerelease: false
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
env:
@@ -107,29 +105,21 @@ jobs:
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Set up ruby env
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
with:
ruby-version: 3.3.6
bundler-cache: true
- name: Assemble beta and upload to Google Play
if: contains(steps.tagger.outputs.tag, '-beta')
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
run: bundle exec fastlane beta
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
@@ -137,7 +127,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production and upload to Google Play
if: "!contains(steps.tagger.outputs.tag, '-beta')"
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
run: bundle exec fastlane production
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
+1 -1
View File
@@ -1 +1 @@
4.0.0-rc0 40000000
4.0.2-rc0 40002000
-2
View File
@@ -177,8 +177,6 @@ dependencies {
addDagger()
addMoshi()
addOkio()
addBaseAndroid()
-3
View File
@@ -1,3 +0,0 @@
-keepclassmembernames @com.squareup.moshi.JsonClass class * extends java.lang.Enum {
<fields>;
}
@@ -1,23 +1,19 @@
package eu.darken.capod.common.upgrade.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
@JsonClass(generateAdapter = true)
data class FossUpgrade(
@Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
val reason: Reason
@SerialName("upgradedAt") @Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
@SerialName("reason") val reason: Reason
) {
@Serializable
@JsonClass(generateAdapter = false)
enum class Reason {
@SerialName("foss.upgrade.reason.donated") @Json(name = "foss.upgrade.reason.donated") DONATED,
@SerialName("foss.upgrade.reason.alreadydonated") @Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@SerialName("foss.upgrade.reason.nomoney") @Json(name = "foss.upgrade.reason.nomoney") NO_MONEY;
@SerialName("foss.upgrade.reason.donated") DONATED,
@SerialName("foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@SerialName("foss.upgrade.reason.nomoney") NO_MONEY;
}
}
+4 -4
View File
@@ -2,9 +2,9 @@
<resources>
<string name="foss_upgrade_donate_label">Faire un don</string>
<string name="foss_upgrade_alreadydonated_label">Jai déjà fait un don</string>
<string name="foss_upgrade_no_money_label">Jai dépensé tout mon argent sur les AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS est gratuit et open source. Si vous le trouvez utile, pensez à sponsoriser le développement pour aider à maintenir le projet en vie.</string>
<string name="foss_upgrade_no_money_label">Jai dépensé tout mon argent sur des AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS est gratuite et à code source ouvert. Si vous la trouvez utile, pensez à soutenir le développement pour contribuer à la pérennité du projet.</string>
<string name="upgrade_foss_sponsor_action">Soutenir le développement</string>
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi. Pas de dépendance à Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Déjà de retour ? Votre soutien maintient CAPod en vie.</string>
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi à la trace. Pas de dépendance à Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Déjà de retour? Votre soutien maintient CAPod en vie.</string>
</resources>
@@ -9,6 +9,9 @@ import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
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
@@ -21,8 +24,6 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
data class BillingClientConnection(
private val client: BillingClient,
@@ -67,22 +68,16 @@ data class BillingClientConnection(
.setProductType(productType)
.build()
val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { continuation ->
client.queryPurchasesAsync(params) { result, purchases ->
continuation.resume(result to purchases)
}
}
val queryResult = client.queryPurchasesAsync(params)
log(TAG) { "queryPurchases($productType): code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases" }
log(TAG) { "queryPurchases($productType): code=${queryResult.billingResult.responseCode}, message=${queryResult.billingResult.debugMessage}, purchases=${queryResult.purchasesList}" }
if (!result.isSuccess) {
if (!queryResult.billingResult.isSuccess) {
log(TAG, WARN) { "queryPurchases($productType) failed" }
throw BillingResultException(result)
} else {
requireNotNull(purchases)
throw BillingResultException(queryResult.billingResult)
}
return purchases
return queryResult.purchasesList
}
suspend fun acknowledgePurchase(purchase: Purchase) {
@@ -90,9 +85,7 @@ data class BillingClientConnection(
setPurchaseToken(purchase.purchaseToken)
}.build()
val result = suspendCoroutine<BillingResult> { continuation ->
client.acknowledgePurchase(ack) { continuation.resume(it) }
}
val result = client.acknowledgePurchase(ack)
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
@@ -118,17 +111,15 @@ data class BillingClientConnection(
val params = QueryProductDetailsParams.newBuilder().setProductList(products).build()
val (result, details) = suspendCoroutine<Pair<BillingResult, List<ProductDetails>>> { continuation ->
client.queryProductDetailsAsync(params) { billingResult, queryResult ->
continuation.resume(billingResult to queryResult.productDetailsList.orEmpty())
}
}
val queryResult = client.queryProductDetails(params)
val details = queryResult.productDetailsList.orEmpty()
log(TAG) {
"querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${result.responseCode}, debug=${result.debugMessage}, details=$details"
"querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${queryResult.billingResult.responseCode}, debug=${queryResult.billingResult.debugMessage}, details=$details"
}
if (!result.isSuccess) throw BillingResultException(result)
if (!queryResult.billingResult.isSuccess) throw BillingResultException(queryResult.billingResult)
for (detail in details) {
val matchingSku = typeSkus.firstOrNull { it.id == detail.productId }
@@ -24,9 +24,11 @@ class BillingDataRepo @Inject constructor(
) {
private val connectionProvider = billingClientConnectionProvider.connection
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
throw it
.retryWhen { cause, attempt ->
if (cause is CancellationException) return@retryWhen false
log(TAG, ERROR) { "Unable to provide client connection (attempt=$attempt):\n${cause.asLog()}" }
delay(60_000) // 60s between retries (upstream already did 5 quick retries)
true
}
.replayingShare(scope)
+10 -5
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.darken.capod">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
@@ -9,10 +9,14 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
tools:ignore="BackgroundLocationPolicy" />
<uses-permission
android:name="android.permission.BLUETOOTH"
@@ -32,7 +36,8 @@
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
<uses-feature
android:name="android.hardware.bluetooth_le"
+1 -4
View File
@@ -14,7 +14,7 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.devicesWithProfiles
import eu.darken.capod.monitor.core.worker.MonitorControl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
@@ -27,7 +27,6 @@ import javax.inject.Inject
open class App : Application() {
@Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@@ -41,8 +40,6 @@ open class App : Application() {
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
monitorControl.startMonitor(forceStart = true)
appScope.launch { widgetManager.refreshWidgets() }
podMonitor.devicesWithProfiles()
@@ -3,19 +3,21 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
import android.os.Parcelable
import androidx.core.util.forEach
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Parcelize
@JsonClass(generateAdapter = true)
@Serializable
data class BleScanResult(
@Json(name = "receivedAt") val receivedAt: Instant,
@Json(name = "address") val address: String,
@Json(name = "rssi") val rssi: Int,
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
@SerialName("address") val address: String,
@SerialName("rssi") val rssi: Int,
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
) : Parcelable {
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
@@ -1,27 +1,24 @@
package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ScannerMode(
val identifier: String,
@StringRes val labelRes: Int
) {
@SerialName("scanner.mode.lowpower") @Json(name = "scanner.mode.lowpower") LOW_POWER(
@SerialName("scanner.mode.lowpower") LOW_POWER(
"scanner.mode.lowpower",
R.string.settings_scanner_mode_lowpower_label
),
@SerialName("scanner.mode.balanced") @Json(name = "scanner.mode.balanced") BALANCED(
@SerialName("scanner.mode.balanced") BALANCED(
"scanner.mode.balanced",
R.string.settings_scanner_mode_balanced_label
),
@SerialName("scanner.mode.lowlatency") @Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
"scanner.mode.lowlatency",
R.string.settings_scanner_mode_lowlatency_label
),
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.CancellationException
@@ -57,21 +59,20 @@ class DebugSessionManager @Inject constructor(
activeDir = recorderState.currentLogDir,
recordingStartedAt = recorderState.recordingStartedAt,
)
val overlaid = applyOverlays(raw, zipping, failedZips)
applyOverlays(raw, zipping, failedZips)
}.replayingShare(appScope)
val orphans = findOrphans(overlaid, zipping)
if (orphans.isNotEmpty()) {
orphans.forEach { (id, _) -> pendingAutoZips.add(id) }
appScope.launch {
orphans.forEach { (id, dir) ->
init {
sessions.onEach { allSessions ->
val orphans = findOrphans(allSessions, zippingIds.value)
orphans.forEach { (id, dir) ->
if (pendingAutoZips.add(id)) {
log(TAG, INFO) { "Orphan session detected, auto-zipping: $id" }
zipSessionAsync(id, dir)
}
}
}
overlaid
}.replayingShare(appScope)
}.launchIn(appScope)
}
private fun applyOverlays(
sessions: List<DebugSession>,
@@ -226,6 +227,7 @@ class DebugSessionManager @Inject constructor(
}
}
failedZipIds.update { emptySet() }
pendingAutoZips.clear()
log(TAG) { "All stored logs deleted" }
refresh()
}
@@ -3,6 +3,7 @@ package eu.darken.capod.common.debug.recording.core
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.annotation.VisibleForTesting
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.InstallId
@@ -48,7 +49,12 @@ class RecorderModule @Inject constructor(
private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
val triggerFileExists = triggerFile.exists()
State(shouldRecord = triggerFileExists)
val persistedInfo = if (triggerFileExists) readTriggerFile() else null
State(
shouldRecord = triggerFileExists,
persistedLogDir = persistedInfo?.first,
recordingStartedAt = persistedInfo?.second ?: 0L,
)
}
val state: Flow<State> = internalState.flow
@@ -59,24 +65,46 @@ class RecorderModule @Inject constructor(
internalState.updateBlocking {
if (!isRecording && shouldRecord) {
val sessionDir = createSessionDir()
val isResume = persistedLogDir != null && persistedLogDir.exists()
val sessionDir = if (isResume) {
log(TAG, INFO) { "Resuming recording into existing session: $persistedLogDir" }
persistedLogDir
} else {
createSessionDir()
}
val logFile = File(sessionDir, "core.log")
val newRecorder = Recorder()
newRecorder.start(logFile)
triggerFile.createNewFile()
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
if (!isResume) {
val startTime = System.currentTimeMillis()
writeTriggerFile(sessionDir, startTime)
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
this@RecorderModule.currentLogDir = sessionDir
this@RecorderModule.currentLogDir = sessionDir
copy(
recorder = newRecorder,
currentLogDir = sessionDir,
recordingStartedAt = System.currentTimeMillis(),
)
copy(
recorder = newRecorder,
currentLogDir = sessionDir,
recordingStartedAt = startTime,
persistedLogDir = null,
)
} else {
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
this@RecorderModule.currentLogDir = sessionDir
copy(
recorder = newRecorder,
currentLogDir = sessionDir,
recordingStartedAt = if (recordingStartedAt > 0L) recordingStartedAt else System.currentTimeMillis(),
persistedLogDir = null,
)
}
} else if (!shouldRecord && isRecording) {
recorder!!.stop()
requireNotNull(recorder) { "Recorder is null despite isRecording" }.stop()
if (triggerFile.exists() && !triggerFile.delete()) {
log(TAG, ERROR) { "Failed to delete trigger file" }
@@ -133,7 +161,8 @@ class RecorderModule @Inject constructor(
internalState.updateBlocking {
copy(shouldRecord = true)
}
return internalState.flow.filter { it.isRecording }.first().currentLogDir!!
val state = internalState.flow.filter { it.isRecording }.first()
return requireNotNull(state.currentLogDir) { "Recording state has no logDir" }
}
suspend fun stopRecorder(): File? {
@@ -169,6 +198,7 @@ class RecorderModule @Inject constructor(
internal val recorder: Recorder? = null,
val currentLogDir: File? = null,
val recordingStartedAt: Long = 0L,
internal val persistedLogDir: File? = null,
) {
val isRecording: Boolean
get() = recorder != null
@@ -177,9 +207,59 @@ class RecorderModule @Inject constructor(
get() = recorder?.path
}
internal fun readTriggerFile(): Pair<File, Long>? = try {
parseTriggerContent(triggerFile.readText())
} catch (e: Exception) {
log(TAG, WARN) { "Failed to read trigger file: $e" }
null
}
private fun writeTriggerFile(sessionDir: File, startTime: Long) {
try {
triggerFile.writeText("${sessionDir.absolutePath}\n$startTime")
} catch (e: Exception) {
log(TAG, WARN) { "Failed to write trigger file: $e" }
try {
triggerFile.createNewFile()
} catch (e2: Exception) {
log(TAG, ERROR) { "Failed to create trigger file fallback: $e2" }
}
}
}
companion object {
internal val TAG = logTag("Debug", "Log", "Recorder", "Module")
private const val FORCE_FILE = "capod_force_debug_run"
private const val MIN_RECORDING_MS = 5_000L
@VisibleForTesting
internal fun parseTriggerContent(
content: String,
now: Long = System.currentTimeMillis(),
): Pair<File, Long>? {
val trimmed = content.trim()
if (trimmed.isEmpty()) return null
val lines = trimmed.lines()
if (lines.size < 2) {
log(TAG, WARN) { "Trigger file has unexpected format: $trimmed" }
return null
}
val dir = File(lines[0])
val timestamp = lines[1].toLongOrNull()
if (timestamp == null || timestamp !in 1..(now + 60_000L)) {
log(TAG, WARN) { "Trigger file has invalid timestamp: ts=$timestamp" }
return null
}
if (!dir.exists()) {
log(TAG, WARN) { "Trigger file references non-existent dir: ${lines[0]}" }
return null
}
return dir to timestamp
}
}
}
@@ -9,6 +9,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
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.debug.recording.core.DebugSession
@@ -132,8 +133,6 @@ class RecorderActivityVM @Inject constructor(
}
fun share() = launch {
val currentState = stater.flow.first()
val dir = currentState.logDir ?: return@launch
val sid = sessionId ?: return@launch
stater.updateBlocking { copy(isWorking = true) }
@@ -141,17 +140,21 @@ class RecorderActivityVM @Inject constructor(
try {
val uri = sessionManager.getZipUri(sid)
val displayName = stater.flow.first().logDir?.name ?: sid
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
type = "application/zip"
addCategory(Intent.CATEGORY_DEFAULT)
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${dir.name}")
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - $displayName")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
events.tryEmit(Event.ShareIntent(chooserIntent))
} catch (e: Exception) {
log(TAG, WARN) { "Failed to share session $sid: ${e.message}" }
} finally {
stater.updateBlocking { copy(isWorking = false) }
}
@@ -390,7 +390,7 @@ private fun BottomActionBar(
onClick = onKeep,
modifier = Modifier.weight(1f),
) {
Text(text = stringResource(R.string.general_close_action))
Text(text = stringResource(R.string.debug_debuglog_screen_keep_action))
}
androidx.compose.material3.Button(
onClick = onShare,
@@ -16,6 +16,7 @@ enum class Permission(
@StringRes val labelRes: Int,
@StringRes val descriptionRes: Int,
val permissionId: String,
val isScanBlocking: Boolean = false,
val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
},
@@ -26,6 +27,7 @@ enum class Permission(
labelRes = R.string.permission_bluetooth_label,
descriptionRes = R.string.permission_bluetooth_description,
permissionId = "android.permission.BLUETOOTH",
isScanBlocking = true,
),
BLUETOOTH_CONNECT(
minApiLevel = Build.VERSION_CODES.S,
@@ -38,6 +40,7 @@ enum class Permission(
labelRes = R.string.permission_bluetooth_scan_label,
descriptionRes = R.string.permission_bluetooth_scan_description,
permissionId = "android.permission.BLUETOOTH_SCAN",
isScanBlocking = true,
),
ACCESS_FINE_LOCATION(
minApiLevel = Build.VERSION_CODES.BASE,
@@ -45,6 +48,7 @@ enum class Permission(
labelRes = R.string.permission_access_fine_location_label,
descriptionRes = R.string.permission_access_fine_location_description,
permissionId = "android.permission.ACCESS_FINE_LOCATION",
isScanBlocking = true,
),
ACCESS_BACKGROUND_LOCATION(
minApiLevel = Build.VERSION_CODES.Q,
@@ -73,7 +77,7 @@ enum class Permission(
},
),
POST_NOTIFICATIONS(
minApiLevel = Build.VERSION_CODES.S,
minApiLevel = Build.VERSION_CODES.TIRAMISU,
labelRes = R.string.permission_post_notifications_label,
descriptionRes = R.string.permission_post_notifications_description,
permissionId = "android.permission.POST_NOTIFICATIONS",
@@ -1,16 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@Suppress("unused")
@OptIn(ExperimentalEncodingApi::class)
class ByteArrayAdapter {
@ToJson
fun toJson(obj: ByteArray): String = Base64.encode(obj)
@FromJson
fun fromJson(base64: String): ByteArray? = Base64.decode(base64)
}
@@ -1,13 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.Instant
class JavaInstantAdapter {
@ToJson
fun toJson(obj: Instant): Long = obj.toEpochMilli()
@FromJson
fun fromJson(epochMillis: Long): Instant = Instant.ofEpochMilli(epochMillis)
}
@@ -0,0 +1,23 @@
package eu.darken.capod.common.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object MapIntByteArrayBase64Serializer : KSerializer<Map<Int, ByteArray>> {
private val delegate = MapSerializer(Int.serializer(), ByteArrayBase64Serializer)
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: Map<Int, ByteArray>) {
delegate.serialize(encoder, value)
}
override fun deserialize(decoder: Decoder): Map<Int, ByteArray> {
return delegate.deserialize(decoder)
}
}
@@ -1,116 +0,0 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package eu.darken.capod.common.serialization
import com.squareup.moshi.*
import java.lang.reflect.Type
import java.util.*
import javax.annotation.CheckReturnValue
import kotlin.apply
import kotlin.collections.isNotEmpty
import kotlin.collections.plus
import kotlin.collections.toTypedArray
import kotlin.io.use
import kotlin.jvm.javaClass
class NameBasedPolyJsonAdapterFactory<T> internal constructor(
val baseType: Class<T>,
val keyLabels: List<String> = emptyList(),
val subtypes: List<Type> = emptyList(),
) : JsonAdapter.Factory {
fun withSubtype(subtype: Class<out T>, label: String): NameBasedPolyJsonAdapterFactory<T> {
require(!keyLabels.contains(label)) { "Labels must be unique." }
return NameBasedPolyJsonAdapterFactory(
baseType = baseType,
keyLabels = keyLabels + label,
subtypes = subtypes + subtype,
)
}
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (Types.getRawType(type) != baseType || annotations.isNotEmpty()) {
return null
}
val jsonAdapters = ArrayList<JsonAdapter<Any>>(subtypes.size)
var i = 0
val size = subtypes.size
while (i < size) {
jsonAdapters.add(moshi.adapter(subtypes[i]))
i++
}
return PolymorphicJsonAdapter(
nameLabels = keyLabels,
subTypes = subtypes,
jsonAdapters = jsonAdapters,
).nullSafe()
}
internal class PolymorphicJsonAdapter(
val nameLabels: List<String>,
val subTypes: List<Type>,
val jsonAdapters: List<JsonAdapter<Any>>,
) : JsonAdapter<Any>() {
private val nameOptions: JsonReader.Options = JsonReader.Options.of(*nameLabels.toTypedArray())
override fun fromJson(reader: JsonReader): Any? {
val peeked = reader.peekJson().apply {
setFailOnUnknown(false)
}
val labelIndex = peeked.use(::labelIndex)
if (labelIndex == -1) {
throw JsonDataException("No matching Field names for $nameLabels")
}
return jsonAdapters[labelIndex].fromJson(reader)
}
private fun labelIndex(reader: JsonReader): Int {
reader.beginObject()
while (reader.hasNext()) {
val labelIndex = reader.selectName(nameOptions)
if (labelIndex == -1) {
reader.skipName()
reader.skipValue()
continue
}
return labelIndex
}
return -1
// throw JsonDataException("Missing label for $labelKey")
}
override fun toJson(writer: JsonWriter, value: Any?) {
val type = value!!.javaClass
val typeIndex = subTypes.indexOf(type)
if (typeIndex == -1) {
throw JsonDataException("No matching name label for $value. Valid labels are $nameLabels")
}
val adapter = jsonAdapters[typeIndex]
writer.beginObject()
val flattenToken = writer.beginFlatten()
adapter.toJson(writer, value)
writer.endFlatten(flattenToken)
writer.endObject()
}
override fun toString(): String = "KeyBasedPolyJsonAdapterFactory($nameLabels)"
}
companion object {
@CheckReturnValue
fun <T> of(baseType: Class<T>): NameBasedPolyJsonAdapterFactory<T> = NameBasedPolyJsonAdapterFactory(
baseType
)
}
}
@@ -1,11 +1,9 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.profiles.core.DeviceProfile
import kotlinx.serialization.json.Json
import javax.inject.Qualifier
import javax.inject.Singleton
@@ -14,14 +12,6 @@ import javax.inject.Singleton
@Module
class SerializationModule {
@Provides
@Singleton
fun moshi(): Moshi = Moshi.Builder().apply {
add(JavaInstantAdapter())
add(ByteArrayAdapter())
add(DeviceProfile.MOSHI_FACTORY)
}.build()
@Provides
@Singleton
@SerializationCapod
@@ -29,6 +19,7 @@ class SerializationModule {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
}
@@ -1,18 +1,15 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeColor(
@StringRes val labelRes: Int
) {
@SerialName("theme.color.blue") @Json(name = "theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
@SerialName("theme.color.green") @Json(name = "theme.color.green") GREEN(R.string.ui_theme_color_green_label),
@SerialName("theme.color.amber") @Json(name = "theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
@SerialName("theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
@SerialName("theme.color.green") GREEN(R.string.ui_theme_color_green_label),
@SerialName("theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
}
@@ -1,18 +1,15 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeMode(
@StringRes val labelRes: Int
) {
@SerialName("theme.mode.system") @Json(name = "theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
@SerialName("theme.mode.dark") @Json(name = "theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
@SerialName("theme.mode.light") @Json(name = "theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
@SerialName("theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
@SerialName("theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
@SerialName("theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
}
@@ -1,19 +1,16 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeStyle(
@StringRes val labelRes: Int
) {
@SerialName("theme.style.default") @Json(name = "theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
@SerialName("theme.style.materialyou") @Json(name = "theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
@SerialName("theme.style.mediumcontrast") @Json(name = "theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
@SerialName("theme.style.highcontrast") @Json(name = "theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
@SerialName("theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
@SerialName("theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
@SerialName("theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
@SerialName("theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
}
@@ -1,24 +1,21 @@
package eu.darken.capod.main.core
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class MonitorMode(
@StringRes val labelRes: Int
) {
@SerialName("monitor.mode.manual") @Json(name = "monitor.mode.manual") MANUAL(
@SerialName("monitor.mode.manual") MANUAL(
R.string.settings_monitor_mode_manual_label
),
@SerialName("monitor.mode.automatic") @Json(name = "monitor.mode.automatic") AUTOMATIC(
@SerialName("monitor.mode.automatic") AUTOMATIC(
R.string.settings_monitor_mode_automatic_label
),
@SerialName("monitor.mode.always") @Json(name = "monitor.mode.always") ALWAYS(
@SerialName("monitor.mode.always") ALWAYS(
R.string.settings_monitor_mode_always_label
),
}
@@ -10,6 +10,7 @@ import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.util.UUID
import javax.inject.Inject
@@ -42,6 +43,9 @@ class PermissionTool @Inject constructor(
}
.onEach { log(TAG) { "Missing permission: $it" } }
val missingScanPermissions: Flow<Set<Permission>> = missingPermissions
.map { perms -> perms.filter { it.isScanBlocking }.toSet() }
companion object {
private val TAG = logTag("PermissionTool")
}
@@ -14,9 +14,11 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log
@@ -78,6 +80,10 @@ class MainActivity : Activity2() {
finish()
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
navigationEntries.forEach { entry ->
entry.apply {
@@ -195,7 +195,7 @@ fun OverviewScreen(
) {
// 1. Permission cards
items(
items = state.permissions.toList(),
items = state.permissions.sortedByDescending { it.isScanBlocking },
key = { it.permissionId },
) { permission ->
PermissionCard(
@@ -205,21 +205,21 @@ fun OverviewScreen(
}
// 2. Bluetooth disabled card
if (!state.isBluetoothEnabled && state.permissions.isEmpty()) {
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
item(key = "bluetooth_disabled") {
BluetoothDisabledCard()
}
}
// 3. No profiles card
if (state.profiles.isEmpty() && state.permissions.isEmpty() && state.isBluetoothEnabled) {
if (state.profiles.isEmpty() && !state.isScanBlocked && state.isBluetoothEnabled) {
item(key = "no_profiles") {
NoProfilesCard(onManageDevices = onManageDevices)
}
}
// 4. Profiled device cards
if (state.permissions.isEmpty() && state.isBluetoothEnabled) {
if (!state.isScanBlocked && state.isBluetoothEnabled) {
items(
items = state.profiledDevices,
key = { it.identifier.hashCode() },
@@ -53,18 +53,23 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false)
val workerAutolaunch = permissionTool.missingPermissions
val workerAutolaunch = permissionTool.missingScanPermissions
.onEach { permissions ->
if (permissions.isNotEmpty()) {
log(TAG) { "Missing permissions: $permissions" }
log(TAG) { "Missing scan permissions: $permissions" }
return@onEach
}
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> {
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
devices?.isNotEmpty() == true
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.ALWAYS -> true
}
@@ -82,7 +87,7 @@ class OverviewViewModel @Inject constructor(
}
}
private val pods = permissionTool.missingPermissions
private val pods = permissionTool.missingScanPermissions
.flatMapLatest { permissions ->
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
@@ -124,6 +129,7 @@ class OverviewViewModel @Inject constructor(
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
}
@@ -56,6 +56,8 @@ import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.toBatteryFloat
import eu.darken.capod.pods.core.toBatteryOrNull
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
@@ -164,7 +166,7 @@ fun DualPodsCard(
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
@@ -175,7 +177,7 @@ fun DualPodsCard(
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent,
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
@@ -218,7 +220,7 @@ fun DualPodsCard(
@Composable
private fun PodGauge(
iconRes: Int,
batteryPercent: Float?,
batteryPercent: Float,
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
@@ -227,15 +229,15 @@ private fun PodGauge(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clamped = batteryPercent?.coerceIn(0f, 1f)
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
@@ -261,7 +263,7 @@ private fun PodGauge(
)
// Progress ring
if (clamped != null) {
if (clamped >= 0f) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(80.dp),
@@ -284,9 +286,9 @@ private fun PodGauge(
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent),
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent != null) {
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -335,7 +337,7 @@ private fun CaseRow(
)
BatteryCapsule(
percent = device.batteryCasePercent,
percent = device.batteryCasePercent.toBatteryFloat(),
modifier = Modifier
.weight(1f)
.height(8.dp),
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -24,10 +25,17 @@ fun PermissionCard(
permission: Permission,
onRequest: (Permission) -> Unit,
) {
val colors = if (permission.isScanBlocking) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
} else {
CardDefaults.cardColors()
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = colors,
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -40,18 +40,18 @@ private val CapsuleShape = RoundedCornerShape(6.dp)
@Composable
fun BatteryCapsule(
percent: Float?,
percent: Float,
modifier: Modifier = Modifier,
) {
val clamped = percent?.coerceIn(0f, 1f)
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
val animatedFraction by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "batteryFill",
)
val barColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
@@ -62,7 +62,7 @@ fun BatteryCapsule(
.clip(CapsuleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (clamped != null) {
if (clamped >= 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
@@ -138,13 +138,6 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/google/dagger") },
)
}
item {
SettingsBaseItem(
title = "Moshi",
subtitle = "A modern JSON library for Kotlin and Java. (APACHE 2.0)",
onClick = { onOpenUrl("https://github.com/square/moshi") },
)
}
item {
SettingsBaseItem(
title = "Android",
@@ -30,6 +30,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.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -191,7 +192,7 @@ private fun SinglePodPreview(
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent),
text = formatPercent(state.percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp),
@@ -293,7 +294,7 @@ private fun WidgetContainer(
@Composable
private fun PodItemRow(
icon: Int,
percent: Float?,
percent: Float,
charging: Boolean,
inEar: Boolean,
textColor: Color,
@@ -313,7 +314,7 @@ private fun PodItemRow(
colorFilter = iconTint,
)
Text(
text = formatPercent(percent),
text = formatPercent(percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
@@ -26,6 +26,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.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -105,7 +106,7 @@ private fun GlanceSinglePod(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent),
text = formatGlancePercent(state.percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
@@ -203,7 +204,7 @@ private fun GlanceWidgetRoot(
@Composable
private fun GlancePodItem(
icon: Int,
percent: Float?,
percent: Float,
charging: Boolean,
inEar: Boolean,
textStyle: TextStyle,
@@ -222,7 +223,7 @@ private fun GlancePodItem(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent),
text = formatGlancePercent(percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
@@ -18,15 +18,15 @@ sealed class WidgetRenderState {
val isWide: Boolean,
val deviceLabel: String?,
@DrawableRes val leftIcon: Int,
val leftPercent: Float?,
val leftPercent: Float,
val leftCharging: Boolean,
val leftInEar: Boolean,
@DrawableRes val rightIcon: Int,
val rightPercent: Float?,
val rightPercent: Float,
val rightCharging: Boolean,
val rightInEar: Boolean,
@DrawableRes val caseIcon: Int,
val casePercent: Float?,
val casePercent: Float,
val caseCharging: Boolean,
) : WidgetRenderState()
@@ -37,7 +37,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedIconColor: Int,
val deviceLabel: String?,
@DrawableRes val headsetIcon: Int,
val percent: Float?,
val percent: Float,
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
@@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.toBatteryFloat
object WidgetRenderStateMapper {
@@ -46,15 +47,15 @@ object WidgetRenderStateMapper {
isWide = isWide,
deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeftPodPercent,
leftPercent = device.batteryLeftPodPercent.toBatteryFloat(),
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRightPodPercent,
rightPercent = device.batteryRightPodPercent.toBatteryFloat(),
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
casePercent = (device as? HasCase)?.batteryCasePercent,
casePercent = (device as? HasCase)?.batteryCasePercent.toBatteryFloat(),
caseCharging = device is HasCase && device.isCaseCharging,
)
@@ -65,7 +66,7 @@ object WidgetRenderStateMapper {
resolvedIconColor = iconColor,
deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes,
percent = device.batteryHeadsetPercent,
percent = device.batteryHeadsetPercent.toBatteryFloat(),
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
worn = device is HasEarDetection && device.isBeingWorn,
@@ -1,8 +1,6 @@
package eu.darken.capod.monitor.core
import android.content.Context
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -11,10 +9,12 @@ 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.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -23,12 +23,11 @@ import javax.inject.Singleton
class PodDeviceCache @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatcherProvider: DispatcherProvider,
moshi: Moshi,
@SerializationCapod private val json: Json,
) {
private val cacheDir by lazy {
File(context.cacheDir, "device_cache").apply { mkdirs() }
}
private val jsonAdapter = moshi.adapter<BleScanResult>()
private val lock = Mutex()
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
@@ -40,7 +39,7 @@ class PodDeviceCache @Inject constructor(
if (!cacheFile.exists()) return@withLock null
try {
val raw = cacheFile.readText()
jsonAdapter.fromJson(raw)
json.decodeFromString<BleScanResult>(raw)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
cacheFile.delete()
@@ -56,8 +55,8 @@ class PodDeviceCache @Inject constructor(
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
val cacheFile = id.toCacheFile()
try {
val json = jsonAdapter.toJson(device)
cacheFile.writeText(json)
val encoded = json.encodeToString(BleScanResult.serializer(), device)
cacheFile.writeText(encoded)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
cacheFile.delete()
@@ -55,11 +55,11 @@ class PodMonitor @Inject constructor(
private val cacheLock = Mutex()
val devices: Flow<List<PodDevice>> = combine(
permissionTool.missingPermissions,
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
) { missingPermissions, isBluetoothEnabled ->
log(TAG) { "devices: missingPermissions=$missingPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
missingPermissions.isEmpty() && isBluetoothEnabled
) { missingScanPermissions, isBluetoothEnabled ->
log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
missingScanPermissions.isEmpty() && isBluetoothEnabled
}
.flatMapLatest { isReady ->
if (!isReady) {
@@ -34,7 +34,12 @@ class BluetoothEventReceiver : BroadcastReceiver() {
} else {
log { "Event related to $bluetoothDevice" }
}
val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
val supportedFeatures = try {
ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
} catch (e: SecurityException) {
log(TAG, WARN) { "Missing BLUETOOTH_CONNECT, can't check device features: ${e.message}" }
return
}
if (supportedFeatures.isEmpty()) {
log(TAG) { "Device has no features we support." }
@@ -22,7 +22,7 @@ class MonitorControl @Inject constructor(
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
val hasBluetoothPermission =
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context)
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_SCAN.isGranted(context)
if (!hasBluetoothPermission) {
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
return
@@ -114,7 +114,15 @@ class MonitorService : Service() {
return
}
super.onCreate()
try {
super.onCreate()
} catch (e: Exception) {
log(TAG, WARN) { "Hilt DI failed in onCreate(), stopping service: ${e.asLog()}" }
Bugs.report(tag = TAG, "Hilt DI failed in onCreate()", exception = e)
foregroundStartFailed = true
stopSelf()
return
}
log(TAG, VERBOSE) { "onCreate()" }
// Replace early notification with the full one from injected MonitorNotifications.
@@ -162,9 +170,9 @@ class MonitorService : Service() {
}
private suspend fun doMonitor() {
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
return
}
@@ -190,10 +198,10 @@ class MonitorService : Service() {
}
.launchIn(monitorScope)
permissionTool.missingPermissions
permissionTool.missingScanPermissions
.flatMapLatest { missingPermsFlow ->
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
monitorScope.coroutineContext.cancelChildren()
emptyFlow()
} else {
@@ -2,8 +2,6 @@ package eu.darken.capod.pods.core
import android.content.Context
import androidx.annotation.DrawableRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
@@ -77,112 +75,111 @@ interface PodDevice {
value class Id(private val id: UUID = UUID.randomUUID())
@Serializable
@JsonClass(generateAdapter = false)
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
) {
@SerialName("airpods.gen1") @Json(name = "airpods.gen1") AIRPODS_GEN1(
@SerialName("airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen2") @Json(name = "airpods.gen2") AIRPODS_GEN2(
@SerialName("airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen3") @Json(name = "airpods.gen3") AIRPODS_GEN3(
@SerialName("airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4") @Json(name = "airpods.gen4") AIRPODS_GEN4(
@SerialName("airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4.anc") @Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
@SerialName("airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.device_airpods_gen4anc_both,
),
@SerialName("airpods.pro") @Json(name = "airpods.pro") AIRPODS_PRO(
@SerialName("airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2") @Json(name = "airpods.pro2") AIRPODS_PRO2(
@SerialName("airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2.usbc") @Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
@SerialName("airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro3") @Json(name = "airpods.pro3") AIRPODS_PRO3(
@SerialName("airpods.pro3") AIRPODS_PRO3(
"AirPods Pro 3",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.max") @Json(name = "airpods.max") AIRPODS_MAX(
@SerialName("airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.device_airpods_max
),
@SerialName("airpods.max.usbc") @Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
@SerialName("airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.device_airpods_max
),
@SerialName("beats.flex") @Json(name = "beats.flex") BEATS_FLEX(
@SerialName("beats.flex") BEATS_FLEX(
"Beats Flex",
R.drawable.device_beats_earbuds,
),
@SerialName("beats.solo.3") @Json(name = "beats.solo.3") BEATS_SOLO_3(
@SerialName("beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3",
R.drawable.device_beats_headphones,
),
@SerialName("beats.studio.3") @Json(name = "beats.studio.3") BEATS_STUDIO_3(
@SerialName("beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3",
R.drawable.device_beats_studio3,
),
@SerialName("beats.x") @Json(name = "beats.x") BEATS_X(
@SerialName("beats.x") BEATS_X(
"Beats X",
R.drawable.device_beats_x,
),
@SerialName("beats.powerbeats.3") @Json(name = "beats.powerbeats.3") POWERBEATS_3(
@SerialName("beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3",
R.drawable.device_powerbeats_3,
),
@SerialName("beats.powerbeats.4") @Json(name = "beats.powerbeats.4") POWERBEATS_4(
@SerialName("beats.powerbeats.4") POWERBEATS_4(
"Power Beats 4",
R.drawable.device_powerbeats_4,
),
@SerialName("beats.powerbeats.pro") @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
@SerialName("beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro",
R.drawable.device_powerbeats_pro_both,
),
@SerialName("beats.powerbeats.pro2") @Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2(
@SerialName("beats.powerbeats.pro2") POWERBEATS_PRO2(
"Power Beats Pro 2",
R.drawable.device_powerbeats_pro2_both,
),
@SerialName("beats.fit.pro") @Json(name = "beats.fit.pro") BEATS_FIT_PRO(
@SerialName("beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro",
R.drawable.device_beats_fitpro_both,
),
@SerialName("fakes.tws.i99999") @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
@SerialName("fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen2") @Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
@SerialName("fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen3") @Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
@SerialName("fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.device_airpods_gen3_both,
),
@SerialName("fakes.varunr.airpodspro") @Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
@SerialName("fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("fakes.generic.airpods.pro2") @Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
@SerialName("fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("unknown") @Json(name = "unknown") UNKNOWN(
@SerialName("unknown") UNKNOWN(
"Unknown"
);
}
@@ -8,6 +8,12 @@ import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
const val BATTERY_UNKNOWN = -1f
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
@@ -1,7 +1,5 @@
package eu.darken.capod.profiles.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
@@ -14,14 +12,13 @@ import java.util.UUID
@Parcelize
@Serializable
@SerialName("apple")
@JsonClass(generateAdapter = true)
data class AppleDeviceProfile(
@Json(name = "id") override val id: ProfileId = UUID.randomUUID().toString(),
@Json(name = "label") override val label: String,
@Json(name = "priority") override val priority: Int = 0,
@Json(name = "model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
@Json(name = "minimumSignalQuality") override val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY,
@Serializable(with = ByteArrayBase64Serializer::class) @Json(name = "identityKey") val identityKey: IdentityResolvingKey? = null,
@Serializable(with = ByteArrayBase64Serializer::class) @Json(name = "encryptionKey") val encryptionKey: ProximityEncryptionKey? = null,
@Json(name = "address") override val address: String? = null,
@SerialName("id") override val id: ProfileId = UUID.randomUUID().toString(),
@SerialName("label") override val label: String,
@SerialName("priority") override val priority: Int = 0,
@SerialName("model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
@SerialName("minimumSignalQuality") override val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY,
@SerialName("identityKey") @Serializable(with = ByteArrayBase64Serializer::class) val identityKey: IdentityResolvingKey? = null,
@SerialName("encryptionKey") @Serializable(with = ByteArrayBase64Serializer::class) val encryptionKey: ProximityEncryptionKey? = null,
@SerialName("address") override val address: String? = null,
) : DeviceProfile
@@ -1,9 +1,7 @@
package eu.darken.capod.profiles.core
import android.os.Parcelable
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import eu.darken.capod.pods.core.PodDevice
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -17,8 +15,5 @@ sealed interface DeviceProfile : Parcelable {
companion object {
const val DEFAULT_MINIMUM_SIGNAL_QUALITY = 0.15f
val MOSHI_FACTORY = PolymorphicJsonAdapterFactory.of(DeviceProfile::class.java, "type")
.withSubtype(AppleDeviceProfile::class.java, "apple")
}
}
@@ -1,11 +1,9 @@
package eu.darken.capod.profiles.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = true)
data class DeviceProfilesContainer(
@Json(name = "profiles") val profiles: List<DeviceProfile> = emptyList()
@SerialName("profiles") val profiles: List<DeviceProfile> = emptyList()
)
@@ -30,7 +30,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -343,7 +343,7 @@ private fun DeviceInfoCard(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = modelExpanded,
@@ -395,7 +395,7 @@ private fun DeviceInfoCard(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(
expanded = deviceExpanded,
@@ -1,27 +1,24 @@
package eu.darken.capod.reaction.core.autoconnect
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class AutoConnectCondition(
val identifier: String,
@StringRes val labelRes: Int
) {
@SerialName("autoconnect.condition.seen") @Json(name = "autoconnect.condition.seen") WHEN_SEEN(
@SerialName("autoconnect.condition.seen") WHEN_SEEN(
"monitor.mode.manual",
R.string.settings_reaction_autoconnect_whenseen_label
),
@SerialName("autoconnect.condition.case") @Json(name = "autoconnect.condition.case") CASE_OPEN(
@SerialName("autoconnect.condition.case") CASE_OPEN(
"autoconnect.condition.case",
R.string.settings_reaction_autoconnect_caseopen_label
),
@SerialName("autoconnect.condition.inear") @Json(name = "autoconnect.condition.inear") IN_EAR(
@SerialName("autoconnect.condition.inear") IN_EAR(
"autoconnect.condition.inear",
R.string.settings_reaction_autoconnect_inear_label
),
@@ -40,6 +40,8 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.toBatteryFloat
import eu.darken.capod.pods.core.toBatteryOrNull
import eu.darken.capod.pods.core.getSignalQuality
@Composable
@@ -127,7 +129,7 @@ private fun DualPodContent(device: DualPodDevice) {
// Left pod
BatteryColumn(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
modifier = Modifier.weight(1f),
)
@@ -135,7 +137,7 @@ private fun DualPodContent(device: DualPodDevice) {
if (hasCase != null) {
BatteryColumn(
iconRes = hasCase.caseIcon,
batteryPercent = hasCase.batteryCasePercent,
batteryPercent = hasCase.batteryCasePercent.toBatteryFloat(),
modifier = Modifier.weight(1f),
)
}
@@ -143,7 +145,7 @@ private fun DualPodContent(device: DualPodDevice) {
// Right pod
BatteryColumn(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent,
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
modifier = Modifier.weight(1f),
)
}
@@ -153,17 +155,18 @@ private fun DualPodContent(device: DualPodDevice) {
private fun SinglePodContent(device: SinglePodDevice) {
BatteryColumn(
iconRes = device.iconRes,
batteryPercent = device.batteryHeadsetPercent,
batteryPercent = device.batteryHeadsetPercent.toBatteryFloat(),
)
}
@Composable
private fun BatteryColumn(
iconRes: Int,
batteryPercent: Float?,
batteryPercent: Float,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val nullablePercent = batteryPercent.toBatteryOrNull()
Column(
modifier = modifier,
@@ -183,13 +186,13 @@ private fun BatteryColumn(
horizontalArrangement = Arrangement.Center,
) {
Icon(
painter = painterResource(getBatteryDrawable(batteryPercent)),
painter = painterResource(getBatteryDrawable(nullablePercent)),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = formatBatteryPercent(context, batteryPercent),
text = formatBatteryPercent(context, nullablePercent),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
+3 -3
View File
@@ -312,10 +312,10 @@
<string name="debug_debuglog_screen_log_files_label">ملفات السجلّ</string>
<plurals name="debug_debuglog_screen_log_files_ready">
<item quantity="zero">%1$d ملف جاهز (%2$s)</item>
<item quantity="one">ملف جاهز (%2$s)</item>
<item quantity="two">ملفان جاهزان (%2$s)</item>
<item quantity="one">%1$d ملف جاهز (%2$s)</item>
<item quantity="two">%1$d ملفان جاهزان (%2$s)</item>
<item quantity="few">%1$d ملفات جاهزة (%2$s)</item>
<item quantity="many">%1$d ملف جاهز (%2$s)</item>
<item quantity="many">%1$d ملفًا جاهزًا (%2$s)</item>
<item quantity="other">%1$d ملف جاهز (%2$s)</item>
</plurals>
<string name="debug_debuglog_screen_discard_action">حذف</string>
+2 -2
View File
@@ -358,7 +358,7 @@
<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">S\'ha enviat correctament el registre de depuració? El registre s\'eliminarà.</string>
<string name="support_debuglog_sent_message">El registre de depuració s\'ha enviat correctament? El registre s\'eliminarà.</string>
<string name="support_contact_sent_title">S\'ha enviat el correu electrònic?</string>
<string name="support_contact_sent_message">S\'ha enviat correctament el correu electrònic? El registre de depuració adjunt s\'eliminarà.</string>
<string name="support_contact_sent_message">El correu electrònic s\'ha enviat correctament? El registre de depuració adjunt s\'eliminarà.</string>
</resources>
+2 -2
View File
@@ -366,7 +366,7 @@
<string name="support_contact_debuglog_delete_message">Tento log ladění bude trvale smazán.</string>
<!-- Post-share confirmation -->
<string name="support_debuglog_sent_title">Log odeslán?</string>
<string name="support_debuglog_sent_message">Byl protokol ladění úspěšně odeslán? Protokol bude smazán.</string>
<string name="support_debuglog_sent_message">Byl log ladění úspěšně odeslán? Log bude smazán.</string>
<string name="support_contact_sent_title">E-mail odeslán?</string>
<string name="support_contact_sent_message">Byl e-mail úspěšně odeslán? Přiložený protokol ladění bude smazán.</string>
<string name="support_contact_sent_message">Byl e-mail úspěšně odeslán? Přiložený log ladění bude smazán.</string>
</resources>
+1 -1
View File
@@ -31,7 +31,7 @@
<string name="upgrade_screen_subscription_action_hint">%s/Jahr</string>
<string name="upgrade_screen_iap_action">Einmalig kaufen</string>
<string name="upgrade_screen_iap_action_hint">Einmaliger Kauf: %s</string>
<string name="upgrade_screen_restore_purchase_action">Auswahl wiederherstellen</string>
<string name="upgrade_screen_restore_purchase_action">Kauf wiederherstellen</string>
<string name="upgrade_screen_restore_purchase_message">Keine Käufe gefunden. Verwendest Du das richtige Konto?</string>
<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>
+2 -2
View File
@@ -358,7 +358,7 @@
<string name="support_contact_debuglog_delete_message">Este registro de depuración se eliminará permanentemente.</string>
<!-- Post-share confirmation -->
<string name="support_debuglog_sent_title">¿Registro enviado?</string>
<string name="support_debuglog_sent_message">¿Se envió el registro de depuración correctamente? El registro se elimina.</string>
<string name="support_contact_sent_title">¿Correo enviado?</string>
<string name="support_debuglog_sent_message">¿Se envió el registro de depuración con éxito? El registro se eliminado.</string>
<string name="support_contact_sent_title">¿El correo electrónico se envió?</string>
<string name="support_contact_sent_message">¿Se envió el correo electrónico correctamente? El registro de depuración adjunto se eliminará.</string>
</resources>
+4 -4
View File
@@ -267,7 +267,7 @@
<string name="profiles_key_status_configured">Seadistatud</string>
<string name="profiles_key_status_not_set">Määramata</string>
<string name="profiles_signal_quality_min_label">Väljas</string>
<string name="profiles_signal_quality_max_label">Range</string>
<string name="profiles_signal_quality_max_label">Strict</string>
<string name="profiles_priority_hint">Profiili asukoht määrab olulisuse. Profiilide asukoha muutmiseks lohistage neid. Nimekirja eesosas olevad profiilid on eelisjärjekorras mitme samasuguse seadme puhul.</string>
<!-- Theme Mode -->
<string name="ui_theme_mode_label">Teema režiim</string>
@@ -357,8 +357,8 @@
<string name="support_contact_debuglog_delete_title">Kustuta silumislogi?</string>
<string name="support_contact_debuglog_delete_message">See silumislogi kustutatakse jäädavalt.</string>
<!-- Post-share confirmation -->
<string name="support_debuglog_sent_title">Logi saadeti?</string>
<string name="support_debuglog_sent_title">Logi saadetud?</string>
<string name="support_debuglog_sent_message">Kas silumislogi saadeti edukalt? Logi kustutatakse.</string>
<string name="support_contact_sent_title">E-kiri saadeti?</string>
<string name="support_contact_sent_message">Kas e-kiri saadeti edukalt? Manustatud silumislogi kustutatakse.</string>
<string name="support_contact_sent_title">Meil saadetud?</string>
<string name="support_contact_sent_message">Kas meil saadeti edukalt? Manustatud silumislogi kustutatakse.</string>
</resources>
+1 -1
View File
@@ -123,7 +123,7 @@
<string name="help_translate_label">ترجمه</string>
<string name="help_translate_description">به ترجمه این برنامه به زبان موردعلاقه خود کمک کنید.</string>
<string name="translators_thanks_title">مترجمان</string>
<string name="translators_thanks_description">تیره کردن</string>
<string name="translators_thanks_description">darken</string>
<string name="widget_description">ویجتی که آخرین وضعیت شناخته‌شده دستگاه را نشان می‌دهد.</string>
<string name="widget_config_screen_title">پیکربندی ابزارک</string>
<string name="widget_configuration_title">انتخاب دستگاه</string>
+34 -34
View File
@@ -6,7 +6,7 @@
<string name="general_copy_action">Copier</string>
<string name="general_thank_you_label">Merci</string>
<string name="general_upgrade_action">Passer Pro</string>
<string name="common_feature_requires_pro_msg">Mettez à niveau pour déverrouiller.</string>
<string name="common_feature_requires_pro_msg">Passez à la version Pro pour débloquer loption.</string>
<string name="general_donate_action">Faire un don</string>
<string name="general_check_action">Vérifier</string>
<string name="general_close_action">Fermer</string>
@@ -18,24 +18,24 @@
<string name="general_example_label">Exemple : %s</string>
<string name="upgrade_capod_label">Surclasser CAPod</string>
<string name="upgrade_capod_description">Obtenez des fonctions supplémentaires et soutenez le développeur.</string>
<string name="upgrade_preamble">CAPod est développé par une seule personne. La mise à niveau déverrouille des fonctionnalités supplémentaires et aide à maintenir l\'appli en vie.</string>
<string name="upgrade_preamble">CAPod est développée par une seule personne. Passer à la version Pro déverrouille des fonctions supplémentaires et contribue à la pérennité de lappli.</string>
<string name="upgrade_benefit_themes">Personnalisation du thème</string>
<string name="upgrade_benefit_autoplay">Lecture automatique &amp; pause automatique</string>
<string name="upgrade_benefit_autoplay">Lecture et pause automatiques</string>
<string name="upgrade_benefit_autoconnect">Connexion automatique</string>
<string name="upgrade_benefit_popups">Popup à l\'ouverture de l\'étui &amp; connexion</string>
<string name="upgrade_benefit_widgets">Widgets de l\'écran d\'accueil</string>
<string name="upgrade_benefit_popups">Fenêtre de notification à l\'ouverture de l\'étui et à la connexion</string>
<string name="upgrade_benefit_widgets">Widgets sur lécran daccueil</string>
<string name="upgrade_benefit_support">Soutenir le développeur</string>
<string name="upgrade_screen_options_description">Mêmes fonctionnalités, tarification différente. L\'abonnement comprend un essai gratuit et peut être annulé à tout moment.</string>
<string name="upgrade_screen_subscription_trial_action">Commencez votre essai gratuit</string>
<string name="upgrade_screen_subscription_action">S\'abonner annuellement</string>
<string name="upgrade_screen_options_description">Mêmes fonctions, tarification différente. Labonnement comprend un essai gratuit et peut être annulé nimporte quand.</string>
<string name="upgrade_screen_subscription_trial_action">Commencer lessai gratuit</string>
<string name="upgrade_screen_subscription_action">Abonnement annuel</string>
<string name="upgrade_screen_subscription_action_hint">%s/an</string>
<string name="upgrade_screen_iap_action">Acheter une fois</string>
<string name="upgrade_screen_iap_action_hint">Achat unique : %s</string>
<string name="upgrade_screen_restore_purchase_action">Rétablir lachat</string>
<string name="upgrade_screen_restore_purchase_message">Aucun achat na été trouvé. Utilisez-vous le bon compte?</string>
<string name="upgrade_screen_restore_troubleshooting_msg">Si vous avez récemment effectué un achat, Google Play peut mettre quelques instants à synchroniser.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Réessayez dans quelques minutes si votre achat napparaît pas.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Assurez-vous d’être connecté avec le même compte Google utilisé pour lachat.</string>
<string name="upgrade_screen_restore_troubleshooting_msg">Si lachat est récent, Google Play peut mettre du temps à synchroniser.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Si votre achat napparaît pas, réessayez dans quelques minutes.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Le même compte Google doit être utilisé pour lachat et la connexion.</string>
<string name="settings_monitor_mode_label">Mode surveillance</string>
<string name="settings_monitor_mode_description">Les circonstances de surveillance des données Bluetooth par lappli.</string>
<string name="settings_monitor_connected_notification_label">Notification supplémentaire</string>
@@ -96,7 +96,7 @@
<string name="settings_support_label">Assistance</string>
<string name="settings_support_description">Si vous avez besoin daide.</string>
<string name="settings_wiki_label">Wiki</string>
<string name="settings_wiki_description">FAQ &amp; Guides</string>
<string name="settings_wiki_description">FAQ et guides</string>
<string name="issue_tracker_label">Suivi des problèmes</string>
<string name="issue_tracker_description">Un système public de suivi des problèmes et des demandes de fonctions (anglais seulement).</string>
<string name="discord_label">Discord</string>
@@ -129,7 +129,7 @@
<string name="widget_configuration_title">Choisir un appareil</string>
<string name="widget_configuration_description">Choisissez le profil dappareil que ce widget doit afficher.</string>
<string name="widget_no_data_label">Aucune donnée</string>
<string name="widget_error_loading_label">Erreur lors du chargement du widget</string>
<string name="widget_error_loading_label">Erreur de chargement du widget</string>
<string name="widget_config_appearance_label">Apparence</string>
<string name="widget_config_preset_label">Préréglages</string>
<string name="widget_config_background_color_label">Couleur d\'arrière-plan</string>
@@ -264,8 +264,8 @@
<string name="profiles_maindevice_encryptionkey_explanation">Les AirPods envoient un message d’état, dont une partie est chiffrée. La clé de chiffrement permet à CAPod de déchiffrer lintégralité du message. Un accès ponctuel à un MacBook est nécessaire.</string>
<string name="profiles_key_invalid_format">Le format de la clé est invalide</string>
<string name="profiles_key_expected_format">Format attendu : %1$s</string>
<string name="profiles_key_status_configured">Configuré</string>
<string name="profiles_key_status_not_set">Non défini</string>
<string name="profiles_key_status_configured">Configurée</string>
<string name="profiles_key_status_not_set">Non définie</string>
<string name="profiles_signal_quality_min_label">Désactivé</string>
<string name="profiles_signal_quality_max_label">Strict</string>
<string name="profiles_priority_hint">L\'ordre de profil détermine la priorité. Faites glisser les profils pour les réorganiser - les profils plus élevés dans la liste ont la priorité lorsque plusieurs appareils correspondent.</string>
@@ -296,14 +296,14 @@
<string name="general_discard_action">Abandonner</string>
<string name="general_keep_editing_action">Continuer de modifier</string>
<!-- Short recording warning -->
<string name="debug_debuglog_short_recording_title">Enregistrement trop court</string>
<string name="debug_debuglog_short_recording_message">Un journal de débogage doit capturer le problème au moment où il se produit. Continuez lenregistrement, reproduisez le problème, puis arrêtez lenregistrement.</string>
<string name="debug_debuglog_short_recording_continue">Continuer lenregistrement</string>
<string name="debug_debuglog_short_recording_title">Lenregistrement est trop court</string>
<string name="debug_debuglog_short_recording_message">Un journal de débogage doit capturer le problème au moment où il se produit. Poursuivez lenregistrement, reproduisez le problème, puis arrêtez lenregistrement.</string>
<string name="debug_debuglog_short_recording_continue">Poursuivre lenregistrement</string>
<string name="debug_debuglog_short_recording_stop">Arrêter quand même</string>
<!-- Recorder screen -->
<string name="debug_debuglog_screen_title">Journal de débogage</string>
<string name="debug_debuglog_screen_subtitle">Exporter les informations de débogage pour le dépannage</string>
<string name="debug_debuglog_screen_sensitive_title">Informations sensibles</string>
<string name="debug_debuglog_screen_subtitle">Exporter les renseignements de débogage pour le dépannage</string>
<string name="debug_debuglog_screen_sensitive_title">Information sensible</string>
<string name="debug_debuglog_screen_session_path_label">Chemin de la session</string>
<string name="debug_debuglog_screen_log_files_label">Fichiers journaux</string>
<plurals name="debug_debuglog_screen_log_files_ready">
@@ -316,34 +316,34 @@
<item quantity="one">%1$d journal de débogage (%2$s)</item>
<item quantity="other">%1$d journaux de débogage (%2$s)</item>
</plurals>
<string name="support_debuglog_clear_action">Effacer les journaux de débogage enregistrés</string>
<string name="support_debuglog_clear_action">Supprimer les journaux de débogage enregistrés</string>
<string name="support_debuglog_sessions_label">Sessions de débogage</string>
<string name="support_debuglog_sessions_desc">Gérer les sessions de journal de débogage enregistrées</string>
<string name="support_debuglog_sessions_empty">Aucune session de débogage</string>
<string name="support_debuglog_session_recording">Enregistrement…</string>
<string name="support_debuglog_session_compressing">Compression…</string>
<string name="support_debuglog_session_delete_title">Supprimer la session ?</string>
<string name="support_debuglog_session_delete_message">Cette session de débogage sera définitivement supprimée.</string>
<string name="support_debuglog_clear_all_message">Toutes les sessions de débogage seront définitivement supprimées.</string>
<string name="support_debuglog_session_delete_message">Cette session de débogage sera supprimée irrémédiablement.</string>
<string name="support_debuglog_clear_all_message">Les sessions de débogage seront supprimées irrémédiablement.</string>
<string name="support_debuglog_failed_empty_log">Échec : le fichier journal est vide</string>
<string name="support_debuglog_failed_missing_log">Échec : le fichier journal est manquant</string>
<string name="support_debuglog_failed_missing_log">Échec : le fichier journal manque</string>
<string name="support_debuglog_failed_corrupt_zip">Échec : le fichier zip est corrompu</string>
<string name="support_debuglog_failed_zip_failed">Échec : la compression a échoué</string>
<!-- Contact form -->
<string name="support_contact_label">Contacter le développeur</string>
<string name="support_contact_desc">Remplissez un formulaire pour envoyer un courriel au développeur.</string>
<string name="support_contact_welcome">Je lis chaque message moi-même et fais de mon mieux pour répondre, mais comme je travaille seul sur ce projet, cela peut prendre un peu de temps. Merci pour votre patience.</string>
<string name="support_contact_welcome">Je lis chaque message et fais de mon mieux pour répondre. Comme jassure seul le développement, cela peut prendre un peu de temps. Merci pour votre patience.</string>
<string name="support_contact_footer">Votre message sera envoyé par courriel. Les renseignements sur lappareil et la configuration sont joints automatiquement. Vous pouvez joindre des captures d’écran ou une vidéo au courriel. Certaines parties du courriel seront en anglais afin que le développeur puisse facilement les comprendre.</string>
<string name="support_contact_category_label">Catégorie</string>
<string name="support_contact_category_question_label">Question</string>
<string name="support_contact_category_feature_label">Demande de fonction</string>
<string name="support_contact_category_bug_label">Relevé de bogue</string>
<string name="support_contact_description_label">Description</string>
<string name="support_contact_description_question_hint">Décrivez votre question en détail. Veuillez être précis.</string>
<string name="support_contact_description_feature_hint">Décrivez la fonctionnalité que vous souhaitez voir ajoutée. Veuillez être précis.</string>
<string name="support_contact_description_bug_hint">Décrivez ce qui sest passé et comment reproduire le problème. Veuillez être précis.</string>
<string name="support_contact_description_question_hint">Rédigez précisément votre question.</string>
<string name="support_contact_description_feature_hint">Décrivez précisément la fonction que vous souhaitez voir ajoutée .</string>
<string name="support_contact_description_bug_hint">Décrivez précisément ce qui sest passé et comment reproduire le problème.</string>
<string name="support_contact_expected_label">Comportement attendu</string>
<string name="support_contact_expected_hint">Décrivez ce que vous attendiez quil se passe.</string>
<string name="support_contact_expected_hint">Décrivez ce à quoi vous vous attendiez.</string>
<plurals name="support_contact_word_count">
<item quantity="one">%1$d / %2$d mot</item>
<item quantity="other">%1$d / %2$d mots</item>
@@ -355,10 +355,10 @@
<string name="support_contact_send_action">Ouvrir lappli de courriel</string>
<string name="support_contact_no_email_app">Aucune appli de courriel na été trouvée sur cet appareil.</string>
<string name="support_contact_debuglog_delete_title">Supprimer le journal de débogage ?</string>
<string name="support_contact_debuglog_delete_message">Ce journal de débogage sera définitivement supprimé.</string>
<string name="support_contact_debuglog_delete_message">Ce journal de débogage sera supprimé irrémédiablement.</string>
<!-- Post-share confirmation -->
<string name="support_debuglog_sent_title">Journal envoyé ?</string>
<string name="support_debuglog_sent_message">Le journal de débogage a-t-il été envoyé avec succès ? Le journal sera supprimé.</string>
<string name="support_contact_sent_title">E-mail envoyé ?</string>
<string name="support_contact_sent_message">L\'e-mail a-t-il été envoyé avec succès ? Le journal de débogage joint sera supprimé.</string>
<string name="support_debuglog_sent_title">Le journal a-t-il été envoyé?</string>
<string name="support_debuglog_sent_message">Le journal de débogage a-t-il été envoyé? Il sera supprimé.</string>
<string name="support_contact_sent_title">Le courriel a été envoyé?</string>
<string name="support_contact_sent_message">Le courriel a-t-il été envoyé? Le journal de débogage joint sera supprimé.</string>
</resources>
+1 -1
View File
@@ -285,7 +285,7 @@
<string name="ui_theme_color_label">Boja teme</string>
<string name="ui_theme_color_blue_label">Plava</string>
<string name="ui_theme_color_green_label">Zelena</string>
<string name="ui_theme_color_amber_label">Jantąrna</string>
<string name="ui_theme_color_amber_label">Jantarna</string>
<string name="ui_theme_color_disabled_materialyou">Koristi boje pozadine</string>
<!-- Settings categories -->
<string name="settings_category_monitoring_label">Praćenje</string>
+11 -11
View File
@@ -302,9 +302,9 @@
<string name="debug_debuglog_short_recording_stop">Ամեն դեպքում դադարեցնել</string>
<!-- Recorder screen -->
<string name="debug_debuglog_screen_title">Կարգաբերման մատյան</string>
<string name="debug_debuglog_screen_subtitle">Կարգաբերման տեղեկատվության արտահանել վհարկարի համար</string>
<string name="debug_debuglog_screen_sensitive_title">ԿոնՖիդենցիալ տեղեկատվության</string>
<string name="debug_debuglog_screen_session_path_label">Սեսիայի Մարութ</string>
<string name="debug_debuglog_screen_subtitle">Արտահանել վրիպազերծման տեղեկություններ անսարքությունների հայտնաբերման համար</string>
<string name="debug_debuglog_screen_sensitive_title">Զգայուն տեղեկություն</string>
<string name="debug_debuglog_screen_session_path_label">Սեանսի ուղի</string>
<string name="debug_debuglog_screen_log_files_label">Մատյանի Ֆայլեր</string>
<plurals name="debug_debuglog_screen_log_files_ready">
<item quantity="one">%1$d Ֆայլ պատրաստուաց ե (%2$s)</item>
@@ -321,10 +321,10 @@
<string name="support_debuglog_sessions_desc">Կարավարել գրանցված Կարգաբերման մատյանի սեսիանները</string>
<string name="support_debuglog_sessions_empty">Կարգաբերման սեսիաններ չկա</string>
<string name="support_debuglog_session_recording">Գրանցություն…</string>
<string name="support_debuglog_session_compressing">սեղադրություն</string>
<string name="support_debuglog_session_compressing">Սեղմվում է</string>
<string name="support_debuglog_session_delete_title">Հեռացնել սեսիան?</string>
<string name="support_debuglog_session_delete_message">Այս Կարգաբերման սեսիանը կինաբար հեռացվել կլի:</string>
<string name="support_debuglog_clear_all_message">Բոլոր Կարգաբերման սեսիանները կինաբար հեռացվել կլին:</string>
<string name="support_debuglog_session_delete_message">Այս վրիպազերծման սեանսը կջնջվի անդառնալիորեն։</string>
<string name="support_debuglog_clear_all_message">Բոլոր վրիպազերծման սեանսները կջնջվեն անդառնալիորեն։</string>
<string name="support_debuglog_failed_empty_log">Սխալ: մատյանի հալը դատարկ է</string>
<string name="support_debuglog_failed_missing_log">Սխալ: մատյանի հալը բացակայում է</string>
<string name="support_debuglog_failed_corrupt_zip">Սխալ: zip հալը վնասվածված է</string>
@@ -332,15 +332,15 @@
<!-- Contact form -->
<string name="support_contact_label">Կապակցություն Մշակողի հետ</string>
<string name="support_contact_desc">Լրացնել ձև մշակողին e-mail ուղարկելու համար:</string>
<string name="support_contact_welcome">Ես կարդիք բոլոր նամակները կարդաթում ենք և կարկանությամբ պատասխանում, բայց միակայի աշխատում, կարոգ է քիչ ժամանակ պահանջվել: շնորհակալություն:</string>
<string name="support_contact_footer">ձևր նամակը կուգկվի ելեկտրոնային նամակով: Բլկի և կարգավորության օգնությունները կցվում են ինքնաբար: Կարող եք կցել խաղագրություններ կամ վիդեո: Նամակի չափերը կլինեն անգլերեն, որպեսզի մշակողին կարող լինի հասկանալ:</string>
<string name="support_contact_welcome">Ես ինքս կարդում եմ յուրաքանչյուր հաղորդագրություն և ամեն ինչ անում եմ պատասխանելու համար, բայց քանի որ մենակ եմ աշխատում, կարող է մի փոքր ժամանակ պահանջվել։ Շնորհակալություն ձեր համբերության համար։</string>
<string name="support_contact_footer">Ձեր հաղորդագրությունը կուղարկվի էլ. փոստով։ Սարքի և կարգաբերման տեղեկությունները կցվում են ավտոմատ կերպով։ Կարող եք նաև կցել սքրինշոթեր կամ տեսանյութ էլ. նամակին։ Էլ. նամակի որոշ մասեր կլինեն անգլերենով, որպեսզի մշակողը կարողանա հեշտությամբ հասկանալ։</string>
<string name="support_contact_category_label">Կատեգորիա</string>
<string name="support_contact_category_question_label">Հարցում</string>
<string name="support_contact_category_feature_label">Ֆունկլության հայց</string>
<string name="support_contact_category_feature_label">Հնարավորության հայց</string>
<string name="support_contact_category_bug_label">Սխալի զեկություն</string>
<string name="support_contact_description_label">Նկարագրություն</string>
<string name="support_contact_description_question_hint">Նկարագրեք ձևր հարցումը մանրամասնորեն: Խնդրում կերկկիսել կոնկրետ:</string>
<string name="support_contact_description_feature_hint">Նկարագրեք այն հնարավորությունը, որին կլինեիք տեսնել: Խնդրում կերկկիսել կոնկրետ:</string>
<string name="support_contact_description_question_hint">Մանրամասն նկարագրեք ձեր հարցը։ Խնդրում ենք կոնկրետ լինել։</string>
<string name="support_contact_description_feature_hint">Նկարագրեք հնարավորությունը, որը ցանկանում եք տեսնել։ Խնդրում ենք կոնկրետ լինել։</string>
<string name="support_contact_description_bug_hint">Նկարագրեք ինչ կատարվել ե կերպես վերարտադրել խնդիրը. Խնդրում կոնկրետ կերկիսել:</string>
<string name="support_contact_expected_label">Սպասվող վարքություն</string>
<string name="support_contact_expected_hint">Նկարագրեք ինչ կարկանում էիք կատարվի:</string>
+1 -1
View File
@@ -123,7 +123,7 @@
<string name="help_translate_label">Terjemahan</string>
<string name="help_translate_description">Bantu terjemahkan aplikasi ini ke bahasa favorit Anda.</string>
<string name="translators_thanks_title">Penerjemah</string>
<string name="translators_thanks_description">gelapkan</string>
<string name="translators_thanks_description">darken</string>
<string name="widget_description">Widget yang menampilkan status perangkat terakhir yang diketahui.</string>
<string name="widget_config_screen_title">Konfigurasi Widget</string>
<string name="widget_configuration_title">Pilih Perangkat</string>
+20 -20
View File
@@ -297,13 +297,13 @@
<string name="general_keep_editing_action">Halda áfram að breyta</string>
<!-- Short recording warning -->
<string name="debug_debuglog_short_recording_title">Upptökun of stutt</string>
<string name="debug_debuglog_short_recording_message">Kembingarskrá þá að ná villunni þegar hana geisar. Haltu áfram með upptöku, endurskapa vandamalæ, og stoppau upptökuna.</string>
<string name="debug_debuglog_short_recording_message">Villuloki þarf að taka upp vandamálið meðan það gerist. Haltu áfram að taka upp, endurskapa vandamálið og stöðvaðu síðan upptökuna.</string>
<string name="debug_debuglog_short_recording_continue">Halda áfram upptöku</string>
<string name="debug_debuglog_short_recording_stop">Stoppa úvis</string>
<!-- Recorder screen -->
<string name="debug_debuglog_screen_title">Kembingarskrá</string>
<string name="debug_debuglog_screen_subtitle">Flytja út kembingarupplýsingard til bilanagreininga</string>
<string name="debug_debuglog_screen_sensitive_title">Vikvæmar upplýsingarr</string>
<string name="debug_debuglog_screen_subtitle">Flytja út villuleitarupplýsingar til úrræðaleitar</string>
<string name="debug_debuglog_screen_sensitive_title">Viðkvæmar upplýsingar</string>
<string name="debug_debuglog_screen_session_path_label">Slóð lotunnar</string>
<string name="debug_debuglog_screen_log_files_label">Skráarskrár</string>
<plurals name="debug_debuglog_screen_log_files_ready">
@@ -317,33 +317,33 @@
<item quantity="other">%1$d kembingarskrár (%2$s)</item>
</plurals>
<string name="support_debuglog_clear_action">Hreinsa geymdar kembingarskrár</string>
<string name="support_debuglog_sessions_label">Kembingarltur</string>
<string name="support_debuglog_sessions_label">Villuleitarlotur</string>
<string name="support_debuglog_sessions_desc">Sjá um skráðar kembingarlotu-skrár</string>
<string name="support_debuglog_sessions_empty">Engar kembingarltur</string>
<string name="support_debuglog_sessions_empty">Engar villuleitarlotur</string>
<string name="support_debuglog_session_recording">Tekur upp…</string>
<string name="support_debuglog_session_compressing">Er að þjitta</string>
<string name="support_debuglog_session_compressing">Þjappað saman</string>
<string name="support_debuglog_session_delete_title">Eyða lotu?</string>
<string name="support_debuglog_session_delete_message">Þessari kembingarlotu verður varðlegt eyðt.</string>
<string name="support_debuglog_clear_all_message">Allar kembingarlotur verða varðlegt eyðar.</string>
<string name="support_debuglog_session_delete_message">Þessari villuleitarlotu verður eytt til frambúðar.</string>
<string name="support_debuglog_clear_all_message">Öllum villuleitarlotum verður eytt til frambúðar.</string>
<string name="support_debuglog_failed_empty_log">Mistókst: skráarskrá er tóm</string>
<string name="support_debuglog_failed_missing_log">Mistókst: skráarskrá vantar</string>
<string name="support_debuglog_failed_corrupt_zip">Mistókst: zip-skrá er skemmd</string>
<string name="support_debuglog_failed_zip_failed">Mistókst: þjittaðning mistókst</string>
<string name="support_debuglog_failed_zip_failed">Mistókst: þjöppun mistókst</string>
<!-- Contact form -->
<string name="support_contact_label">Hafa samband við þróunarmann</string>
<string name="support_contact_desc">Fylltu út eyðublað til að senda þróunarmanni tölvupóst.</string>
<string name="support_contact_welcome">Ég les hvert skilaboð sjálfur og geri mitt besta til að svara, en þegar ég vinn á þessu einn kann það taka smá tíma. Pakk þér fyrir þólinmæðina.</string>
<string name="support_contact_footer">Skilaboðin þín verða send með tölvupósti. Tæki- og uppsetningarupplýsingarr eru héfðar sjálfkrafa. Hægt er að hæfa skýumyðnd eða mófundarmynd við tölvupóstinn. Sumir hlutar tölvupóstsins verða á ensku svo þróunarmurinn geti að skilja hann.</string>
<string name="support_contact_welcome">Ég les hvert einasta skilaboð sjálfur og geri mitt besta til að svara, en þar sem ég vinn þessu einn, getur það tekið smá tíma. Takk fyrir þolinmæðina.</string>
<string name="support_contact_footer">Skilaboðin þín verða send með tölvupósti. Upplýsingar um tæki og uppsetningu eru hengdar við sjálfkrafa. Þú getur hengt við skjámyndir eða myndband í tölvupóstinn. Sumir hlutar tölvupóstsins verða á ensku svo þróunaraðilinn geti auðveldlega skilið þau.</string>
<string name="support_contact_category_label">Flokkur</string>
<string name="support_contact_category_question_label">Spurning</string>
<string name="support_contact_category_feature_label">Bægi um eiginleika</string>
<string name="support_contact_category_bug_label">Villutilkyðning</string>
<string name="support_contact_category_feature_label">Beiðni um eiginleika</string>
<string name="support_contact_category_bug_label">Villutilkynning</string>
<string name="support_contact_description_label">Lýsing</string>
<string name="support_contact_description_question_hint">Settu spurninguna þína ítarlega niður. Vinsamlegast verðu áhertni.</string>
<string name="support_contact_description_feature_hint">Lýstu þver eiginleikann sem þú vilt sjá. Vinsamlegast verðu áhertni.</string>
<string name="support_contact_description_bug_hint">Lýstu þver þvert sem gerst og hvernig hægt er að endurskapa vandamalæ. Vinsamlegast verðu áhertni.</string>
<string name="support_contact_expected_label">Vænt héguð</string>
<string name="support_contact_expected_hint">Lýstu þver þver sem þú bjóst við að geriðist.</string>
<string name="support_contact_description_question_hint">Lýstu spurningunni þinni í smáatriðum. Vinsamlegast vertu nákvæmur.</string>
<string name="support_contact_description_feature_hint">Lýstu þeim eiginleika sem þú vilt sjá. Vinsamlegast vertu nákvæmur.</string>
<string name="support_contact_description_bug_hint">Lýstu því sem gerðist og hvernig á að endurskapa vandamálið. Vinsamlegast vertu nákvæmur.</string>
<string name="support_contact_expected_label">Vænt hegðun</string>
<string name="support_contact_expected_hint">Lýstu því sem þú bjóst við að gerðist.</string>
<plurals name="support_contact_word_count">
<item quantity="one">%1$d / %2$d orð</item>
<item quantity="other">%1$d / %2$d orð</item>
@@ -351,11 +351,11 @@
<string name="support_contact_debuglog_label">Villuleitarskrá</string>
<string name="support_contact_debuglog_picker_hint">Hæfa fyrirliggjandi kembingarskrá, eða taka upp nýja.</string>
<string name="support_contact_debuglog_picker_empty">Engar kembingarskrár fundust. Taktu upp eina fyrst.</string>
<string name="support_contact_debuglog_zip_error">Tókst ekki að þjitta kembingarskrá</string>
<string name="support_contact_debuglog_zip_error">Mistókst að þjappa saman villuloka</string>
<string name="support_contact_send_action">Opna tölvupóstforrit</string>
<string name="support_contact_no_email_app">Ekkert tölvupóstforrit fundiðst á þessum tæki.</string>
<string name="support_contact_debuglog_delete_title">Eyða kembingarskrá?</string>
<string name="support_contact_debuglog_delete_message">Þessari kembingarskrá verður varðlegt eyðt.</string>
<string name="support_contact_debuglog_delete_message">Þessum villuloka verður eytt til frambúðar.</string>
<!-- Post-share confirmation -->
<string name="support_debuglog_sent_title">Fannst skráin?</string>
<string name="support_debuglog_sent_message">Var villuleitarskráin send? Skráin verður eytt.</string>
+2 -4
View File
@@ -249,8 +249,7 @@
<string name="profiles_save_action">프로필 저장</string>
<string name="profiles_drag_handle_description">드래그하여 순서 변경</string>
<string name="profiles_delete_title">프로필 삭제</string>
<string name="profiles_delete_message">프로필을 삭제하시겠습니까?
이 작업은 취소할 수 없습니다.</string>
<string name="profiles_delete_message">프로필을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.</string>
<string name="profiles_delete_action">삭제</string>
<string name="profiles_basic_info_title">기기 정보</string>
<string name="profiles_basic_info_description">기기의 이름, 모델 및 선택적 블루투스 페어링을 설정하세요.</string>
@@ -268,8 +267,7 @@
<string name="profiles_key_status_not_set">설정되지 않음</string>
<string name="profiles_signal_quality_min_label">끄기</string>
<string name="profiles_signal_quality_max_label">엄격</string>
<string name="profiles_priority_hint">프로필 순서는 우선순위를 결정합니다. 프로필을 드래그하여 순서를 변경하세요.
리스트의 위에 있을수록 여러 장치가 일치할때 우선순위가 높습니다.</string>
<string name="profiles_priority_hint">프로필 순서는 우선순위를 결정합니다. 프로필을 드래그하여 순서를 변경하세요. 리스트의 위에 있을수록 여러 장치가 일치할때 우선순위가 높습니다.</string>
<!-- Theme Mode -->
<string name="ui_theme_mode_label">테마 모드</string>
<string name="ui_theme_mode_system_label">기기 테마 사용</string>
+1 -1
View File
@@ -86,7 +86,7 @@
<string name="debug_debuglog_size_compressed_label">ຂະໜາດທີ່ບີບອັດ</string>
<string name="debug_notification_channel_label">ການແຈ້ງເຕືອນດີບັກ</string>
<string name="debug_debuglog_file_label">ໄຟລ໌ບັນທຶກທີ່ບັນທຶກໄວ້</string>
<string name="debug_debuglog_recording_progress">ລັງບັນທຶກບັນທຶກດີບັກ</string>
<string name="debug_debuglog_recording_progress">ໍາລັງບັນທຶກບັນທຶກດີບັກ</string>
<string name="debug_debuglog_record_action">ບັນທຶກບັນທຶກດີບັກ</string>
<string name="debug_debuglog_stop_action">ຢຸດການບັນທຶກ</string>
<string name="debug_debuglog_sensitive_information_message">ໄຟລ໌ທີ່ສ້າງຂຶ້ນມີຂໍ້ມູນທີ່ละเอียดອ່ອນ (ຕົວຢ່າງ: ລາຍລະອຽດອຸປະກອນ Bluetooth). ແບ່ງປັນມັນກັບຝ່າຍທີ່ເຊື່ອຖືໄດ້ເທົ່ານັ້ນ.</string>
+2 -2
View File
@@ -32,8 +32,8 @@
<string name="upgrade_screen_iap_action">Eenmalig kopen</string>
<string name="upgrade_screen_iap_action_hint">Eenmalige aankoop: %s</string>
<string name="upgrade_screen_restore_purchase_action">Herstel aankoop</string>
<string name="upgrade_screen_restore_purchase_message">Geen aankopen gevonden. Gebruikt u de juiste rekening?</string>
<string name="upgrade_screen_restore_troubleshooting_msg">Als je recent hebt gekocht, kan het even duren voordat Google Play synchroniseert.</string>
<string name="upgrade_screen_restore_purchase_message">Geen aankopen gevonden. Gebruikt je wel het juiste account?</string>
<string name="upgrade_screen_restore_troubleshooting_msg">Als je de app onlangs hebt gekocht, kan het even duren voordat Google Play de gegevens synchroniseert.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Probeer het over een paar minuten opnieuw als je aankoop niet verschijnt.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Zorg ervoor dat je bent aangemeld met hetzelfde Google-account dat is gebruikt voor de aankoop.</string>
<string name="settings_monitor_mode_label">Monitor modus</string>
+15 -15
View File
@@ -129,7 +129,7 @@
<string name="widget_configuration_title">උපාංගය තෝරන්න</string>
<string name="widget_configuration_description">මෙම විජට් එක කුමන උපාංග පැතිකඩ පෙන්විය යුතුද යන්න තෝරන්න.</string>
<string name="widget_no_data_label">දත්ත නොමැත</string>
<string name="widget_error_loading_label">විජට් පළ්ක් ගැනීම෪ විපත්තියක</string>
<string name="widget_error_loading_label">විජට් පූරණය කිරීමේ දෝෂයක</string>
<string name="widget_config_appearance_label">පෙනුම</string>
<string name="widget_config_preset_label">පෙර සැකසුම්</string>
<string name="widget_config_background_color_label">පසුබිම් වර්ණ</string>
@@ -330,30 +330,30 @@
<string name="support_debuglog_failed_corrupt_zip">අසහය වී: zip ගොනුව විනාශ වී</string>
<string name="support_debuglog_failed_zip_failed">අසහය වී: සංකලනය අසහය වී</string>
<!-- Contact form -->
<string name="support_contact_label">සංවර්ධකයා සමග හය්ක්‍ළ ගන්න</string>
<string name="support_contact_desc">සංවර්ධකයාට ඉකිමෙල් දෙනුම් යළ්ලීමට ප්‍රශ්නාවලියක් පිරළන්න.</string>
<string name="support_contact_welcome">මම හට හට් ගිණුම් එකක්‍මක් බවට ප්‍රතික්‍‍ෂේප කරන අතර, මම එකක්‍ම් ගිණුම් කරනබින් පිිතුරුයක් ගත හැක. ඉවත් එකක්‍මක් අවෛ එය්ක කරනබින් ඉවත් ගත හැකිය.</string>
<string name="support_contact_footer">ඔබ෪ය සදහන ඉකිමෙල් යන්නේ ඇති යළ්ලනු ලැබේ. උපකරණ සහ සූදානම් හය ස්වයංක්‍රීයව ගල කරයි. ඉකිමෙල්යට ස්ක්‍රීන්ෂොට්ඩියොයක් සංලග් කර ගත හැක. ඉකිමෙල්ය෪් කලක්ටස් ඉංග්‍රීසි යයින් ිය හෙත් සංවර්ධකයාට සහලසින් ඇතික් ගත හැක.</string>
<string name="support_contact_label">සංවර්ධකයා අමතන්න</string>
<string name="support_contact_desc">සංවර්ධකයාට ඊමේල් යැවීමට පෝරමයක් පුරවන්න.</string>
<string name="support_contact_welcome">මම සෑම පණිවිඩයක්ම තනිව කියවා හැකි උපරිමයෙන් පිිතුරු දීමට උත්සාහ කරමි, නමුත් මෙය තනිවම කරන නිසා, ටිකක් කාලය ගත විය හැකිය. ඔබේ ඉවසීමට ස්තූතියි.</string>
<string name="support_contact_footer">ඔබේ පණිවිඩය ඊමේල් හරහා යවනු ලැබේ. උපාංග සහ සැකසීම් තොරතුරු ස්වයංක්‍රීයව අමුණා ඇත. ඔබට ඊමේල් වලට තිරදර්ශනඩියෝ අමුණා ගත හැක. ඊමේල් හි සමහරටස් ඉංග්‍රීසියෙන් ියා ඇති අතර, සංවර්ධකයාට පහසුවෙන් තේරුම් ගත හැකිය.</string>
<string name="support_contact_category_label">කත්තාව</string>
<string name="support_contact_category_question_label">ප්‍රශ්නය</string>
<string name="support_contact_category_feature_label">ළන් ඓදිරීමේ</string>
<string name="support_contact_category_feature_label">ලක්ෂණ ඉල්ලීම</string>
<string name="support_contact_category_bug_label">දෝෂ වාර්තාව</string>
<string name="support_contact_description_label">විවරණය</string>
<string name="support_contact_description_question_hint">ඔබ෪ය ප්‍රශ්නය විස්තරව විවරණ කරන්න. කරුණාකර වන්න.</string>
<string name="support_contact_description_feature_hint">ඔබ දකින් ගන්නා ළන් විශේෂාංගය විවරණ කරන්න. කරුණාකර වන්න.</string>
<string name="support_contact_description_bug_hint">සිදු වීයද සහ ගැටළුව නැවත ඇති කරන්නේ කෙසේද යන්න විවරණ කරන්න. කරුණාකර වන්න.</string>
<string name="support_contact_expected_label">ප෍දු කරනු ලැබීයක්</string>
<string name="support_contact_expected_hint">සිදු වේයේ කෙයි යන්න ඔබ යන්න ඇති කලා විවරණ කරන්න.</string>
<string name="support_contact_description_question_hint">ඔබ ප්‍රශ්නය විස්තරාත්මකව විස්තර කරන්න. කරුණාකර නිශ්චිත වන්න.</string>
<string name="support_contact_description_feature_hint">ඔබ දකිනු කැමති ලක්ෂණය විස්තර කරන්න. කරුණාකර නිශ්චිත වන්න.</string>
<string name="support_contact_description_bug_hint">කුමක් සිදු වූයේ දැයි සහ ගැටළුව නැවත නිර්මාණය කරන්නේ කෙසේදැයි විස්තර කරන්න. කරුණාකර නිශ්චිත වන්න.</string>
<string name="support_contact_expected_label">අපේක්ෂිත හැසිරීම</string>
<string name="support_contact_expected_hint">ඔබ සිදු වනු අපේක්ෂා කළ දෙය විස්තර කරන්න.</string>
<plurals name="support_contact_word_count">
<item quantity="one">%1$d / %2$d වට පදය</item>
<item quantity="other">%1$d / %2$d වට් පද</item>
</plurals>
<string name="support_contact_debuglog_label">දෝෂහරණ ලොගය</string>
<string name="support_contact_debuglog_picker_hint">ළ්කාලිය දෝෂහරණ ලොගයක් සංලග් කරන්න, හො අලුත් කරනු ලදියක් රෙකර්ඩ් කරන්න.</string>
<string name="support_contact_debuglog_picker_empty">දෝෂහරණ ලොග් හිසි නේ. පළ්වේන් එකක් රෙකර්ඩ් කරන්න.</string>
<string name="support_contact_debuglog_picker_hint">වතින නිදොස් ලොගයක් අමුණන්න, නැතිනම් නව එකක් පටිගත කරන්න.</string>
<string name="support_contact_debuglog_picker_empty">නිදොස් ලොග කිසිවක් හමු නොවිණි. පළමුව එකක් පටිගත කරන්න.</string>
<string name="support_contact_debuglog_zip_error">දෝෂහරණ ලොගය සංකලනය අසහය වී</string>
<string name="support_contact_send_action">ඉකිමෙල් යෙදුම ඇහල්න්න</string>
<string name="support_contact_no_email_app">මෙම උපකරණයේ ඉකිමෙල් යෙදුමක් පළ්ක් නැත.</string>
<string name="support_contact_send_action">ඊමේල් යෙදුම විවෘත කරන්න</string>
<string name="support_contact_no_email_app">මෙම උපාංගයේ ඊමේල් යෙදුමක් හමු නොවිණි.</string>
<string name="support_contact_debuglog_delete_title">දෝෂහරණ ලොගය දුරු කරන්නද?</string>
<string name="support_contact_debuglog_delete_message">මෙම දෝෂහරණ ලොගය හෙළලා දුරු කරනු ලැබේ.</string>
<!-- Post-share confirmation -->
+18
View File
@@ -308,8 +308,20 @@
<string name="debug_debuglog_screen_sensitive_title">Citlivé informácie</string>
<string name="debug_debuglog_screen_session_path_label">Cesta relácie</string>
<string name="debug_debuglog_screen_log_files_label">Log súbory</string>
<plurals name="debug_debuglog_screen_log_files_ready">
<item quantity="one">%1$d súbor je pripravený (%2$s)</item>
<item quantity="few">%1$d súbory sú pripravené (%2$s)</item>
<item quantity="many">%1$d súborov je pripravených (%2$s)</item>
<item quantity="other">%1$d súborov je pripravených (%2$s)</item>
</plurals>
<string name="debug_debuglog_screen_discard_action">Vymazať</string>
<!-- Log management -->
<plurals name="support_debuglog_folder_summary">
<item quantity="one">%1$d denník ladenia (%2$s)</item>
<item quantity="few">%1$d denníky ladenia (%2$s)</item>
<item quantity="many">%1$d denníkov ladenia (%2$s)</item>
<item quantity="other">%1$d denníkov ladenia (%2$s)</item>
</plurals>
<string name="support_debuglog_clear_action">Vymazať uložené ladíace záznamy</string>
<string name="support_debuglog_sessions_label">Ladíace relacie</string>
<string name="support_debuglog_sessions_desc">Spravovať nahrané relacie ladíacich záznamov</string>
@@ -338,6 +350,12 @@
<string name="support_contact_description_bug_hint">Popíšte, čo sa stalo a ako replikovať problém. Prosím, buďte špecifický.</string>
<string name="support_contact_expected_label">Očakávané správanie</string>
<string name="support_contact_expected_hint">Popíšte, čo ste očakávali, že sa stane.</string>
<plurals name="support_contact_word_count">
<item quantity="one">%1$d / %2$d slovo</item>
<item quantity="few">%1$d / %2$d slová</item>
<item quantity="many">%1$d / %2$d slov</item>
<item quantity="other">%1$d / %2$d slov</item>
</plurals>
<string name="support_contact_debuglog_label">Ladiaci log</string>
<string name="support_contact_debuglog_picker_hint">Priložte existujúci ladíaci záznam alebo nahrajte nový.</string>
<string name="support_contact_debuglog_picker_empty">Žiadne ladíace záznamy sa nenašli. Najskôr nahraje jeden.</string>
+4 -4
View File
@@ -309,10 +309,10 @@
<string name="debug_debuglog_screen_session_path_label">Pot seje</string>
<string name="debug_debuglog_screen_log_files_label">Dnevniške datoteke</string>
<plurals name="debug_debuglog_screen_log_files_ready">
<item quantity="one">Datoteka je pripravljena (%2$s).</item>
<item quantity="two">Datoteki sta pripravljeni (%2$s).</item>
<item quantity="few">%1$d datoteke so pripravljene (%2$s).</item>
<item quantity="other">%1$d datotek je pripravljenih (%2$s).</item>
<item quantity="one">%1$d datoteka je pripravljena (%2$s)</item>
<item quantity="two">%1$d datoteki sta pripravljeni (%2$s)</item>
<item quantity="few">%1$d datoteke so pripravljene (%2$s)</item>
<item quantity="other">%1$d datotek je pripravljenih (%2$s)</item>
</plurals>
<string name="debug_debuglog_screen_discard_action">Izbriši</string>
<!-- Log management -->
+1 -1
View File
@@ -333,7 +333,7 @@
<string name="support_contact_label">Kontakto zhvilluesin</string>
<string name="support_contact_desc">Plotëso një formular për të dërguar zhvilluesit një email.</string>
<string name="support_contact_welcome">I lexoj çdo mesazh vetë dhe bëj çmos të përgjigjem, por meqë punoj vetëm në këtë, mund të duhet pak kohë. Faleminderit për durim.</string>
<string name="support_contact_footer">Mesazhi juaj do të dërgohet me email. Informacioni i pajisjes dhe i konfigurimit bash këngjitet automatikisht. Mund të bash këngjitni pamje ekrani ose video në email. Disa pjesë të emailit do të jenë në anglisht që zhvilluesi ta kuptojë le htë.</string>
<string name="support_contact_footer">Mesazhi juaj do të dërgohet me email. Informacioni i pajisjes dhe i konfigurimit bashkëngjitet automatikisht. Mund të bashkëngjitni pamje ekrani ose një video në email. Disa pjesë të emailit do të jenë në anglisht, në mënyrë që zhvilluesi ta kuptojë lehtë.</string>
<string name="support_contact_category_label">Kategoria</string>
<string name="support_contact_category_question_label">Pyetje</string>
<string name="support_contact_category_feature_label">Kërkgesë për veçori</string>
+3 -3
View File
@@ -336,9 +336,9 @@
<string name="support_contact_category_feature_label">功能請求</string>
<string name="support_contact_category_bug_label">錯誤回報</string>
<string name="support_contact_description_label">說明</string>
<string name="support_contact_description_question_hint">詳細描述您的問題,請量具體說明。</string>
<string name="support_contact_description_feature_hint">描述您希望看到的功能,請量具體說明。</string>
<string name="support_contact_description_bug_hint">描述發生了什麼以及如何復現問題,請量具體說明。</string>
<string name="support_contact_description_question_hint">詳細描述您的問題,請量具體說明。</string>
<string name="support_contact_description_feature_hint">描述您希望看到的功能,請量具體說明。</string>
<string name="support_contact_description_bug_hint">描述發生了什麼以及如何重現此問題,請量具體說明。</string>
<string name="support_contact_expected_label">預期行為</string>
<string name="support_contact_expected_hint">描述您預期發生的事情。</string>
<plurals name="support_contact_word_count">
+1 -1
View File
@@ -325,7 +325,7 @@
<string name="support_debuglog_session_delete_title">Susa iseshini?</string>
<string name="support_debuglog_session_delete_message">Le seshini yokususa iziphazamiso izosuswwa ngokuphelele.</string>
<string name="support_debuglog_clear_all_message">Zonke izinseshini zokususa iziphazamiso zizosuswwa ngokuphelele.</string>
<string name="support_debuglog_failed_empty_log">Kwehlulekile: ifayela lerekhodi likhona</string>
<string name="support_debuglog_failed_empty_log">Yehlulekile: ifayela le-log liyize</string>
<string name="support_debuglog_failed_missing_log">Kwehlulekile: ifayela lerekhodi alikhona</string>
<string name="support_debuglog_failed_corrupt_zip">Kwehlulekile: ifayela le-zip lonakele</string>
<string name="support_debuglog_failed_zip_failed">Kwehlulekile: ukucindezela kwehlulekile</string>
+1
View File
@@ -351,6 +351,7 @@
<item quantity="other">%1$d files ready (%2$s)</item>
</plurals>
<string name="debug_debuglog_screen_discard_action">Delete</string>
<string name="debug_debuglog_screen_keep_action">Keep</string>
<!-- Log management -->
<plurals name="support_debuglog_folder_summary">
@@ -0,0 +1,76 @@
package eu.darken.capod.common.bluetooth
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
/**
* Tests backward compatibility with legacy Moshi-serialized BleScanResult cache files.
*/
class BleScanResultSerializationTest : BaseTest() {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
@Test
fun `legacy Moshi cache JSON decodes correctly`() {
// This matches the format PodDeviceCache previously wrote via Moshi
val legacyJson = """{"receivedAt":1709553600000,"address":"AA:BB:CC:DD:EE:FF","rssi":-55,"generatedAtNanos":123456789,"manufacturerSpecificData":{"76":"AQID"}}"""
val result = json.decodeFromString<BleScanResult>(legacyJson)
result.receivedAt shouldBe Instant.ofEpochMilli(1709553600000)
result.address shouldBe "AA:BB:CC:DD:EE:FF"
result.rssi shouldBe -55
result.generatedAtNanos shouldBe 123456789L
result.manufacturerSpecificData.size shouldBe 1
result.manufacturerSpecificData[76]!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
}
@Test
fun `round-trip preserves all fields`() {
val original = BleScanResult(
receivedAt = Instant.ofEpochMilli(1709553600000),
address = "11:22:33:44:55:66",
rssi = -42,
generatedAtNanos = 987654321L,
manufacturerSpecificData = mapOf(
76 to byteArrayOf(0x01, 0x02, 0x03),
77 to byteArrayOf(0xAA.toByte(), 0xBB.toByte()),
)
)
val encoded = json.encodeToString(BleScanResult.serializer(), original)
val decoded = json.decodeFromString<BleScanResult>(encoded)
decoded.receivedAt shouldBe original.receivedAt
decoded.address shouldBe original.address
decoded.rssi shouldBe original.rssi
decoded.generatedAtNanos shouldBe original.generatedAtNanos
decoded.manufacturerSpecificData.size shouldBe original.manufacturerSpecificData.size
original.manufacturerSpecificData.forEach { (key, value) ->
decoded.manufacturerSpecificData[key]!!.toList() shouldBe value.toList()
}
}
@Test
fun `empty manufacturer data round-trips`() {
val original = BleScanResult(
receivedAt = Instant.EPOCH,
address = "00:00:00:00:00:00",
rssi = 0,
generatedAtNanos = 0L,
manufacturerSpecificData = emptyMap()
)
val encoded = json.encodeToString(BleScanResult.serializer(), original)
val decoded = json.decodeFromString<BleScanResult>(encoded)
decoded.manufacturerSpecificData shouldBe emptyMap()
}
}
@@ -1,6 +1,5 @@
package eu.darken.capod.common.datastore
import com.squareup.moshi.Json as MoshiJson
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
@@ -11,16 +10,15 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesContainer
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerialName
import io.kotest.matchers.string.shouldContain
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* Tests that verify @SerialName values match @Json(name=...) values,
* ensuring Moshi-serialized SharedPreferences data can be read by kotlinx-serialization
* after the DataStore migration.
* Tests that verify kotlinx-serialization produces the expected wire format
* and can decode legacy JSON strings (originally written by Moshi).
*/
class DataStoreMigrationCompatTest : BaseTest() {
@@ -28,106 +26,145 @@ class DataStoreMigrationCompatTest : BaseTest() {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
/**
* For each enum with both @SerialName and @Json annotations,
* verify they produce the same string. This catches typos in @SerialName values.
*/
private inline fun <reified T : Enum<T>> verifyEnumSerialNameParity() {
val enumClass = T::class.java
for (constant in enumClass.enumConstants!!) {
val field = enumClass.getField(constant.name)
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
val serialNameAnnotation = field.getAnnotation(SerialName::class.java)
@Test
fun `ThemeMode encodes to canonical string`() {
json.encodeToString(serializer<ThemeMode>(), ThemeMode.DARK) shouldBe "\"theme.mode.dark\""
}
if (moshiAnnotation != null && serialNameAnnotation != null) {
serialNameAnnotation.value shouldBe moshiAnnotation.name
}
@Test
fun `ThemeMode legacy string decodes correctly`() {
val legacyOutput = "\"theme.mode.dark\""
json.decodeFromString(serializer<ThemeMode>(), legacyOutput) shouldBe ThemeMode.DARK
}
@Test
fun `ThemeMode round-trip`() {
ThemeMode.entries.forEach { mode ->
val encoded = json.encodeToString(serializer<ThemeMode>(), mode)
json.decodeFromString(serializer<ThemeMode>(), encoded) shouldBe mode
}
}
@Test
fun `SerialName matches Json name - ThemeMode`() = verifyEnumSerialNameParity<ThemeMode>()
@Test
fun `SerialName matches Json name - ThemeStyle`() = verifyEnumSerialNameParity<ThemeStyle>()
@Test
fun `SerialName matches Json name - ThemeColor`() = verifyEnumSerialNameParity<ThemeColor>()
@Test
fun `SerialName matches Json name - MonitorMode`() = verifyEnumSerialNameParity<MonitorMode>()
@Test
fun `SerialName matches Json name - ScannerMode`() = verifyEnumSerialNameParity<ScannerMode>()
@Test
fun `SerialName matches Json name - AutoConnectCondition`() = verifyEnumSerialNameParity<AutoConnectCondition>()
@Test
fun `SerialName matches Json name - PodDevice Model`() = verifyEnumSerialNameParity<PodDevice.Model>()
@Test
fun `Moshi-serialized ThemeMode string is readable by kotlinx`() {
// Moshi stores enums as JSON strings like: "theme.mode.dark"
val moshiOutput = "\"theme.mode.dark\""
val result = json.decodeFromString(serializer<ThemeMode>(), moshiOutput)
result shouldBe ThemeMode.DARK
fun `ScannerMode encodes to canonical string`() {
json.encodeToString(serializer<ScannerMode>(), ScannerMode.BALANCED) shouldBe "\"scanner.mode.balanced\""
}
@Test
fun `Moshi-serialized ScannerMode string is readable by kotlinx`() {
val moshiOutput = "\"scanner.mode.balanced\""
val result = json.decodeFromString(serializer<ScannerMode>(), moshiOutput)
result shouldBe ScannerMode.BALANCED
fun `ScannerMode legacy string decodes correctly`() {
val legacyOutput = "\"scanner.mode.balanced\""
json.decodeFromString(serializer<ScannerMode>(), legacyOutput) shouldBe ScannerMode.BALANCED
}
@Test
fun `Moshi-serialized MonitorMode string is readable by kotlinx`() {
val moshiOutput = "\"monitor.mode.automatic\""
val result = json.decodeFromString(serializer<MonitorMode>(), moshiOutput)
result shouldBe MonitorMode.AUTOMATIC
fun `ScannerMode round-trip`() {
ScannerMode.entries.forEach { mode ->
val encoded = json.encodeToString(serializer<ScannerMode>(), mode)
json.decodeFromString(serializer<ScannerMode>(), encoded) shouldBe mode
}
}
@Test
fun `all PodDevice Model values can be decoded from Moshi format`() {
fun `MonitorMode encodes to canonical string`() {
json.encodeToString(serializer<MonitorMode>(), MonitorMode.AUTOMATIC) shouldBe "\"monitor.mode.automatic\""
}
@Test
fun `MonitorMode legacy string decodes correctly`() {
val legacyOutput = "\"monitor.mode.automatic\""
json.decodeFromString(serializer<MonitorMode>(), legacyOutput) shouldBe MonitorMode.AUTOMATIC
}
@Test
fun `MonitorMode round-trip`() {
MonitorMode.entries.forEach { mode ->
val encoded = json.encodeToString(serializer<MonitorMode>(), mode)
json.decodeFromString(serializer<MonitorMode>(), encoded) shouldBe mode
}
}
@Test
fun `ThemeStyle encodes to canonical string`() {
json.encodeToString(serializer<ThemeStyle>(), ThemeStyle.MATERIAL_YOU) shouldBe "\"theme.style.materialyou\""
}
@Test
fun `ThemeStyle legacy string decodes correctly`() {
val legacyOutput = "\"theme.style.materialyou\""
json.decodeFromString(serializer<ThemeStyle>(), legacyOutput) shouldBe ThemeStyle.MATERIAL_YOU
}
@Test
fun `ThemeStyle round-trip`() {
ThemeStyle.entries.forEach { style ->
val encoded = json.encodeToString(serializer<ThemeStyle>(), style)
json.decodeFromString(serializer<ThemeStyle>(), encoded) shouldBe style
}
}
@Test
fun `ThemeColor encodes to canonical string`() {
json.encodeToString(serializer<ThemeColor>(), ThemeColor.GREEN) shouldBe "\"theme.color.green\""
}
@Test
fun `ThemeColor legacy string decodes correctly`() {
val legacyOutput = "\"theme.color.green\""
json.decodeFromString(serializer<ThemeColor>(), legacyOutput) shouldBe ThemeColor.GREEN
}
@Test
fun `ThemeColor round-trip`() {
ThemeColor.entries.forEach { color ->
val encoded = json.encodeToString(serializer<ThemeColor>(), color)
json.decodeFromString(serializer<ThemeColor>(), encoded) shouldBe color
}
}
@Test
fun `AutoConnectCondition encodes to canonical string`() {
json.encodeToString(serializer<AutoConnectCondition>(), AutoConnectCondition.WHEN_SEEN) shouldBe "\"autoconnect.condition.seen\""
}
@Test
fun `AutoConnectCondition legacy string decodes correctly`() {
val legacyOutput = "\"autoconnect.condition.seen\""
json.decodeFromString(serializer<AutoConnectCondition>(), legacyOutput) shouldBe AutoConnectCondition.WHEN_SEEN
}
@Test
fun `AutoConnectCondition round-trip`() {
AutoConnectCondition.entries.forEach { condition ->
val encoded = json.encodeToString(serializer<AutoConnectCondition>(), condition)
json.decodeFromString(serializer<AutoConnectCondition>(), encoded) shouldBe condition
}
}
@Test
fun `PodDevice Model encodes to canonical string`() {
json.encodeToString(serializer<PodDevice.Model>(), PodDevice.Model.AIRPODS_PRO) shouldBe "\"airpods.pro\""
}
@Test
fun `all PodDevice Model values round-trip`() {
for (model in PodDevice.Model.entries) {
val field = PodDevice.Model::class.java.getField(model.name)
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
if (moshiAnnotation != null) {
val moshiOutput = "\"${moshiAnnotation.name}\""
val result = json.decodeFromString(serializer<PodDevice.Model>(), moshiOutput)
result shouldBe model
}
val encoded = json.encodeToString(serializer<PodDevice.Model>(), model)
json.decodeFromString(serializer<PodDevice.Model>(), encoded) shouldBe model
}
}
@Test
fun `Moshi-serialized ThemeStyle string is readable by kotlinx`() {
val moshiOutput = "\"theme.style.materialyou\""
val result = json.decodeFromString(serializer<ThemeStyle>(), moshiOutput)
result shouldBe ThemeStyle.MATERIAL_YOU
fun `PodDevice Model legacy string decodes correctly`() {
val legacyOutput = "\"airpods.pro\""
json.decodeFromString(serializer<PodDevice.Model>(), legacyOutput) shouldBe PodDevice.Model.AIRPODS_PRO
}
@Test
fun `Moshi-serialized ThemeColor string is readable by kotlinx`() {
val moshiOutput = "\"theme.color.green\""
val result = json.decodeFromString(serializer<ThemeColor>(), moshiOutput)
result shouldBe ThemeColor.GREEN
}
@Test
fun `Moshi-serialized AutoConnectCondition string is readable by kotlinx`() {
val moshiOutput = "\"autoconnect.condition.seen\""
val result = json.decodeFromString(serializer<AutoConnectCondition>(), moshiOutput)
result shouldBe AutoConnectCondition.WHEN_SEEN
}
@Test
fun `Moshi-serialized DeviceProfilesContainer JSON is readable by kotlinx`() {
// This is what Moshi would produce for a container with one Apple profile
val moshiJson = """
fun `legacy DeviceProfilesContainer JSON decodes correctly`() {
val legacyJson = """
{
"profiles": [
{
@@ -143,7 +180,7 @@ class DataStoreMigrationCompatTest : BaseTest() {
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), legacyJson)
result.profiles.size shouldBe 1
val profile = result.profiles[0] as AppleDeviceProfile
@@ -157,9 +194,43 @@ class DataStoreMigrationCompatTest : BaseTest() {
}
@Test
fun `Moshi-serialized DeviceProfile with ByteArray fields is readable by kotlinx`() {
fun `DeviceProfilesContainer round-trip`() {
val container = DeviceProfilesContainer(
profiles = listOf(
AppleDeviceProfile(
id = "round-trip-id",
label = "Test Profile",
model = PodDevice.Model.AIRPODS_GEN2,
address = "11:22:33:44:55:66",
)
)
)
val encoded = json.encodeToString(serializer<DeviceProfilesContainer>(), container)
val decoded = json.decodeFromString(serializer<DeviceProfilesContainer>(), encoded)
decoded.profiles.size shouldBe 1
val profile = decoded.profiles[0] as AppleDeviceProfile
profile.id shouldBe "round-trip-id"
profile.label shouldBe "Test Profile"
profile.model shouldBe PodDevice.Model.AIRPODS_GEN2
profile.address shouldBe "11:22:33:44:55:66"
}
@Test
fun `DeviceProfilesContainer includes type discriminator`() {
val container = DeviceProfilesContainer(
profiles = listOf(
AppleDeviceProfile(id = "disc-test", label = "Test")
)
)
val encoded = json.encodeToString(serializer<DeviceProfilesContainer>(), container)
encoded shouldContain "\"type\":\"apple\""
}
@Test
fun `legacy DeviceProfile with ByteArray fields decodes correctly`() {
// Base64 encoded: [0x01, 0x02, 0x03] = "AQID"
val moshiJson = """
val legacyJson = """
{
"profiles": [
{
@@ -176,7 +247,7 @@ class DataStoreMigrationCompatTest : BaseTest() {
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), legacyJson)
val profile = result.profiles[0] as AppleDeviceProfile
profile.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
profile.encryptionKey!!.toList() shouldBe listOf<Byte>(0x04, 0x05, 0x06)
@@ -33,5 +33,10 @@ class RecorderModuleStateTest : BaseTest() {
fun `currentLogPath is null`() {
RecorderModule.State().currentLogPath shouldBe null
}
@Test
fun `persistedLogDir is null`() {
RecorderModule.State().persistedLogDir shouldBe null
}
}
}
@@ -0,0 +1,134 @@
package eu.darken.capod.common.debug.recording.core
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import java.io.File
class RecorderModuleTriggerFileTest : BaseTest() {
@TempDir
lateinit var tempDir: File
private val now = 1_700_000_000_000L
@AfterEach
fun cleanup() {
tempDir.listFiles()?.forEach { it.deleteRecursively() }
}
@Nested
inner class ParseTriggerContent {
@Test
fun `valid content returns dir and timestamp`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val content = "${sessionDir.absolutePath}\n$now"
val result = RecorderModule.parseTriggerContent(content, now = now)
result shouldBe (sessionDir to now)
}
@Test
fun `empty content returns null`() {
RecorderModule.parseTriggerContent("", now = now).shouldBeNull()
}
@Test
fun `blank content returns null`() {
RecorderModule.parseTriggerContent(" \n ", now = now).shouldBeNull()
}
@Test
fun `single line returns null`() {
RecorderModule.parseTriggerContent("/some/path", now = now).shouldBeNull()
}
@Test
fun `non-numeric timestamp returns null`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val content = "${sessionDir.absolutePath}\nnotanumber"
RecorderModule.parseTriggerContent(content, now = now).shouldBeNull()
}
@Test
fun `zero timestamp returns null`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val content = "${sessionDir.absolutePath}\n0"
RecorderModule.parseTriggerContent(content, now = now).shouldBeNull()
}
@Test
fun `negative timestamp returns null`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val content = "${sessionDir.absolutePath}\n-1000"
RecorderModule.parseTriggerContent(content, now = now).shouldBeNull()
}
@Test
fun `future timestamp beyond skew returns null`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val futureTs = now + 120_000L
val content = "${sessionDir.absolutePath}\n$futureTs"
RecorderModule.parseTriggerContent(content, now = now).shouldBeNull()
}
@Test
fun `future timestamp within skew is accepted`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val nearFutureTs = now + 30_000L
val content = "${sessionDir.absolutePath}\n$nearFutureTs"
val result = RecorderModule.parseTriggerContent(content, now = now)
result shouldBe (sessionDir to nearFutureTs)
}
@Test
fun `non-existent directory returns null`() {
val content = "/nonexistent/path/capod_session\n$now"
RecorderModule.parseTriggerContent(content, now = now).shouldBeNull()
}
@Test
fun `content with trailing whitespace is handled`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val content = " ${sessionDir.absolutePath}\n$now \n"
val result = RecorderModule.parseTriggerContent(content, now = now)
result shouldBe (sessionDir to now)
}
@Test
fun `old timestamp is accepted`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val oldTs = 1_000L
val content = "${sessionDir.absolutePath}\n$oldTs"
val result = RecorderModule.parseTriggerContent(content, now = now)
result shouldBe (sessionDir to oldTs)
}
@Test
fun `extra lines are ignored`() {
val sessionDir = File(tempDir, "capod_session").also { it.mkdirs() }
val content = "${sessionDir.absolutePath}\n$now\nextra\ndata"
val result = RecorderModule.parseTriggerContent(content, now = now)
result shouldBe (sessionDir to now)
}
}
}
@@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -81,6 +82,9 @@ class OverviewViewModelTest : BaseTest() {
permissionTool = mockk<PermissionTool>(relaxed = true).also {
every { it.missingPermissions } returns missingPermissionsFlow
every { it.missingScanPermissions } returns missingPermissionsFlow.map { perms ->
perms.filter { it.isScanBlocking }.toSet()
}
}
generalSettings = mockk<GeneralSettings>().also {
@@ -1,29 +0,0 @@
package testhelpers.json
import com.squareup.moshi.JsonReader
import com.squareup.moshi.Moshi
import okio.Buffer
import okio.ByteString.Companion.encode
import okio.buffer
import okio.sink
import java.io.File
fun String.toComparableJson(): String {
val value = Buffer().use {
it.writeUtf8(this)
val reader = JsonReader.of(it)
reader.readJsonValue()
}
val adapter = Moshi.Builder().build().adapter(Any::class.java).indent(" ")
return adapter.toJson(value)
}
fun String.writeToFile(file: File) = encode().let { text ->
require(!file.exists())
file.parentFile?.mkdirs()
file.createNewFile()
file.sink().buffer().use { it.write(text) }
}
@@ -1,10 +1,8 @@
package eu.darken.capod.common.datastore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.squareup.moshi.Json as MoshiJson
import eu.darken.capod.common.upgrade.core.FossUpgrade
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.junit.jupiter.api.Test
@@ -25,6 +23,7 @@ class FossUpgradeSerializationTest : BaseTest() {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
private fun createDataStore() = PreferenceDataStoreFactory.create(
@@ -50,24 +49,27 @@ class FossUpgradeSerializationTest : BaseTest() {
}
@Test
fun `SerialName matches Json name - FossUpgrade Reason`() {
val enumClass = FossUpgrade.Reason::class.java
for (constant in enumClass.enumConstants!!) {
val field = enumClass.getField(constant.name)
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
val serialNameAnnotation = field.getAnnotation(SerialName::class.java)
if (moshiAnnotation != null && serialNameAnnotation != null) {
serialNameAnnotation.value shouldBe moshiAnnotation.name
}
}
fun `FossUpgrade Reason encodes to canonical string`() {
json.encodeToString(serializer<FossUpgrade.Reason>(), FossUpgrade.Reason.DONATED) shouldBe "\"foss.upgrade.reason.donated\""
}
@Test
fun `Moshi-serialized FossUpgrade JSON is readable by kotlinx`() {
val moshiJson = """{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}"""
val result = json.decodeFromString(serializer<FossUpgrade>(), moshiJson)
fun `legacy FossUpgrade JSON decodes correctly`() {
val legacyJson = """{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}"""
val result = json.decodeFromString(serializer<FossUpgrade>(), legacyJson)
result.upgradedAt shouldBe Instant.ofEpochMilli(1709553600000)
result.reason shouldBe FossUpgrade.Reason.DONATED
}
@Test
fun `FossUpgrade round-trip`() {
val upgrade = FossUpgrade(
upgradedAt = Instant.ofEpochMilli(1709553600000),
reason = FossUpgrade.Reason.ALREADY_DONATED,
)
val encoded = json.encodeToString(serializer<FossUpgrade>(), upgrade)
val decoded = json.decodeFromString(serializer<FossUpgrade>(), encoded)
decoded.upgradedAt shouldBe upgrade.upgradedAt
decoded.reason shouldBe upgrade.reason
}
}
-5
View File
@@ -60,11 +60,6 @@ fun DependencyHandlerScope.addDagger() {
kspAndroidTest("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
}
fun DependencyHandlerScope.addMoshi() {
implementation("com.squareup.moshi:moshi:1.15.2")
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
add("ksp", "com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
}
fun DependencyHandlerScope.addOkio() {
implementation("com.squareup.okio:okio:3.1.0")
+1 -1
View File
@@ -15,7 +15,7 @@ object Versions {
}
object Compose {
const val bom = "2025.06.01"
const val bom = "2025.10.00"
}
object Navigation3 {
+2
View File
@@ -27,6 +27,7 @@ platform :android do
track: 'beta',
rollout: '0.10',
package_name: 'eu.darken.capod',
mapping: '../app/build/outputs/mapping/gplayBeta/mapping.txt',
skip_upload_changelogs: 'false',
skip_upload_apk: 'true',
skip_upload_images: 'true',
@@ -42,6 +43,7 @@ platform :android do
track: 'beta',
rollout: '0.10',
package_name: 'eu.darken.capod',
mapping: '../app/build/outputs/mapping/gplayRelease/mapping.txt',
skip_upload_changelogs: 'false',
skip_upload_apk: 'true',
skip_upload_images: 'true',
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# Validates Fastlane metadata files stay within store character limits.
#
# Checked files per locale:
# title.txt - hard limit 30 chars
# short_description.txt - hard limit 80 chars
# full_description.txt - hard limit 3800 chars, warn at 3700
# changelogs/*.txt - hard limit 500 chars each
set -euo pipefail
export LC_ALL=C.UTF-8
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
METADATA_DIR="$SCRIPT_DIR/metadata/android"
TITLE_MAX=30
SHORT_DESC_MAX=80
FULL_DESC_MAX=3800
FULL_DESC_WARN=3700
CHANGELOG_MAX=500
failed=0
warned=0
checked=0
# Count characters excluding only the trailing newline
count_chars() {
sed -z 's/\n$//' < "$1" | wc -m | tr -d '[:space:]'
}
check_file() {
local file="$1"
local label="$2"
local hard_limit="$3"
local warn_limit="${4:-0}"
if [ ! -f "$file" ]; then
return
fi
local chars
chars="$(count_chars "$file")"
checked=$((checked + 1))
if [ "$chars" -gt "$hard_limit" ]; then
echo "FAIL $label: $chars chars (limit: $hard_limit)"
failed=$((failed + 1))
elif [ "$warn_limit" -gt 0 ] && [ "$chars" -gt "$warn_limit" ]; then
echo "WARN $label: $chars chars (approaching limit)"
warned=$((warned + 1))
fi
}
if [ ! -d "$METADATA_DIR" ]; then
echo "ERROR: Metadata directory not found: $METADATA_DIR"
exit 1
fi
locale_count=0
for locale_dir in "$METADATA_DIR"/*/; do
[ -d "$locale_dir" ] || continue
locale="$(basename "$locale_dir")"
locale_count=$((locale_count + 1))
check_file "$locale_dir/title.txt" "$locale/title.txt" "$TITLE_MAX"
check_file "$locale_dir/short_description.txt" "$locale/short_description.txt" "$SHORT_DESC_MAX"
check_file "$locale_dir/full_description.txt" "$locale/full_description.txt" "$FULL_DESC_MAX" "$FULL_DESC_WARN"
for changelog in "$locale_dir"/changelogs/*.txt; do
[ -f "$changelog" ] || continue
changelog_name="changelogs/$(basename "$changelog")"
check_file "$changelog" "$locale/$changelog_name" "$CHANGELOG_MAX"
done
done
if [ "$locale_count" -eq 0 ]; then
echo "ERROR: No locale directories found in $METADATA_DIR"
exit 1
fi
if [ "$checked" -eq 0 ]; then
echo "ERROR: No metadata files found to check"
exit 1
fi
echo ""
echo "Checked $checked files across $locale_count locales."
if [ "$failed" -gt 0 ]; then
echo "$failed failure(s), $warned warning(s)."
exit 1
fi
if [ "$warned" -gt 0 ]; then
echo "All within limits. $warned warning(s)."
fi
exit 0
+1 -1
View File
@@ -1 +1 @@
CAPod - AirPods laguntzailea
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - Kumppani AirPodsille
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - Kasama para sa AirPods
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - pratilac za AirPods
CAPod - Companion for AirPods
@@ -1,18 +1,18 @@
CAPod is a companion app for AirPods.
CAPod-ը AirPods-ի ուղեկցող հավելված է։
Features:
Հնարավորություններ.
* Battery level for pods and cases.
* Charging status for pods and case.
* Additional infos about connection, microphone and case.
* Can receive and show all nearby devices.
* Ear detection with automatic play/pause.
* Automatically connect phone and AirPods.
* Show popup when case is opened.
* Մարտկոցի մակարդակ pods-ների և պատյանների համար։
* Լիցքավորման կարգավիճակ pods-ների և պատյանի համար։
* Լրացուցիչ տեղեկություններ կապի, խոսափողի և պատյանի մասին։
* Կարող է ստանալ և ցուցադրել բոլոր մոտակա սարքերը։
* Ականջի հայտնաբերում ավտոմատ նվագարկում/դադարով։
* Ավտոմատ կերպով կապել սարքը և AirPods-ը։
* Ցուցադրել թռուցիկ պատուհան, երբ պատյանը բացվի։
CAPod is ad-free. Some features require an in-app purchase.
CAPod-ը գովազդից ազատ է։ Որոշ հնարավորություններ պահանջում են ծրագրի ներսի գնում։
Most popular AirPods and Beats devices are supported.
If your device is similar to AirPods but not yet supported, send me a short mail.
Աջակցվում են AirPods-ի և Beats-ի ամենատարածված սարքերը։
Եթե ձեր սարքը նման է AirPods-ին, բայց դեռ չի աջակցվում, ուղարկեք ինձ կարճ նամակ։
Got a cool idea for a new feature? Reach out!
Հետաքրքիր գաղափար ունե՞ք նոր հնարավորության համար: Կապվե՛ք մեզ հետ:
+1 -1
View File
@@ -1 +1 @@
CAPod - Félagi fyrir AirPods
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - AirPods-ის თანამგზავრი
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - សម្រាប់ AirPods
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - Hevalê AirPods
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - ຄູ່ຮ່ວມສຳລັບ AirPods
CAPod - Companion for AirPods
+1 -1
View File
@@ -1 +1 @@
CAPod - AirPods kompanionas
CAPod - Companion for AirPods

Some files were not shown because too many files have changed in this diff Show More