mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
Compare commits
32
Commits
v4.0.0-rc0
...
v4.0.3-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6ec2b6bb | ||
|
|
9a85b23d91 | ||
|
|
217d9be177 | ||
|
|
53447f3fb9 | ||
|
|
db759bac7c | ||
|
|
6837db5b84 | ||
|
|
83b24cced2 | ||
|
|
d913e72c5c | ||
|
|
5c16c08dee | ||
|
|
20e22c0910 | ||
|
|
fa3e56b0f3 | ||
|
|
f25718ab2c | ||
|
|
6e9b275936 | ||
|
|
c6c7cd7858 | ||
|
|
4d7027a9b8 | ||
|
|
992cb5e695 | ||
|
|
039d20d7aa | ||
|
|
52433460be | ||
|
|
95bb225e64 | ||
|
|
ac477113cf | ||
|
|
5e21941765 | ||
|
|
e5032ee35a | ||
|
|
1ceac349ab | ||
|
|
2db01b410b | ||
|
|
193a1030fb | ||
|
|
12e35631d6 | ||
|
|
67474a30da | ||
|
|
44cd0eec2a | ||
|
|
a79f6a0535 | ||
|
|
8675d73a98 | ||
|
|
3423786f06 | ||
|
|
00083707c6 |
@@ -0,0 +1 @@
|
|||||||
|
worktrees/
|
||||||
@@ -80,5 +80,5 @@ Code shared between phone and Wear OS apps is placed in `app-common`. When modif
|
|||||||
|
|
||||||
- **Hilt**: Dependency injection framework
|
- **Hilt**: Dependency injection framework
|
||||||
- **AndroidX Navigation**: Fragment navigation with SafeArgs
|
- **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
|
- **Material Design**: UI components following Material Design guidelines
|
||||||
|
|||||||
+12
-11
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"mcp__ide__getDiagnostics",
|
"mcp__ide__getDiagnostics",
|
||||||
"WebSearch"
|
"WebSearch"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
},
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"android-translation@claude-code-cafe": true,
|
"android-translation@claude-code-cafe": true,
|
||||||
"debugbadger@claude-code-cafe": true,
|
"debugbadger@claude-code-cafe": true,
|
||||||
"jvm-tools@claude-code-cafe": true,
|
"jvm-tools@claude-code-cafe": true,
|
||||||
"frontend-design@claude-plugins-official": true,
|
"frontend-design@claude-plugins-official": true,
|
||||||
"kotlin-lsp@claude-plugins-official": true
|
"kotlin-lsp@claude-plugins-official": true,
|
||||||
|
"support-investigator@claude-code-cafe": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
@@ -14,7 +14,7 @@ runs:
|
|||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Cache Gradle Wrapper
|
- name: Cache Gradle Wrapper
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
@@ -24,7 +24,7 @@ runs:
|
|||||||
${{ runner.os }}-gradle-wrapper-
|
${{ runner.os }}-gradle-wrapper-
|
||||||
|
|
||||||
- name: Cache Gradle Dependencies
|
- name: Cache Gradle Dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -33,7 +33,7 @@ runs:
|
|||||||
${{ runner.os }}-gradle-caches-
|
${{ runner.os }}-gradle-caches-
|
||||||
|
|
||||||
- name: Cache Android Global Build-Cache
|
- name: Cache Android Global Build-Cache
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.android/build-cache
|
~/.android/build-cache
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -53,9 +53,18 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
- name: Test modules
|
- 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"
|
name: "Validation"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
|
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: 'Build only, skip release/upload'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-github:
|
release-github:
|
||||||
@@ -24,23 +30,15 @@ jobs:
|
|||||||
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
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
|
- name: Assemble beta APK
|
||||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
if: contains(github.ref_name, '-beta')
|
||||||
run: ./gradlew assembleFossBeta
|
run: ./gradlew assembleFossBeta
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -49,7 +47,7 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Assemble production APK
|
- name: Assemble production APK
|
||||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
if: "!contains(github.ref_name, '-beta')"
|
||||||
run: ./gradlew assembleFossRelease
|
run: ./gradlew assembleFossRelease
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -58,24 +56,24 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: ${{ steps.tagger.outputs.tag }}
|
name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
|
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
prerelease: false
|
||||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: ${{ steps.tagger.outputs.tag }}
|
name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
|
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
|
||||||
env:
|
env:
|
||||||
@@ -107,29 +105,21 @@ jobs:
|
|||||||
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
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
|
- name: Set up ruby env
|
||||||
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
|
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.3.6
|
ruby-version: 3.3.6
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Assemble beta and upload to Google Play
|
- 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
|
run: bundle exec fastlane beta
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
@@ -137,7 +127,7 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Assemble production and upload to Google Play
|
- 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
|
run: bundle exec fastlane production
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
|
|||||||
@@ -177,8 +177,6 @@ dependencies {
|
|||||||
|
|
||||||
addDagger()
|
addDagger()
|
||||||
|
|
||||||
addMoshi()
|
|
||||||
|
|
||||||
addOkio()
|
addOkio()
|
||||||
|
|
||||||
addBaseAndroid()
|
addBaseAndroid()
|
||||||
|
|||||||
@@ -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
|
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 eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class FossUpgrade(
|
data class FossUpgrade(
|
||||||
@Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
|
@SerialName("upgradedAt") @Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
|
||||||
val reason: Reason
|
@SerialName("reason") val reason: Reason
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class Reason {
|
enum class Reason {
|
||||||
@SerialName("foss.upgrade.reason.donated") @Json(name = "foss.upgrade.reason.donated") DONATED,
|
@SerialName("foss.upgrade.reason.donated") DONATED,
|
||||||
@SerialName("foss.upgrade.reason.alreadydonated") @Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
|
@SerialName("foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
|
||||||
@SerialName("foss.upgrade.reason.nomoney") @Json(name = "foss.upgrade.reason.nomoney") NO_MONEY;
|
@SerialName("foss.upgrade.reason.nomoney") NO_MONEY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="foss_upgrade_donate_label">Faire un don</string>
|
<string name="foss_upgrade_donate_label">Faire un don</string>
|
||||||
<string name="foss_upgrade_alreadydonated_label">J’ai déjà fait un don</string>
|
<string name="foss_upgrade_alreadydonated_label">J’ai déjà fait un don</string>
|
||||||
<string name="foss_upgrade_no_money_label">J’ai dépensé tout mon argent sur les AirPods</string>
|
<string name="foss_upgrade_no_money_label">J’ai dépensé tout mon argent sur des 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="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_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_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>
|
<string name="upgrade_foss_sponsor_returned_early">Déjà de retour ? Votre soutien maintient CAPod en vie.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+14
-23
@@ -9,6 +9,9 @@ import com.android.billingclient.api.ProductDetails
|
|||||||
import com.android.billingclient.api.Purchase
|
import com.android.billingclient.api.Purchase
|
||||||
import com.android.billingclient.api.QueryProductDetailsParams
|
import com.android.billingclient.api.QueryProductDetailsParams
|
||||||
import com.android.billingclient.api.QueryPurchasesParams
|
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.INFO
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
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.log
|
||||||
@@ -21,8 +24,6 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
data class BillingClientConnection(
|
data class BillingClientConnection(
|
||||||
private val client: BillingClient,
|
private val client: BillingClient,
|
||||||
@@ -67,22 +68,16 @@ data class BillingClientConnection(
|
|||||||
.setProductType(productType)
|
.setProductType(productType)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { continuation ->
|
val queryResult = client.queryPurchasesAsync(params)
|
||||||
client.queryPurchasesAsync(params) { result, purchases ->
|
|
||||||
continuation.resume(result to purchases)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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" }
|
log(TAG, WARN) { "queryPurchases($productType) failed" }
|
||||||
throw BillingResultException(result)
|
throw BillingResultException(queryResult.billingResult)
|
||||||
} else {
|
|
||||||
requireNotNull(purchases)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return purchases
|
return queryResult.purchasesList
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun acknowledgePurchase(purchase: Purchase) {
|
suspend fun acknowledgePurchase(purchase: Purchase) {
|
||||||
@@ -90,9 +85,7 @@ data class BillingClientConnection(
|
|||||||
setPurchaseToken(purchase.purchaseToken)
|
setPurchaseToken(purchase.purchaseToken)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val result = suspendCoroutine<BillingResult> { continuation ->
|
val result = client.acknowledgePurchase(ack)
|
||||||
client.acknowledgePurchase(ack) { continuation.resume(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
|
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 params = QueryProductDetailsParams.newBuilder().setProductList(products).build()
|
||||||
|
|
||||||
val (result, details) = suspendCoroutine<Pair<BillingResult, List<ProductDetails>>> { continuation ->
|
val queryResult = client.queryProductDetails(params)
|
||||||
client.queryProductDetailsAsync(params) { billingResult, queryResult ->
|
|
||||||
continuation.resume(billingResult to queryResult.productDetailsList.orEmpty())
|
val details = queryResult.productDetailsList.orEmpty()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log(TAG) {
|
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) {
|
for (detail in details) {
|
||||||
val matchingSku = typeSkus.firstOrNull { it.id == detail.productId }
|
val matchingSku = typeSkus.firstOrNull { it.id == detail.productId }
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ class BillingDataRepo @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
private val connectionProvider = billingClientConnectionProvider.connection
|
private val connectionProvider = billingClientConnectionProvider.connection
|
||||||
.catch {
|
.retryWhen { cause, attempt ->
|
||||||
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
|
if (cause is CancellationException) return@retryWhen false
|
||||||
throw it
|
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)
|
.replayingShare(scope)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="eu.darken.capod">
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<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.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<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.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
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH"
|
android:name="android.permission.BLUETOOTH"
|
||||||
@@ -32,7 +36,8 @@
|
|||||||
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_SCAN"
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation"
|
||||||
|
tools:ignore="UnusedAttribute" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.bluetooth_le"
|
android:name="android.hardware.bluetooth_le"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
|||||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
import eu.darken.capod.monitor.core.PodMonitor
|
||||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
@@ -27,7 +27,6 @@ import javax.inject.Inject
|
|||||||
open class App : Application() {
|
open class App : Application() {
|
||||||
|
|
||||||
@Inject lateinit var autoReporting: AutomaticBugReporter
|
@Inject lateinit var autoReporting: AutomaticBugReporter
|
||||||
@Inject lateinit var monitorControl: MonitorControl
|
|
||||||
@Inject lateinit var podMonitor: PodMonitor
|
@Inject lateinit var podMonitor: PodMonitor
|
||||||
@Inject lateinit var widgetManager: WidgetManager
|
@Inject lateinit var widgetManager: WidgetManager
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
@@ -41,8 +40,6 @@ open class App : Application() {
|
|||||||
|
|
||||||
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
|
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
|
||||||
|
|
||||||
monitorControl.startMonitor(forceStart = true)
|
|
||||||
|
|
||||||
appScope.launch { widgetManager.refreshWidgets() }
|
appScope.launch { widgetManager.refreshWidgets() }
|
||||||
|
|
||||||
podMonitor.devicesWithProfiles()
|
podMonitor.devicesWithProfiles()
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ package eu.darken.capod.common.bluetooth
|
|||||||
import android.bluetooth.le.ScanResult
|
import android.bluetooth.le.ScanResult
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.core.util.forEach
|
import androidx.core.util.forEach
|
||||||
import com.squareup.moshi.Json
|
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||||
import com.squareup.moshi.JsonClass
|
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@JsonClass(generateAdapter = true)
|
@Serializable
|
||||||
data class BleScanResult(
|
data class BleScanResult(
|
||||||
@Json(name = "receivedAt") val receivedAt: Instant,
|
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
|
||||||
@Json(name = "address") val address: String,
|
@SerialName("address") val address: String,
|
||||||
@Json(name = "rssi") val rssi: Int,
|
@SerialName("rssi") val rssi: Int,
|
||||||
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
|
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
|
||||||
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
|
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
|
||||||
|
|||||||
@@ -304,6 +304,9 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
emit(wrappedDevices)
|
emit(wrappedDevices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var _isNudgeAvailable: Boolean = true
|
||||||
|
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||||
|
|
||||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||||
try {
|
try {
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
log(TAG) { "Nudging Android connection to $device" }
|
||||||
@@ -317,6 +320,12 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
log(TAG) { "Nudged connection to $device" }
|
log(TAG) { "Nudged connection to $device" }
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
val isSecurityException = e is SecurityException ||
|
||||||
|
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||||
|
if (isSecurityException) {
|
||||||
|
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
|
||||||
|
_isNudgeAvailable = false
|
||||||
|
}
|
||||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
package eu.darken.capod.common.bluetooth
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class ScannerMode(
|
enum class ScannerMode(
|
||||||
val identifier: String,
|
val identifier: String,
|
||||||
@StringRes val labelRes: Int
|
@StringRes val labelRes: Int
|
||||||
) {
|
) {
|
||||||
@SerialName("scanner.mode.lowpower") @Json(name = "scanner.mode.lowpower") LOW_POWER(
|
@SerialName("scanner.mode.lowpower") LOW_POWER(
|
||||||
"scanner.mode.lowpower",
|
"scanner.mode.lowpower",
|
||||||
R.string.settings_scanner_mode_lowpower_label
|
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",
|
"scanner.mode.balanced",
|
||||||
R.string.settings_scanner_mode_balanced_label
|
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",
|
"scanner.mode.lowlatency",
|
||||||
R.string.settings_scanner_mode_lowlatency_label
|
R.string.settings_scanner_mode_lowlatency_label
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import eu.darken.capod.pods.core.PodDevice
|
|||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.pods.core.SinglePodDevice
|
||||||
import eu.darken.capod.pods.core.unknown.UnknownDevice
|
import eu.darken.capod.pods.core.unknown.UnknownDevice
|
||||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||||
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
|
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@@ -114,11 +116,13 @@ object MockPodDataProvider {
|
|||||||
_isBeingWorn = true,
|
_isBeingWorn = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun airPodsMaxCharging(): SinglePodDevice = MockSinglePodDevice(
|
fun airPodsMaxCharging(): SinglePodDevice = MockSingleApplePodDevice(
|
||||||
_model = PodDevice.Model.AIRPODS_MAX,
|
_model = PodDevice.Model.AIRPODS_MAX,
|
||||||
_label = "AirPods Max",
|
_label = "AirPods Max",
|
||||||
batteryHeadsetPercent = 0.40f,
|
batteryHeadsetPercent = 0.40f,
|
||||||
_isHeadsetBeingCharged = true,
|
_isHeadsetBeingCharged = true,
|
||||||
|
_isIRKMatch = true,
|
||||||
|
_hasPrivatePayload = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun beatsSolo3(): SinglePodDevice = MockSinglePodDevice(
|
fun beatsSolo3(): SinglePodDevice = MockSinglePodDevice(
|
||||||
@@ -273,3 +277,41 @@ private class MockSinglePodDevice(
|
|||||||
// HasEarDetection
|
// HasEarDetection
|
||||||
override val isBeingWorn: Boolean = _isBeingWorn
|
override val isBeingWorn: Boolean = _isBeingWorn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("PropertyName")
|
||||||
|
private class MockSingleApplePodDevice(
|
||||||
|
private val _model: PodDevice.Model,
|
||||||
|
private val _label: String,
|
||||||
|
override val batteryHeadsetPercent: Float?,
|
||||||
|
private val _isHeadsetBeingCharged: Boolean = false,
|
||||||
|
private val _isBeingWorn: Boolean = false,
|
||||||
|
private val _isIRKMatch: Boolean = false,
|
||||||
|
private val _hasPrivatePayload: Boolean = false,
|
||||||
|
rssi: Int = -50,
|
||||||
|
) : SinglePodDevice, ApplePods, HasChargeDetection, HasEarDetection {
|
||||||
|
override val identifier: PodDevice.Id = PodDevice.Id()
|
||||||
|
override val model: PodDevice.Model = _model
|
||||||
|
override val seenLastAt: Instant = MOCK_NOW
|
||||||
|
override val seenFirstAt: Instant = MOCK_NOW
|
||||||
|
override val seenCounter: Int = 5
|
||||||
|
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
|
||||||
|
override val reliability: Float = 1.0f
|
||||||
|
override val signalQuality: Float = 0.80f
|
||||||
|
override val iconRes: Int = _model.iconRes
|
||||||
|
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
|
||||||
|
isIRKMatch = _isIRKMatch,
|
||||||
|
profile = AppleDeviceProfile(label = _label, model = _model),
|
||||||
|
)
|
||||||
|
override val payload: ProximityPayload = ProximityPayload(
|
||||||
|
public = ProximityPayload.Public(UByteArray(9)),
|
||||||
|
private = if (_hasPrivatePayload) ProximityPayload.Private(UByteArray(8)) else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getLabel(context: Context): String = _model.label
|
||||||
|
|
||||||
|
// HasChargeDetection
|
||||||
|
override val isHeadsetBeingCharged: Boolean = _isHeadsetBeingCharged
|
||||||
|
|
||||||
|
// HasEarDetection
|
||||||
|
override val isBeingWorn: Boolean = _isBeingWorn
|
||||||
|
}
|
||||||
|
|||||||
+12
-10
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -57,21 +59,20 @@ class DebugSessionManager @Inject constructor(
|
|||||||
activeDir = recorderState.currentLogDir,
|
activeDir = recorderState.currentLogDir,
|
||||||
recordingStartedAt = recorderState.recordingStartedAt,
|
recordingStartedAt = recorderState.recordingStartedAt,
|
||||||
)
|
)
|
||||||
val overlaid = applyOverlays(raw, zipping, failedZips)
|
applyOverlays(raw, zipping, failedZips)
|
||||||
|
}.replayingShare(appScope)
|
||||||
|
|
||||||
val orphans = findOrphans(overlaid, zipping)
|
init {
|
||||||
if (orphans.isNotEmpty()) {
|
sessions.onEach { allSessions ->
|
||||||
orphans.forEach { (id, _) -> pendingAutoZips.add(id) }
|
val orphans = findOrphans(allSessions, zippingIds.value)
|
||||||
appScope.launch {
|
orphans.forEach { (id, dir) ->
|
||||||
orphans.forEach { (id, dir) ->
|
if (pendingAutoZips.add(id)) {
|
||||||
log(TAG, INFO) { "Orphan session detected, auto-zipping: $id" }
|
log(TAG, INFO) { "Orphan session detected, auto-zipping: $id" }
|
||||||
zipSessionAsync(id, dir)
|
zipSessionAsync(id, dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}.launchIn(appScope)
|
||||||
|
}
|
||||||
overlaid
|
|
||||||
}.replayingShare(appScope)
|
|
||||||
|
|
||||||
private fun applyOverlays(
|
private fun applyOverlays(
|
||||||
sessions: List<DebugSession>,
|
sessions: List<DebugSession>,
|
||||||
@@ -226,6 +227,7 @@ class DebugSessionManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
failedZipIds.update { emptySet() }
|
failedZipIds.update { emptySet() }
|
||||||
|
pendingAutoZips.clear()
|
||||||
log(TAG) { "All stored logs deleted" }
|
log(TAG) { "All stored logs deleted" }
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package eu.darken.capod.common.debug.recording.core
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
import eu.darken.capod.common.InstallId
|
import eu.darken.capod.common.InstallId
|
||||||
@@ -48,7 +49,12 @@ class RecorderModule @Inject constructor(
|
|||||||
|
|
||||||
private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
|
private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
|
||||||
val triggerFileExists = triggerFile.exists()
|
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
|
val state: Flow<State> = internalState.flow
|
||||||
|
|
||||||
@@ -59,24 +65,46 @@ class RecorderModule @Inject constructor(
|
|||||||
|
|
||||||
internalState.updateBlocking {
|
internalState.updateBlocking {
|
||||||
if (!isRecording && shouldRecord) {
|
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 logFile = File(sessionDir, "core.log")
|
||||||
val newRecorder = Recorder()
|
val newRecorder = Recorder()
|
||||||
newRecorder.start(logFile)
|
newRecorder.start(logFile)
|
||||||
triggerFile.createNewFile()
|
|
||||||
|
|
||||||
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
|
if (!isResume) {
|
||||||
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
|
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(
|
copy(
|
||||||
recorder = newRecorder,
|
recorder = newRecorder,
|
||||||
currentLogDir = sessionDir,
|
currentLogDir = sessionDir,
|
||||||
recordingStartedAt = System.currentTimeMillis(),
|
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) {
|
} else if (!shouldRecord && isRecording) {
|
||||||
recorder!!.stop()
|
requireNotNull(recorder) { "Recorder is null despite isRecording" }.stop()
|
||||||
|
|
||||||
if (triggerFile.exists() && !triggerFile.delete()) {
|
if (triggerFile.exists() && !triggerFile.delete()) {
|
||||||
log(TAG, ERROR) { "Failed to delete trigger file" }
|
log(TAG, ERROR) { "Failed to delete trigger file" }
|
||||||
@@ -133,7 +161,8 @@ class RecorderModule @Inject constructor(
|
|||||||
internalState.updateBlocking {
|
internalState.updateBlocking {
|
||||||
copy(shouldRecord = true)
|
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? {
|
suspend fun stopRecorder(): File? {
|
||||||
@@ -169,6 +198,7 @@ class RecorderModule @Inject constructor(
|
|||||||
internal val recorder: Recorder? = null,
|
internal val recorder: Recorder? = null,
|
||||||
val currentLogDir: File? = null,
|
val currentLogDir: File? = null,
|
||||||
val recordingStartedAt: Long = 0L,
|
val recordingStartedAt: Long = 0L,
|
||||||
|
internal val persistedLogDir: File? = null,
|
||||||
) {
|
) {
|
||||||
val isRecording: Boolean
|
val isRecording: Boolean
|
||||||
get() = recorder != null
|
get() = recorder != null
|
||||||
@@ -177,9 +207,59 @@ class RecorderModule @Inject constructor(
|
|||||||
get() = recorder?.path
|
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 {
|
companion object {
|
||||||
internal val TAG = logTag("Debug", "Log", "Recorder", "Module")
|
internal val TAG = logTag("Debug", "Log", "Recorder", "Module")
|
||||||
private const val FORCE_FILE = "capod_force_debug_run"
|
private const val FORCE_FILE = "capod_force_debug_run"
|
||||||
private const val MIN_RECORDING_MS = 5_000L
|
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.PrivacyPolicy
|
||||||
import eu.darken.capod.common.WebpageTool
|
import eu.darken.capod.common.WebpageTool
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
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.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.debug.recording.core.DebugSession
|
import eu.darken.capod.common.debug.recording.core.DebugSession
|
||||||
@@ -132,8 +133,6 @@ class RecorderActivityVM @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun share() = launch {
|
fun share() = launch {
|
||||||
val currentState = stater.flow.first()
|
|
||||||
val dir = currentState.logDir ?: return@launch
|
|
||||||
val sid = sessionId ?: return@launch
|
val sid = sessionId ?: return@launch
|
||||||
|
|
||||||
stater.updateBlocking { copy(isWorking = true) }
|
stater.updateBlocking { copy(isWorking = true) }
|
||||||
@@ -141,17 +140,21 @@ class RecorderActivityVM @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val uri = sessionManager.getZipUri(sid)
|
val uri = sessionManager.getZipUri(sid)
|
||||||
|
|
||||||
|
val displayName = stater.flow.first().logDir?.name ?: sid
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
type = "application/zip"
|
type = "application/zip"
|
||||||
addCategory(Intent.CATEGORY_DEFAULT)
|
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)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
|
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
|
||||||
events.tryEmit(Event.ShareIntent(chooserIntent))
|
events.tryEmit(Event.ShareIntent(chooserIntent))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, WARN) { "Failed to share session $sid: ${e.message}" }
|
||||||
} finally {
|
} finally {
|
||||||
stater.updateBlocking { copy(isWorking = false) }
|
stater.updateBlocking { copy(isWorking = false) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ private fun BottomActionBar(
|
|||||||
onClick = onKeep,
|
onClick = onKeep,
|
||||||
modifier = Modifier.weight(1f),
|
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(
|
androidx.compose.material3.Button(
|
||||||
onClick = onShare,
|
onClick = onShare,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ enum class Permission(
|
|||||||
@StringRes val labelRes: Int,
|
@StringRes val labelRes: Int,
|
||||||
@StringRes val descriptionRes: Int,
|
@StringRes val descriptionRes: Int,
|
||||||
val permissionId: String,
|
val permissionId: String,
|
||||||
|
val isScanBlocking: Boolean = false,
|
||||||
val isGranted: (Context) -> Boolean = {
|
val isGranted: (Context) -> Boolean = {
|
||||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
||||||
},
|
},
|
||||||
@@ -26,6 +27,7 @@ enum class Permission(
|
|||||||
labelRes = R.string.permission_bluetooth_label,
|
labelRes = R.string.permission_bluetooth_label,
|
||||||
descriptionRes = R.string.permission_bluetooth_description,
|
descriptionRes = R.string.permission_bluetooth_description,
|
||||||
permissionId = "android.permission.BLUETOOTH",
|
permissionId = "android.permission.BLUETOOTH",
|
||||||
|
isScanBlocking = true,
|
||||||
),
|
),
|
||||||
BLUETOOTH_CONNECT(
|
BLUETOOTH_CONNECT(
|
||||||
minApiLevel = Build.VERSION_CODES.S,
|
minApiLevel = Build.VERSION_CODES.S,
|
||||||
@@ -38,6 +40,7 @@ enum class Permission(
|
|||||||
labelRes = R.string.permission_bluetooth_scan_label,
|
labelRes = R.string.permission_bluetooth_scan_label,
|
||||||
descriptionRes = R.string.permission_bluetooth_scan_description,
|
descriptionRes = R.string.permission_bluetooth_scan_description,
|
||||||
permissionId = "android.permission.BLUETOOTH_SCAN",
|
permissionId = "android.permission.BLUETOOTH_SCAN",
|
||||||
|
isScanBlocking = true,
|
||||||
),
|
),
|
||||||
ACCESS_FINE_LOCATION(
|
ACCESS_FINE_LOCATION(
|
||||||
minApiLevel = Build.VERSION_CODES.BASE,
|
minApiLevel = Build.VERSION_CODES.BASE,
|
||||||
@@ -45,6 +48,7 @@ enum class Permission(
|
|||||||
labelRes = R.string.permission_access_fine_location_label,
|
labelRes = R.string.permission_access_fine_location_label,
|
||||||
descriptionRes = R.string.permission_access_fine_location_description,
|
descriptionRes = R.string.permission_access_fine_location_description,
|
||||||
permissionId = "android.permission.ACCESS_FINE_LOCATION",
|
permissionId = "android.permission.ACCESS_FINE_LOCATION",
|
||||||
|
isScanBlocking = true,
|
||||||
),
|
),
|
||||||
ACCESS_BACKGROUND_LOCATION(
|
ACCESS_BACKGROUND_LOCATION(
|
||||||
minApiLevel = Build.VERSION_CODES.Q,
|
minApiLevel = Build.VERSION_CODES.Q,
|
||||||
@@ -73,7 +77,7 @@ enum class Permission(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
POST_NOTIFICATIONS(
|
POST_NOTIFICATIONS(
|
||||||
minApiLevel = Build.VERSION_CODES.S,
|
minApiLevel = Build.VERSION_CODES.TIRAMISU,
|
||||||
labelRes = R.string.permission_post_notifications_label,
|
labelRes = R.string.permission_post_notifications_label,
|
||||||
descriptionRes = R.string.permission_post_notifications_description,
|
descriptionRes = R.string.permission_post_notifications_description,
|
||||||
permissionId = "android.permission.POST_NOTIFICATIONS",
|
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)
|
|
||||||
}
|
|
||||||
+23
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
-116
@@ -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
|
package eu.darken.capod.common.serialization
|
||||||
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import javax.inject.Qualifier
|
import javax.inject.Qualifier
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -14,14 +12,6 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
class SerializationModule {
|
class SerializationModule {
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun moshi(): Moshi = Moshi.Builder().apply {
|
|
||||||
add(JavaInstantAdapter())
|
|
||||||
add(ByteArrayAdapter())
|
|
||||||
add(DeviceProfile.MOSHI_FACTORY)
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@SerializationCapod
|
@SerializationCapod
|
||||||
@@ -29,6 +19,7 @@ class SerializationModule {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
explicitNulls = false
|
explicitNulls = false
|
||||||
|
classDiscriminator = "type"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
package eu.darken.capod.common.theming
|
package eu.darken.capod.common.theming
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class ThemeColor(
|
enum class ThemeColor(
|
||||||
@StringRes val labelRes: Int
|
@StringRes val labelRes: Int
|
||||||
) {
|
) {
|
||||||
@SerialName("theme.color.blue") @Json(name = "theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
|
@SerialName("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.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.amber") AMBER(R.string.ui_theme_color_amber_label),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
package eu.darken.capod.common.theming
|
package eu.darken.capod.common.theming
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class ThemeMode(
|
enum class ThemeMode(
|
||||||
@StringRes val labelRes: Int
|
@StringRes val labelRes: Int
|
||||||
) {
|
) {
|
||||||
@SerialName("theme.mode.system") @Json(name = "theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
|
@SerialName("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.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.light") LIGHT(R.string.ui_theme_mode_light_label),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
package eu.darken.capod.common.theming
|
package eu.darken.capod.common.theming
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class ThemeStyle(
|
enum class ThemeStyle(
|
||||||
@StringRes val labelRes: Int
|
@StringRes val labelRes: Int
|
||||||
) {
|
) {
|
||||||
@SerialName("theme.style.default") @Json(name = "theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
|
@SerialName("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.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.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.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
package eu.darken.capod.main.core
|
package eu.darken.capod.main.core
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class MonitorMode(
|
enum class MonitorMode(
|
||||||
@StringRes val labelRes: Int
|
@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
|
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
|
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
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -42,6 +43,9 @@ class PermissionTool @Inject constructor(
|
|||||||
}
|
}
|
||||||
.onEach { log(TAG) { "Missing permission: $it" } }
|
.onEach { log(TAG) { "Missing permission: $it" } }
|
||||||
|
|
||||||
|
val missingScanPermissions: Flow<Set<Permission>> = missingPermissions
|
||||||
|
.map { perms -> perms.filter { it.isScanBlocking }.toSet() }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("PermissionTool")
|
private val TAG = logTag("PermissionTool")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import androidx.compose.ui.graphics.luminance
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
@@ -78,6 +80,10 @@ class MainActivity : Activity2() {
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator(),
|
||||||
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
navigationEntries.forEach { entry ->
|
navigationEntries.forEach { entry ->
|
||||||
entry.apply {
|
entry.apply {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ fun OverviewScreen(
|
|||||||
) {
|
) {
|
||||||
// 1. Permission cards
|
// 1. Permission cards
|
||||||
items(
|
items(
|
||||||
items = state.permissions.toList(),
|
items = state.permissions.sortedByDescending { it.isScanBlocking },
|
||||||
key = { it.permissionId },
|
key = { it.permissionId },
|
||||||
) { permission ->
|
) { permission ->
|
||||||
PermissionCard(
|
PermissionCard(
|
||||||
@@ -205,21 +205,21 @@ fun OverviewScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Bluetooth disabled card
|
// 2. Bluetooth disabled card
|
||||||
if (!state.isBluetoothEnabled && state.permissions.isEmpty()) {
|
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
|
||||||
item(key = "bluetooth_disabled") {
|
item(key = "bluetooth_disabled") {
|
||||||
BluetoothDisabledCard()
|
BluetoothDisabledCard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. No profiles card
|
// 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") {
|
item(key = "no_profiles") {
|
||||||
NoProfilesCard(onManageDevices = onManageDevices)
|
NoProfilesCard(onManageDevices = onManageDevices)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Profiled device cards
|
// 4. Profiled device cards
|
||||||
if (state.permissions.isEmpty() && state.isBluetoothEnabled) {
|
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||||
items(
|
items(
|
||||||
items = state.profiledDevices,
|
items = state.profiledDevices,
|
||||||
key = { it.identifier.hashCode() },
|
key = { it.identifier.hashCode() },
|
||||||
|
|||||||
@@ -53,18 +53,23 @@ class OverviewViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||||
|
|
||||||
val workerAutolaunch = permissionTool.missingPermissions
|
val workerAutolaunch = permissionTool.missingScanPermissions
|
||||||
.onEach { permissions ->
|
.onEach { permissions ->
|
||||||
if (permissions.isNotEmpty()) {
|
if (permissions.isNotEmpty()) {
|
||||||
log(TAG) { "Missing permissions: $permissions" }
|
log(TAG) { "Missing scan permissions: $permissions" }
|
||||||
return@onEach
|
return@onEach
|
||||||
}
|
}
|
||||||
|
|
||||||
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
|
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
|
||||||
MonitorMode.MANUAL -> false
|
MonitorMode.MANUAL -> false
|
||||||
MonitorMode.AUTOMATIC -> {
|
MonitorMode.AUTOMATIC -> {
|
||||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
try {
|
||||||
devices?.isNotEmpty() == true
|
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
|
MonitorMode.ALWAYS -> true
|
||||||
}
|
}
|
||||||
@@ -82,7 +87,7 @@ class OverviewViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val pods = permissionTool.missingPermissions
|
private val pods = permissionTool.missingScanPermissions
|
||||||
.flatMapLatest { permissions ->
|
.flatMapLatest { permissions ->
|
||||||
if (permissions.isNotEmpty()) {
|
if (permissions.isNotEmpty()) {
|
||||||
return@flatMapLatest flowOf(emptyList())
|
return@flatMapLatest flowOf(emptyList())
|
||||||
@@ -124,6 +129,7 @@ class OverviewViewModel @Inject constructor(
|
|||||||
val upgradeInfo: UpgradeRepo.Info,
|
val upgradeInfo: UpgradeRepo.Info,
|
||||||
val showUnmatchedDevices: Boolean,
|
val showUnmatchedDevices: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||||
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
|
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
|
||||||
val unmatchedDevices: 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.apple.DualApplePods.LidState
|
||||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
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.getSignalQuality
|
||||||
import eu.darken.capod.pods.core.lastSeenFormatted
|
import eu.darken.capod.pods.core.lastSeenFormatted
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@@ -164,7 +166,7 @@ fun DualPodsCard(
|
|||||||
) {
|
) {
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeftPodPercent,
|
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
|
||||||
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
|
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
|
||||||
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
|
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
|
||||||
showEarDetection = device is HasEarDetectionDual,
|
showEarDetection = device is HasEarDetectionDual,
|
||||||
@@ -175,7 +177,7 @@ fun DualPodsCard(
|
|||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRightPodPercent,
|
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
|
||||||
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
|
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
|
||||||
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
|
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
|
||||||
showEarDetection = device is HasEarDetectionDual,
|
showEarDetection = device is HasEarDetectionDual,
|
||||||
@@ -218,7 +220,7 @@ fun DualPodsCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PodGauge(
|
private fun PodGauge(
|
||||||
iconRes: Int,
|
iconRes: Int,
|
||||||
batteryPercent: Float?,
|
batteryPercent: Float,
|
||||||
isCharging: Boolean,
|
isCharging: Boolean,
|
||||||
isInEar: Boolean,
|
isInEar: Boolean,
|
||||||
showEarDetection: Boolean,
|
showEarDetection: Boolean,
|
||||||
@@ -227,15 +229,15 @@ private fun PodGauge(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = if (clamped >= 0f) clamped else 0f,
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "gaugeProgress",
|
label = "gaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
@@ -261,7 +263,7 @@ private fun PodGauge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Progress ring
|
// Progress ring
|
||||||
if (clamped != null) {
|
if (clamped >= 0f) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(80.dp),
|
modifier = Modifier.size(80.dp),
|
||||||
@@ -284,9 +286,9 @@ private fun PodGauge(
|
|||||||
|
|
||||||
// Battery percentage
|
// Battery percentage
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, batteryPercent),
|
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (batteryPercent != null) {
|
color = if (batteryPercent >= 0f) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -335,7 +337,7 @@ private fun CaseRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCasePercent,
|
percent = device.batteryCasePercent.toBatteryFloat(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(8.dp),
|
.height(8.dp),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -24,10 +25,17 @@ fun PermissionCard(
|
|||||||
permission: Permission,
|
permission: Permission,
|
||||||
onRequest: (Permission) -> Unit,
|
onRequest: (Permission) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val colors = if (permission.isScanBlocking) {
|
||||||
|
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
|
||||||
|
} else {
|
||||||
|
CardDefaults.cardColors()
|
||||||
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
|
colors = colors,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
|
|||||||
@@ -40,18 +40,18 @@ private val CapsuleShape = RoundedCornerShape(6.dp)
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BatteryCapsule(
|
fun BatteryCapsule(
|
||||||
percent: Float?,
|
percent: Float,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val clamped = percent?.coerceIn(0f, 1f)
|
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
|
||||||
val animatedFraction by animateFloatAsState(
|
val animatedFraction by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = if (clamped >= 0f) clamped else 0f,
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "batteryFill",
|
label = "batteryFill",
|
||||||
)
|
)
|
||||||
|
|
||||||
val barColor = when {
|
val barColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
@@ -62,7 +62,7 @@ fun BatteryCapsule(
|
|||||||
.clip(CapsuleShape)
|
.clip(CapsuleShape)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
) {
|
) {
|
||||||
if (clamped != null) {
|
if (clamped >= 0f) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Key
|
||||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.twotone.Hearing
|
import androidx.compose.material.icons.twotone.Hearing
|
||||||
|
import androidx.compose.material.icons.twotone.Key
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -44,6 +47,7 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
|||||||
import eu.darken.capod.pods.core.HasChargeDetection
|
import eu.darken.capod.pods.core.HasChargeDetection
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
import eu.darken.capod.pods.core.HasEarDetection
|
||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.pods.core.SinglePodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.ApplePods
|
||||||
import eu.darken.capod.pods.core.firstSeenFormatted
|
import eu.darken.capod.pods.core.firstSeenFormatted
|
||||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.getSignalQuality
|
import eu.darken.capod.pods.core.getSignalQuality
|
||||||
@@ -98,12 +102,23 @@ fun SinglePodsCard(
|
|||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = device.meta.profile?.label ?: "?",
|
Text(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
text = device.meta.profile?.label ?: "?",
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = 1,
|
||||||
)
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (device is ApplePods && device.meta.isIRKMatch) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (device.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = device.getLabel(context),
|
text = device.getLabel(context),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
@@ -138,13 +138,6 @@ fun AcknowledgementsScreen(
|
|||||||
onClick = { onOpenUrl("https://github.com/google/dagger") },
|
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 {
|
item {
|
||||||
SettingsBaseItem(
|
SettingsBaseItem(
|
||||||
title = "Android",
|
title = "Android",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
|
import eu.darken.capod.pods.core.toBatteryOrNull
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -191,7 +192,7 @@ private fun SinglePodPreview(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(state.percent),
|
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
@@ -293,7 +294,7 @@ private fun WidgetContainer(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PodItemRow(
|
private fun PodItemRow(
|
||||||
icon: Int,
|
icon: Int,
|
||||||
percent: Float?,
|
percent: Float,
|
||||||
charging: Boolean,
|
charging: Boolean,
|
||||||
inEar: Boolean,
|
inEar: Boolean,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
@@ -313,7 +314,7 @@ private fun PodItemRow(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent),
|
text = formatPercent(percent.toBatteryOrNull()),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.glance.text.TextAlign
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
|
import eu.darken.capod.pods.core.toBatteryOrNull
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -105,7 +106,7 @@ private fun GlanceSinglePod(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(state.percent),
|
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
@@ -203,7 +204,7 @@ private fun GlanceWidgetRoot(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun GlancePodItem(
|
private fun GlancePodItem(
|
||||||
icon: Int,
|
icon: Int,
|
||||||
percent: Float?,
|
percent: Float,
|
||||||
charging: Boolean,
|
charging: Boolean,
|
||||||
inEar: Boolean,
|
inEar: Boolean,
|
||||||
textStyle: TextStyle,
|
textStyle: TextStyle,
|
||||||
@@ -222,7 +223,7 @@ private fun GlancePodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent),
|
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ sealed class WidgetRenderState {
|
|||||||
val isWide: Boolean,
|
val isWide: Boolean,
|
||||||
val deviceLabel: String?,
|
val deviceLabel: String?,
|
||||||
@DrawableRes val leftIcon: Int,
|
@DrawableRes val leftIcon: Int,
|
||||||
val leftPercent: Float?,
|
val leftPercent: Float,
|
||||||
val leftCharging: Boolean,
|
val leftCharging: Boolean,
|
||||||
val leftInEar: Boolean,
|
val leftInEar: Boolean,
|
||||||
@DrawableRes val rightIcon: Int,
|
@DrawableRes val rightIcon: Int,
|
||||||
val rightPercent: Float?,
|
val rightPercent: Float,
|
||||||
val rightCharging: Boolean,
|
val rightCharging: Boolean,
|
||||||
val rightInEar: Boolean,
|
val rightInEar: Boolean,
|
||||||
@DrawableRes val caseIcon: Int,
|
@DrawableRes val caseIcon: Int,
|
||||||
val casePercent: Float?,
|
val casePercent: Float,
|
||||||
val caseCharging: Boolean,
|
val caseCharging: Boolean,
|
||||||
) : WidgetRenderState()
|
) : WidgetRenderState()
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ sealed class WidgetRenderState {
|
|||||||
@ColorInt override val resolvedIconColor: Int,
|
@ColorInt override val resolvedIconColor: Int,
|
||||||
val deviceLabel: String?,
|
val deviceLabel: String?,
|
||||||
@DrawableRes val headsetIcon: Int,
|
@DrawableRes val headsetIcon: Int,
|
||||||
val percent: Float?,
|
val percent: Float,
|
||||||
@DrawableRes val batteryIcon: Int,
|
@DrawableRes val batteryIcon: Int,
|
||||||
val charging: Boolean,
|
val charging: Boolean,
|
||||||
val worn: 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.PodDevice
|
||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.pods.core.SinglePodDevice
|
||||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||||
|
import eu.darken.capod.pods.core.toBatteryFloat
|
||||||
|
|
||||||
object WidgetRenderStateMapper {
|
object WidgetRenderStateMapper {
|
||||||
|
|
||||||
@@ -46,15 +47,15 @@ object WidgetRenderStateMapper {
|
|||||||
isWide = isWide,
|
isWide = isWide,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
leftIcon = device.leftPodIcon,
|
leftIcon = device.leftPodIcon,
|
||||||
leftPercent = device.batteryLeftPodPercent,
|
leftPercent = device.batteryLeftPodPercent.toBatteryFloat(),
|
||||||
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
|
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
|
||||||
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
|
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
|
||||||
rightIcon = device.rightPodIcon,
|
rightIcon = device.rightPodIcon,
|
||||||
rightPercent = device.batteryRightPodPercent,
|
rightPercent = device.batteryRightPodPercent.toBatteryFloat(),
|
||||||
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
|
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
|
||||||
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
|
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
|
||||||
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
|
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,
|
caseCharging = device is HasCase && device.isCaseCharging,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ object WidgetRenderStateMapper {
|
|||||||
resolvedIconColor = iconColor,
|
resolvedIconColor = iconColor,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
headsetIcon = device.iconRes,
|
headsetIcon = device.iconRes,
|
||||||
percent = device.batteryHeadsetPercent,
|
percent = device.batteryHeadsetPercent.toBatteryFloat(),
|
||||||
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
|
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
|
||||||
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
|
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
|
||||||
worn = device is HasEarDetection && device.isBeingWorn,
|
worn = device is HasEarDetection && device.isBeingWorn,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package eu.darken.capod.monitor.core
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import com.squareup.moshi.adapter
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
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.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.common.serialization.SerializationCapod
|
||||||
import eu.darken.capod.profiles.core.ProfileId
|
import eu.darken.capod.profiles.core.ProfileId
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -23,12 +23,11 @@ import javax.inject.Singleton
|
|||||||
class PodDeviceCache @Inject constructor(
|
class PodDeviceCache @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val dispatcherProvider: DispatcherProvider,
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
moshi: Moshi,
|
@SerializationCapod private val json: Json,
|
||||||
) {
|
) {
|
||||||
private val cacheDir by lazy {
|
private val cacheDir by lazy {
|
||||||
File(context.cacheDir, "device_cache").apply { mkdirs() }
|
File(context.cacheDir, "device_cache").apply { mkdirs() }
|
||||||
}
|
}
|
||||||
private val jsonAdapter = moshi.adapter<BleScanResult>()
|
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
|
|
||||||
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
|
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
|
||||||
@@ -40,7 +39,7 @@ class PodDeviceCache @Inject constructor(
|
|||||||
if (!cacheFile.exists()) return@withLock null
|
if (!cacheFile.exists()) return@withLock null
|
||||||
try {
|
try {
|
||||||
val raw = cacheFile.readText()
|
val raw = cacheFile.readText()
|
||||||
jsonAdapter.fromJson(raw)
|
json.decodeFromString<BleScanResult>(raw)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
|
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
|
||||||
cacheFile.delete()
|
cacheFile.delete()
|
||||||
@@ -56,8 +55,8 @@ class PodDeviceCache @Inject constructor(
|
|||||||
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
|
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
|
||||||
val cacheFile = id.toCacheFile()
|
val cacheFile = id.toCacheFile()
|
||||||
try {
|
try {
|
||||||
val json = jsonAdapter.toJson(device)
|
val encoded = json.encodeToString(BleScanResult.serializer(), device)
|
||||||
cacheFile.writeText(json)
|
cacheFile.writeText(encoded)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
|
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
|
||||||
cacheFile.delete()
|
cacheFile.delete()
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ class PodMonitor @Inject constructor(
|
|||||||
private val cacheLock = Mutex()
|
private val cacheLock = Mutex()
|
||||||
|
|
||||||
val devices: Flow<List<PodDevice>> = combine(
|
val devices: Flow<List<PodDevice>> = combine(
|
||||||
permissionTool.missingPermissions,
|
permissionTool.missingScanPermissions,
|
||||||
bluetoothManager.isBluetoothEnabled
|
bluetoothManager.isBluetoothEnabled
|
||||||
) { missingPermissions, isBluetoothEnabled ->
|
) { missingScanPermissions, isBluetoothEnabled ->
|
||||||
log(TAG) { "devices: missingPermissions=$missingPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
|
log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
|
||||||
missingPermissions.isEmpty() && isBluetoothEnabled
|
missingScanPermissions.isEmpty() && isBluetoothEnabled
|
||||||
}
|
}
|
||||||
.flatMapLatest { isReady ->
|
.flatMapLatest { isReady ->
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ class BluetoothEventReceiver : BroadcastReceiver() {
|
|||||||
} else {
|
} else {
|
||||||
log { "Event related to $bluetoothDevice" }
|
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()) {
|
if (supportedFeatures.isEmpty()) {
|
||||||
log(TAG) { "Device has no features we support." }
|
log(TAG) { "Device has no features we support." }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class MonitorControl @Inject constructor(
|
|||||||
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
|
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
|
||||||
|
|
||||||
val hasBluetoothPermission =
|
val hasBluetoothPermission =
|
||||||
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context)
|
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_SCAN.isGranted(context)
|
||||||
if (!hasBluetoothPermission) {
|
if (!hasBluetoothPermission) {
|
||||||
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
|
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -114,7 +114,15 @@ class MonitorService : Service() {
|
|||||||
return
|
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()" }
|
log(TAG, VERBOSE) { "onCreate()" }
|
||||||
|
|
||||||
// Replace early notification with the full one from injected MonitorNotifications.
|
// Replace early notification with the full one from injected MonitorNotifications.
|
||||||
@@ -162,9 +170,9 @@ class MonitorService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun doMonitor() {
|
private suspend fun doMonitor() {
|
||||||
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
|
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
|
||||||
if (permissionsMissingOnStart.isNotEmpty()) {
|
if (permissionsMissingOnStart.isNotEmpty()) {
|
||||||
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
|
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,10 +198,10 @@ class MonitorService : Service() {
|
|||||||
}
|
}
|
||||||
.launchIn(monitorScope)
|
.launchIn(monitorScope)
|
||||||
|
|
||||||
permissionTool.missingPermissions
|
permissionTool.missingScanPermissions
|
||||||
.flatMapLatest { missingPermsFlow ->
|
.flatMapLatest { missingPermsFlow ->
|
||||||
if (missingPermsFlow.isNotEmpty()) {
|
if (missingPermsFlow.isNotEmpty()) {
|
||||||
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
|
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
|
||||||
monitorScope.coroutineContext.cancelChildren()
|
monitorScope.coroutineContext.cancelChildren()
|
||||||
emptyFlow()
|
emptyFlow()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package eu.darken.capod.pods.core
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
@@ -77,112 +75,111 @@ interface PodDevice {
|
|||||||
value class Id(private val id: UUID = UUID.randomUUID())
|
value class Id(private val id: UUID = UUID.randomUUID())
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class Model(
|
enum class Model(
|
||||||
val label: String,
|
val label: String,
|
||||||
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
|
@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)",
|
label = "AirPods (Gen 1)",
|
||||||
iconRes = R.drawable.device_airpods_gen1_both,
|
iconRes = R.drawable.device_airpods_gen1_both,
|
||||||
),
|
),
|
||||||
@SerialName("airpods.gen2") @Json(name = "airpods.gen2") AIRPODS_GEN2(
|
@SerialName("airpods.gen2") AIRPODS_GEN2(
|
||||||
"AirPods (Gen 2)",
|
"AirPods (Gen 2)",
|
||||||
R.drawable.device_airpods_gen1_both,
|
R.drawable.device_airpods_gen1_both,
|
||||||
),
|
),
|
||||||
@SerialName("airpods.gen3") @Json(name = "airpods.gen3") AIRPODS_GEN3(
|
@SerialName("airpods.gen3") AIRPODS_GEN3(
|
||||||
"AirPods (Gen 3)",
|
"AirPods (Gen 3)",
|
||||||
R.drawable.device_airpods_gen3_both,
|
R.drawable.device_airpods_gen3_both,
|
||||||
),
|
),
|
||||||
@SerialName("airpods.gen4") @Json(name = "airpods.gen4") AIRPODS_GEN4(
|
@SerialName("airpods.gen4") AIRPODS_GEN4(
|
||||||
"AirPods (Gen 4)",
|
"AirPods (Gen 4)",
|
||||||
R.drawable.device_airpods_gen3_both,
|
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)",
|
"AirPods (Gen 4 ANC)",
|
||||||
R.drawable.device_airpods_gen4anc_both,
|
R.drawable.device_airpods_gen4anc_both,
|
||||||
),
|
),
|
||||||
@SerialName("airpods.pro") @Json(name = "airpods.pro") AIRPODS_PRO(
|
@SerialName("airpods.pro") AIRPODS_PRO(
|
||||||
"AirPods Pro",
|
"AirPods Pro",
|
||||||
R.drawable.device_airpods_pro2_both
|
R.drawable.device_airpods_pro2_both
|
||||||
),
|
),
|
||||||
@SerialName("airpods.pro2") @Json(name = "airpods.pro2") AIRPODS_PRO2(
|
@SerialName("airpods.pro2") AIRPODS_PRO2(
|
||||||
"AirPods Pro 2",
|
"AirPods Pro 2",
|
||||||
R.drawable.device_airpods_pro2_both
|
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",
|
"AirPods Pro 2 USB-C",
|
||||||
R.drawable.device_airpods_pro2_both
|
R.drawable.device_airpods_pro2_both
|
||||||
),
|
),
|
||||||
@SerialName("airpods.pro3") @Json(name = "airpods.pro3") AIRPODS_PRO3(
|
@SerialName("airpods.pro3") AIRPODS_PRO3(
|
||||||
"AirPods Pro 3",
|
"AirPods Pro 3",
|
||||||
R.drawable.device_airpods_pro2_both
|
R.drawable.device_airpods_pro2_both
|
||||||
),
|
),
|
||||||
@SerialName("airpods.max") @Json(name = "airpods.max") AIRPODS_MAX(
|
@SerialName("airpods.max") AIRPODS_MAX(
|
||||||
"AirPods Max",
|
"AirPods Max",
|
||||||
R.drawable.device_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",
|
"AirPods Max USB-C",
|
||||||
R.drawable.device_airpods_max
|
R.drawable.device_airpods_max
|
||||||
),
|
),
|
||||||
@SerialName("beats.flex") @Json(name = "beats.flex") BEATS_FLEX(
|
@SerialName("beats.flex") BEATS_FLEX(
|
||||||
"Beats Flex",
|
"Beats Flex",
|
||||||
R.drawable.device_beats_earbuds,
|
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",
|
"Beats Solo 3",
|
||||||
R.drawable.device_beats_headphones,
|
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",
|
"Beats Studio 3",
|
||||||
R.drawable.device_beats_studio3,
|
R.drawable.device_beats_studio3,
|
||||||
),
|
),
|
||||||
@SerialName("beats.x") @Json(name = "beats.x") BEATS_X(
|
@SerialName("beats.x") BEATS_X(
|
||||||
"Beats X",
|
"Beats X",
|
||||||
R.drawable.device_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",
|
"Power Beats 3",
|
||||||
R.drawable.device_powerbeats_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",
|
"Power Beats 4",
|
||||||
R.drawable.device_powerbeats_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",
|
"Power Beats Pro",
|
||||||
R.drawable.device_powerbeats_pro_both,
|
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",
|
"Power Beats Pro 2",
|
||||||
R.drawable.device_powerbeats_pro2_both,
|
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",
|
"Beats Fit Pro",
|
||||||
R.drawable.device_beats_fitpro_both,
|
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",
|
"AirPods (Gen 1)? \uD83C\uDFAD",
|
||||||
R.drawable.device_airpods_gen1_both,
|
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",
|
"AirPods (Gen 2)? \uD83C\uDFAD",
|
||||||
R.drawable.device_airpods_gen1_both,
|
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",
|
"AirPods (Gen 3)? \uD83C\uDFAD",
|
||||||
R.drawable.device_airpods_gen3_both,
|
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",
|
"AirPods Pro? \uD83C\uDFAD",
|
||||||
R.drawable.device_airpods_pro2_both,
|
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",
|
"AirPods Pro2? \uD83C\uDFAD",
|
||||||
R.drawable.device_airpods_pro2_both,
|
R.drawable.device_airpods_pro2_both,
|
||||||
),
|
),
|
||||||
@SerialName("unknown") @Json(name = "unknown") UNKNOWN(
|
@SerialName("unknown") UNKNOWN(
|
||||||
"Unknown"
|
"Unknown"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import kotlin.math.roundToInt
|
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 =
|
fun formatBatteryPercent(context: Context, percent: Float?): String =
|
||||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
percent?.let { "${(it * 100).roundToInt()}%" }
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
?: context.getString(R.string.general_value_not_available_label)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package eu.darken.capod.profiles.core
|
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.common.serialization.ByteArrayBase64Serializer
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
|
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
|
||||||
@@ -14,14 +12,13 @@ import java.util.UUID
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("apple")
|
@SerialName("apple")
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class AppleDeviceProfile(
|
data class AppleDeviceProfile(
|
||||||
@Json(name = "id") override val id: ProfileId = UUID.randomUUID().toString(),
|
@SerialName("id") override val id: ProfileId = UUID.randomUUID().toString(),
|
||||||
@Json(name = "label") override val label: String,
|
@SerialName("label") override val label: String,
|
||||||
@Json(name = "priority") override val priority: Int = 0,
|
@SerialName("priority") override val priority: Int = 0,
|
||||||
@Json(name = "model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
|
@SerialName("model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
|
||||||
@Json(name = "minimumSignalQuality") override val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY,
|
@SerialName("minimumSignalQuality") override val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY,
|
||||||
@Serializable(with = ByteArrayBase64Serializer::class) @Json(name = "identityKey") val identityKey: IdentityResolvingKey? = null,
|
@SerialName("identityKey") @Serializable(with = ByteArrayBase64Serializer::class) val identityKey: IdentityResolvingKey? = null,
|
||||||
@Serializable(with = ByteArrayBase64Serializer::class) @Json(name = "encryptionKey") val encryptionKey: ProximityEncryptionKey? = null,
|
@SerialName("encryptionKey") @Serializable(with = ByteArrayBase64Serializer::class) val encryptionKey: ProximityEncryptionKey? = null,
|
||||||
@Json(name = "address") override val address: String? = null,
|
@SerialName("address") override val address: String? = null,
|
||||||
) : DeviceProfile
|
) : DeviceProfile
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package eu.darken.capod.profiles.core
|
package eu.darken.capod.profiles.core
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -17,8 +15,5 @@ sealed interface DeviceProfile : Parcelable {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_MINIMUM_SIGNAL_QUALITY = 0.15f
|
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
|
package eu.darken.capod.profiles.core
|
||||||
|
|
||||||
import com.squareup.moshi.Json
|
import kotlinx.serialization.SerialName
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = true)
|
|
||||||
data class DeviceProfilesContainer(
|
data class DeviceProfilesContainer(
|
||||||
@Json(name = "profiles") val profiles: List<DeviceProfile> = emptyList()
|
@SerialName("profiles") val profiles: List<DeviceProfile> = emptyList()
|
||||||
)
|
)
|
||||||
+3
-3
@@ -30,7 +30,7 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MenuAnchorType
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -343,7 +343,7 @@ private fun DeviceInfoCard(
|
|||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(
|
||||||
expanded = modelExpanded,
|
expanded = modelExpanded,
|
||||||
@@ -395,7 +395,7 @@ private fun DeviceInfoCard(
|
|||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceExpanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceExpanded) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(
|
||||||
expanded = deviceExpanded,
|
expanded = deviceExpanded,
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ class AutoConnect @Inject constructor(
|
|||||||
log(TAG) { "Auto connect condition ($condition) is not fullfilled: ${decision.reason}" }
|
log(TAG) { "Auto connect condition ($condition) is not fullfilled: ${decision.reason}" }
|
||||||
return@map
|
return@map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!bluetoothManager.isNudgeAvailable) {
|
||||||
|
log(TAG, WARN) { "nudgeConnection is not available on this device, skipping" }
|
||||||
|
return@map
|
||||||
|
}
|
||||||
|
|
||||||
val result = bluetoothManager.nudgeConnection(bondedDevice)
|
val result = bluetoothManager.nudgeConnection(bondedDevice)
|
||||||
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
|
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
package eu.darken.capod.reaction.core.autoconnect
|
package eu.darken.capod.reaction.core.autoconnect
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class AutoConnectCondition(
|
enum class AutoConnectCondition(
|
||||||
val identifier: String,
|
val identifier: String,
|
||||||
@StringRes val labelRes: Int
|
@StringRes val labelRes: Int
|
||||||
) {
|
) {
|
||||||
@SerialName("autoconnect.condition.seen") @Json(name = "autoconnect.condition.seen") WHEN_SEEN(
|
@SerialName("autoconnect.condition.seen") WHEN_SEEN(
|
||||||
"monitor.mode.manual",
|
"monitor.mode.manual",
|
||||||
R.string.settings_reaction_autoconnect_whenseen_label
|
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",
|
"autoconnect.condition.case",
|
||||||
R.string.settings_reaction_autoconnect_caseopen_label
|
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",
|
"autoconnect.condition.inear",
|
||||||
R.string.settings_reaction_autoconnect_inear_label
|
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.SinglePodDevice
|
||||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
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
|
import eu.darken.capod.pods.core.getSignalQuality
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -127,7 +129,7 @@ private fun DualPodContent(device: DualPodDevice) {
|
|||||||
// Left pod
|
// Left pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeftPodPercent,
|
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ private fun DualPodContent(device: DualPodDevice) {
|
|||||||
if (hasCase != null) {
|
if (hasCase != null) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = hasCase.caseIcon,
|
iconRes = hasCase.caseIcon,
|
||||||
batteryPercent = hasCase.batteryCasePercent,
|
batteryPercent = hasCase.batteryCasePercent.toBatteryFloat(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,7 @@ private fun DualPodContent(device: DualPodDevice) {
|
|||||||
// Right pod
|
// Right pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRightPodPercent,
|
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,17 +155,18 @@ private fun DualPodContent(device: DualPodDevice) {
|
|||||||
private fun SinglePodContent(device: SinglePodDevice) {
|
private fun SinglePodContent(device: SinglePodDevice) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.iconRes,
|
iconRes = device.iconRes,
|
||||||
batteryPercent = device.batteryHeadsetPercent,
|
batteryPercent = device.batteryHeadsetPercent.toBatteryFloat(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BatteryColumn(
|
private fun BatteryColumn(
|
||||||
iconRes: Int,
|
iconRes: Int,
|
||||||
batteryPercent: Float?,
|
batteryPercent: Float,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val nullablePercent = batteryPercent.toBatteryOrNull()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -183,13 +186,13 @@ private fun BatteryColumn(
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(getBatteryDrawable(batteryPercent)),
|
painter = painterResource(getBatteryDrawable(nullablePercent)),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, batteryPercent),
|
text = formatBatteryPercent(context, nullablePercent),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -312,10 +312,10 @@
|
|||||||
<string name="debug_debuglog_screen_log_files_label">ملفات السجلّ</string>
|
<string name="debug_debuglog_screen_log_files_label">ملفات السجلّ</string>
|
||||||
<plurals name="debug_debuglog_screen_log_files_ready">
|
<plurals name="debug_debuglog_screen_log_files_ready">
|
||||||
<item quantity="zero">%1$d ملف جاهز (%2$s)</item>
|
<item quantity="zero">%1$d ملف جاهز (%2$s)</item>
|
||||||
<item quantity="one">ملف جاهز (%2$s)</item>
|
<item quantity="one">%1$d ملف جاهز (%2$s)</item>
|
||||||
<item quantity="two">ملفان جاهزان (%2$s)</item>
|
<item quantity="two">%1$d ملفان جاهزان (%2$s)</item>
|
||||||
<item quantity="few">%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>
|
<item quantity="other">%1$d ملف جاهز (%2$s)</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="debug_debuglog_screen_discard_action">حذف</string>
|
<string name="debug_debuglog_screen_discard_action">حذف</string>
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimrirà permanentment.</string>
|
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimrirà permanentment.</string>
|
||||||
<!-- Post-share confirmation -->
|
<!-- Post-share confirmation -->
|
||||||
<string name="support_debuglog_sent_title">S\'ha enviat el registre?</string>
|
<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_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>
|
</resources>
|
||||||
|
|||||||
@@ -366,7 +366,7 @@
|
|||||||
<string name="support_contact_debuglog_delete_message">Tento log ladění bude trvale smazán.</string>
|
<string name="support_contact_debuglog_delete_message">Tento log ladění bude trvale smazán.</string>
|
||||||
<!-- Post-share confirmation -->
|
<!-- Post-share confirmation -->
|
||||||
<string name="support_debuglog_sent_title">Log odeslán?</string>
|
<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_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>
|
</resources>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<string name="upgrade_screen_subscription_action_hint">%s/Jahr</string>
|
<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">Einmalig kaufen</string>
|
||||||
<string name="upgrade_screen_iap_action_hint">Einmaliger Kauf: %s</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_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_troubleshooting_msg">Wenn du kürzlich gekauft hast, kann es einen Moment dauern, bis Google Play synchronisiert.</string>
|
||||||
<string name="upgrade_screen_restore_sync_patience_hint">Versuche es in wenigen Minuten erneut, falls dein Kauf nicht erscheint.</string>
|
<string name="upgrade_screen_restore_sync_patience_hint">Versuche es in wenigen Minuten erneut, falls dein Kauf nicht erscheint.</string>
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
<string name="support_contact_debuglog_delete_message">Este registro de depuración se eliminará permanentemente.</string>
|
<string name="support_contact_debuglog_delete_message">Este registro de depuración se eliminará permanentemente.</string>
|
||||||
<!-- Post-share confirmation -->
|
<!-- Post-share confirmation -->
|
||||||
<string name="support_debuglog_sent_title">¿Registro enviado?</string>
|
<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 eliminará.</string>
|
<string name="support_debuglog_sent_message">¿Se envió el registro de depuración con éxito? El registro será eliminado.</string>
|
||||||
<string name="support_contact_sent_title">¿Correo enviado?</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>
|
<string name="support_contact_sent_message">¿Se envió el correo electrónico correctamente? El registro de depuración adjunto se eliminará.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -267,7 +267,7 @@
|
|||||||
<string name="profiles_key_status_configured">Seadistatud</string>
|
<string name="profiles_key_status_configured">Seadistatud</string>
|
||||||
<string name="profiles_key_status_not_set">Määramata</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_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>
|
<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 -->
|
<!-- Theme Mode -->
|
||||||
<string name="ui_theme_mode_label">Teema režiim</string>
|
<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_title">Kustuta silumislogi?</string>
|
||||||
<string name="support_contact_debuglog_delete_message">See silumislogi kustutatakse jäädavalt.</string>
|
<string name="support_contact_debuglog_delete_message">See silumislogi kustutatakse jäädavalt.</string>
|
||||||
<!-- Post-share confirmation -->
|
<!-- 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_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_title">Meil saadetud?</string>
|
||||||
<string name="support_contact_sent_message">Kas e-kiri saadeti edukalt? Manustatud silumislogi kustutatakse.</string>
|
<string name="support_contact_sent_message">Kas meil saadeti edukalt? Manustatud silumislogi kustutatakse.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<string name="help_translate_label">ترجمه</string>
|
<string name="help_translate_label">ترجمه</string>
|
||||||
<string name="help_translate_description">به ترجمه این برنامه به زبان موردعلاقه خود کمک کنید.</string>
|
<string name="help_translate_description">به ترجمه این برنامه به زبان موردعلاقه خود کمک کنید.</string>
|
||||||
<string name="translators_thanks_title">مترجمان</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_description">ویجتی که آخرین وضعیت شناختهشده دستگاه را نشان میدهد.</string>
|
||||||
<string name="widget_config_screen_title">پیکربندی ابزارک</string>
|
<string name="widget_config_screen_title">پیکربندی ابزارک</string>
|
||||||
<string name="widget_configuration_title">انتخاب دستگاه</string>
|
<string name="widget_configuration_title">انتخاب دستگاه</string>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<string name="general_copy_action">Copier</string>
|
<string name="general_copy_action">Copier</string>
|
||||||
<string name="general_thank_you_label">Merci</string>
|
<string name="general_thank_you_label">Merci</string>
|
||||||
<string name="general_upgrade_action">Passer Pro</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 l’option.</string>
|
||||||
<string name="general_donate_action">Faire un don</string>
|
<string name="general_donate_action">Faire un don</string>
|
||||||
<string name="general_check_action">Vérifier</string>
|
<string name="general_check_action">Vérifier</string>
|
||||||
<string name="general_close_action">Fermer</string>
|
<string name="general_close_action">Fermer</string>
|
||||||
@@ -18,24 +18,24 @@
|
|||||||
<string name="general_example_label">Exemple : %s</string>
|
<string name="general_example_label">Exemple : %s</string>
|
||||||
<string name="upgrade_capod_label">Surclasser CAPod</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_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 l’appli.</string>
|
||||||
<string name="upgrade_benefit_themes">Personnalisation du thème</string>
|
<string name="upgrade_benefit_themes">Personnalisation du thème</string>
|
||||||
<string name="upgrade_benefit_autoplay">Lecture automatique & 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_autoconnect">Connexion automatique</string>
|
||||||
<string name="upgrade_benefit_popups">Popup à l\'ouverture de l\'étui & connexion</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 de l\'écran d\'accueil</string>
|
<string name="upgrade_benefit_widgets">Widgets sur l’écran d’accueil</string>
|
||||||
<string name="upgrade_benefit_support">Soutenir le développeur</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_options_description">Mêmes fonctions, tarification différente. L’abonnement comprend un essai gratuit et peut être annulé n’importe quand.</string>
|
||||||
<string name="upgrade_screen_subscription_trial_action">Commencez votre essai gratuit</string>
|
<string name="upgrade_screen_subscription_trial_action">Commencer l’essai gratuit</string>
|
||||||
<string name="upgrade_screen_subscription_action">S\'abonner annuellement</string>
|
<string name="upgrade_screen_subscription_action">Abonnement annuel</string>
|
||||||
<string name="upgrade_screen_subscription_action_hint">%s/an</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">Acheter une fois</string>
|
||||||
<string name="upgrade_screen_iap_action_hint">Achat unique : %s</string>
|
<string name="upgrade_screen_iap_action_hint">Achat unique : %s</string>
|
||||||
<string name="upgrade_screen_restore_purchase_action">Rétablir l’achat</string>
|
<string name="upgrade_screen_restore_purchase_action">Rétablir l’achat</string>
|
||||||
<string name="upgrade_screen_restore_purchase_message">Aucun achat n’a été trouvé. Utilisez-vous le bon compte ?</string>
|
<string name="upgrade_screen_restore_purchase_message">Aucun achat n’a é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_troubleshooting_msg">Si l’achat est récent, Google Play peut mettre du temps à synchroniser.</string>
|
||||||
<string name="upgrade_screen_restore_sync_patience_hint">Réessayez dans quelques minutes si votre achat n’apparaît pas.</string>
|
<string name="upgrade_screen_restore_sync_patience_hint">Si votre achat n’apparaît pas, réessayez dans quelques minutes.</string>
|
||||||
<string name="upgrade_screen_restore_multiaccount_hint">Assurez-vous d’être connecté avec le même compte Google utilisé pour l’achat.</string>
|
<string name="upgrade_screen_restore_multiaccount_hint">Le même compte Google doit être utilisé pour l’achat et la connexion.</string>
|
||||||
<string name="settings_monitor_mode_label">Mode surveillance</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 l’appli.</string>
|
<string name="settings_monitor_mode_description">Les circonstances de surveillance des données Bluetooth par l’appli.</string>
|
||||||
<string name="settings_monitor_connected_notification_label">Notification supplémentaire</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_label">Assistance</string>
|
||||||
<string name="settings_support_description">Si vous avez besoin d’aide.</string>
|
<string name="settings_support_description">Si vous avez besoin d’aide.</string>
|
||||||
<string name="settings_wiki_label">Wiki</string>
|
<string name="settings_wiki_label">Wiki</string>
|
||||||
<string name="settings_wiki_description">FAQ & 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_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="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>
|
<string name="discord_label">Discord</string>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<string name="widget_configuration_title">Choisir un appareil</string>
|
<string name="widget_configuration_title">Choisir un appareil</string>
|
||||||
<string name="widget_configuration_description">Choisissez le profil d’appareil que ce widget doit afficher.</string>
|
<string name="widget_configuration_description">Choisissez le profil d’appareil que ce widget doit afficher.</string>
|
||||||
<string name="widget_no_data_label">Aucune donnée</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_appearance_label">Apparence</string>
|
||||||
<string name="widget_config_preset_label">Préréglages</string>
|
<string name="widget_config_preset_label">Préréglages</string>
|
||||||
<string name="widget_config_background_color_label">Couleur d\'arrière-plan</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 l’intégralité du message. Un accès ponctuel à un MacBook est nécessaire.</string>
|
<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 l’inté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_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_expected_format">Format attendu : %1$s</string>
|
||||||
<string name="profiles_key_status_configured">Configuré</string>
|
<string name="profiles_key_status_configured">Configurée</string>
|
||||||
<string name="profiles_key_status_not_set">Non défini</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_min_label">Désactivé</string>
|
||||||
<string name="profiles_signal_quality_max_label">Strict</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>
|
<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_discard_action">Abandonner</string>
|
||||||
<string name="general_keep_editing_action">Continuer de modifier</string>
|
<string name="general_keep_editing_action">Continuer de modifier</string>
|
||||||
<!-- Short recording warning -->
|
<!-- Short recording warning -->
|
||||||
<string name="debug_debuglog_short_recording_title">Enregistrement trop court</string>
|
<string name="debug_debuglog_short_recording_title">L’enregistrement 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. Continuez l’enregistrement, reproduisez le problème, puis arrêtez l’enregistrement.</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 l’enregistrement, reproduisez le problème, puis arrêtez l’enregistrement.</string>
|
||||||
<string name="debug_debuglog_short_recording_continue">Continuer l’enregistrement</string>
|
<string name="debug_debuglog_short_recording_continue">Poursuivre l’enregistrement</string>
|
||||||
<string name="debug_debuglog_short_recording_stop">Arrêter quand même</string>
|
<string name="debug_debuglog_short_recording_stop">Arrêter quand même</string>
|
||||||
<!-- Recorder screen -->
|
<!-- Recorder screen -->
|
||||||
<string name="debug_debuglog_screen_title">Journal de débogage</string>
|
<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_subtitle">Exporter les renseignements de débogage pour le dépannage</string>
|
||||||
<string name="debug_debuglog_screen_sensitive_title">Informations sensibles</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_session_path_label">Chemin de la session</string>
|
||||||
<string name="debug_debuglog_screen_log_files_label">Fichiers journaux</string>
|
<string name="debug_debuglog_screen_log_files_label">Fichiers journaux</string>
|
||||||
<plurals name="debug_debuglog_screen_log_files_ready">
|
<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="one">%1$d journal de débogage (%2$s)</item>
|
||||||
<item quantity="other">%1$d journaux de débogage (%2$s)</item>
|
<item quantity="other">%1$d journaux de débogage (%2$s)</item>
|
||||||
</plurals>
|
</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_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_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_sessions_empty">Aucune session de débogage</string>
|
||||||
<string name="support_debuglog_session_recording">Enregistrement…</string>
|
<string name="support_debuglog_session_recording">Enregistrement…</string>
|
||||||
<string name="support_debuglog_session_compressing">Compression…</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_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_session_delete_message">Cette session de débogage sera supprimée irrémédiablement.</string>
|
||||||
<string name="support_debuglog_clear_all_message">Toutes les sessions de débogage seront définitivement supprimées.</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_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_corrupt_zip">Échec : le fichier zip est corrompu</string>
|
||||||
<string name="support_debuglog_failed_zip_failed">Échec : la compression a échoué</string>
|
<string name="support_debuglog_failed_zip_failed">Échec : la compression a échoué</string>
|
||||||
<!-- Contact form -->
|
<!-- Contact form -->
|
||||||
<string name="support_contact_label">Contacter le développeur</string>
|
<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_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 j’assure 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 l’appareil 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_footer">Votre message sera envoyé par courriel. Les renseignements sur l’appareil 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_label">Catégorie</string>
|
||||||
<string name="support_contact_category_question_label">Question</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_feature_label">Demande de fonction</string>
|
||||||
<string name="support_contact_category_bug_label">Relevé de bogue</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_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_question_hint">Rédigez précisément votre question.</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_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 ce qui s’est passé et comment reproduire le problème. Veuillez être précis.</string>
|
<string name="support_contact_description_bug_hint">Décrivez précisément ce qui s’est passé et comment reproduire le problème.</string>
|
||||||
<string name="support_contact_expected_label">Comportement attendu</string>
|
<string name="support_contact_expected_label">Comportement attendu</string>
|
||||||
<string name="support_contact_expected_hint">Décrivez ce que vous attendiez qu’il se passe.</string>
|
<string name="support_contact_expected_hint">Décrivez ce à quoi vous vous attendiez.</string>
|
||||||
<plurals name="support_contact_word_count">
|
<plurals name="support_contact_word_count">
|
||||||
<item quantity="one">%1$d / %2$d mot</item>
|
<item quantity="one">%1$d / %2$d mot</item>
|
||||||
<item quantity="other">%1$d / %2$d mots</item>
|
<item quantity="other">%1$d / %2$d mots</item>
|
||||||
@@ -355,10 +355,10 @@
|
|||||||
<string name="support_contact_send_action">Ouvrir l’appli de courriel</string>
|
<string name="support_contact_send_action">Ouvrir l’appli de courriel</string>
|
||||||
<string name="support_contact_no_email_app">Aucune appli de courriel n’a été trouvée sur cet appareil.</string>
|
<string name="support_contact_no_email_app">Aucune appli de courriel n’a é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_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 -->
|
<!-- Post-share confirmation -->
|
||||||
<string name="support_debuglog_sent_title">Journal envoyé ?</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é avec succès ? Le journal sera supprimé.</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">E-mail envoyé ?</string>
|
<string name="support_contact_sent_title">Le courriel a été 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_contact_sent_message">Le courriel a-t-il été envoyé ? Le journal de débogage joint sera supprimé.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
<string name="ui_theme_color_label">Boja teme</string>
|
<string name="ui_theme_color_label">Boja teme</string>
|
||||||
<string name="ui_theme_color_blue_label">Plava</string>
|
<string name="ui_theme_color_blue_label">Plava</string>
|
||||||
<string name="ui_theme_color_green_label">Zelena</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>
|
<string name="ui_theme_color_disabled_materialyou">Koristi boje pozadine</string>
|
||||||
<!-- Settings categories -->
|
<!-- Settings categories -->
|
||||||
<string name="settings_category_monitoring_label">Praćenje</string>
|
<string name="settings_category_monitoring_label">Praćenje</string>
|
||||||
|
|||||||
@@ -302,9 +302,9 @@
|
|||||||
<string name="debug_debuglog_short_recording_stop">Ամեն դեպքում դադարեցնել</string>
|
<string name="debug_debuglog_short_recording_stop">Ամեն դեպքում դադարեցնել</string>
|
||||||
<!-- Recorder screen -->
|
<!-- Recorder screen -->
|
||||||
<string name="debug_debuglog_screen_title">Կարգաբերման մատյան</string>
|
<string name="debug_debuglog_screen_title">Կարգաբերման մատյան</string>
|
||||||
<string name="debug_debuglog_screen_subtitle">Կարգաբերման տեղեկատվության արտահանել վհարկարի համար</string>
|
<string name="debug_debuglog_screen_subtitle">Արտահանել վրիպազերծման տեղեկություններ անսարքությունների հայտնաբերման համար</string>
|
||||||
<string name="debug_debuglog_screen_sensitive_title">ԿոնՖիդենցիալ տեղեկատվության</string>
|
<string name="debug_debuglog_screen_sensitive_title">Զգայուն տեղեկություն</string>
|
||||||
<string name="debug_debuglog_screen_session_path_label">Սեսիայի Մարութ</string>
|
<string name="debug_debuglog_screen_session_path_label">Սեանսի ուղի</string>
|
||||||
<string name="debug_debuglog_screen_log_files_label">Մատյանի Ֆայլեր</string>
|
<string name="debug_debuglog_screen_log_files_label">Մատյանի Ֆայլեր</string>
|
||||||
<plurals name="debug_debuglog_screen_log_files_ready">
|
<plurals name="debug_debuglog_screen_log_files_ready">
|
||||||
<item quantity="one">%1$d Ֆայլ պատրաստուաց ե (%2$s)</item>
|
<item quantity="one">%1$d Ֆայլ պատրաստուաց ե (%2$s)</item>
|
||||||
@@ -321,10 +321,10 @@
|
|||||||
<string name="support_debuglog_sessions_desc">Կարավարել գրանցված Կարգաբերման մատյանի սեսիանները</string>
|
<string name="support_debuglog_sessions_desc">Կարավարել գրանցված Կարգաբերման մատյանի սեսիանները</string>
|
||||||
<string name="support_debuglog_sessions_empty">Կարգաբերման սեսիաններ չկա</string>
|
<string name="support_debuglog_sessions_empty">Կարգաբերման սեսիաններ չկա</string>
|
||||||
<string name="support_debuglog_session_recording">Գրանցություն…</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_title">Հեռացնել սեսիան?</string>
|
||||||
<string name="support_debuglog_session_delete_message">Այս Կարգաբերման սեսիանը կինաբար հեռացվել կլի:</string>
|
<string name="support_debuglog_session_delete_message">Այս վրիպազերծման սեանսը կջնջվի անդառնալիորեն։</string>
|
||||||
<string name="support_debuglog_clear_all_message">Բոլոր Կարգաբերման սեսիանները կինաբար հեռացվել կլին:</string>
|
<string name="support_debuglog_clear_all_message">Բոլոր վրիպազերծման սեանսները կջնջվեն անդառնալիորեն։</string>
|
||||||
<string name="support_debuglog_failed_empty_log">Սխալ: մատյանի հալը դատարկ է</string>
|
<string name="support_debuglog_failed_empty_log">Սխալ: մատյանի հալը դատարկ է</string>
|
||||||
<string name="support_debuglog_failed_missing_log">Սխալ: մատյանի հալը բացակայում է</string>
|
<string name="support_debuglog_failed_missing_log">Սխալ: մատյանի հալը բացակայում է</string>
|
||||||
<string name="support_debuglog_failed_corrupt_zip">Սխալ: zip հալը վնասվածված է</string>
|
<string name="support_debuglog_failed_corrupt_zip">Սխալ: zip հալը վնասվածված է</string>
|
||||||
@@ -332,15 +332,15 @@
|
|||||||
<!-- Contact form -->
|
<!-- Contact form -->
|
||||||
<string name="support_contact_label">Կապակցություն Մշակողի հետ</string>
|
<string name="support_contact_label">Կապակցություն Մշակողի հետ</string>
|
||||||
<string name="support_contact_desc">Լրացնել ձև մշակողին e-mail ուղարկելու համար:</string>
|
<string name="support_contact_desc">Լրացնել ձև մշակողին e-mail ուղարկելու համար:</string>
|
||||||
<string name="support_contact_welcome">Ես կարդիք բոլոր նամակները կարդաթում ենք և կարկանությամբ պատասխանում, բայց միակայի աշխատում, կարոգ է քիչ ժամանակ պահանջվել: շնորհակալություն:</string>
|
<string name="support_contact_welcome">Ես ինքս կարդում եմ յուրաքանչյուր հաղորդագրություն և ամեն ինչ անում եմ պատասխանելու համար, բայց քանի որ մենակ եմ աշխատում, կարող է մի փոքր ժամանակ պահանջվել։ Շնորհակալություն ձեր համբերության համար։</string>
|
||||||
<string name="support_contact_footer">ձևր նամակը կուգկվի ելեկտրոնային նամակով: Բլկի և կարգավորության օգնությունները կցվում են ինքնաբար: Կարող եք կցել խաղագրություններ կամ վիդեո: Նամակի չափերը կլինեն անգլերեն, որպեսզի մշակողին կարող լինի հասկանալ:</string>
|
<string name="support_contact_footer">Ձեր հաղորդագրությունը կուղարկվի էլ. փոստով։ Սարքի և կարգաբերման տեղեկությունները կցվում են ավտոմատ կերպով։ Կարող եք նաև կցել սքրինշոթեր կամ տեսանյութ էլ. նամակին։ Էլ. նամակի որոշ մասեր կլինեն անգլերենով, որպեսզի մշակողը կարողանա հեշտությամբ հասկանալ։</string>
|
||||||
<string name="support_contact_category_label">Կատեգորիա</string>
|
<string name="support_contact_category_label">Կատեգորիա</string>
|
||||||
<string name="support_contact_category_question_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_category_bug_label">Սխալի զեկություն</string>
|
||||||
<string name="support_contact_description_label">Նկարագրություն</string>
|
<string name="support_contact_description_label">Նկարագրություն</string>
|
||||||
<string name="support_contact_description_question_hint">Նկարագրեք ձևր հարցումը մանրամասնորեն: Խնդրում կերկկիսել կոնկրետ:</string>
|
<string name="support_contact_description_question_hint">Մանրամասն նկարագրեք ձեր հարցը։ Խնդրում ենք կոնկրետ լինել։</string>
|
||||||
<string name="support_contact_description_feature_hint">Նկարագրեք այն հնարավորությունը, որին կլինեիք տեսնել: Խնդրում կերկկիսել կոնկրետ:</string>
|
<string name="support_contact_description_feature_hint">Նկարագրեք հնարավորությունը, որը ցանկանում եք տեսնել։ Խնդրում ենք կոնկրետ լինել։</string>
|
||||||
<string name="support_contact_description_bug_hint">Նկարագրեք ինչ կատարվել ե կերպես վերարտադրել խնդիրը. Խնդրում կոնկրետ կերկիսել:</string>
|
<string name="support_contact_description_bug_hint">Նկարագրեք ինչ կատարվել ե կերպես վերարտադրել խնդիրը. Խնդրում կոնկրետ կերկիսել:</string>
|
||||||
<string name="support_contact_expected_label">Սպասվող վարքություն</string>
|
<string name="support_contact_expected_label">Սպասվող վարքություն</string>
|
||||||
<string name="support_contact_expected_hint">Նկարագրեք ինչ կարկանում էիք կատարվի:</string>
|
<string name="support_contact_expected_hint">Նկարագրեք ինչ կարկանում էիք կատարվի:</string>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<string name="help_translate_label">Terjemahan</string>
|
<string name="help_translate_label">Terjemahan</string>
|
||||||
<string name="help_translate_description">Bantu terjemahkan aplikasi ini ke bahasa favorit Anda.</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_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_description">Widget yang menampilkan status perangkat terakhir yang diketahui.</string>
|
||||||
<string name="widget_config_screen_title">Konfigurasi Widget</string>
|
<string name="widget_config_screen_title">Konfigurasi Widget</string>
|
||||||
<string name="widget_configuration_title">Pilih Perangkat</string>
|
<string name="widget_configuration_title">Pilih Perangkat</string>
|
||||||
|
|||||||
@@ -297,13 +297,13 @@
|
|||||||
<string name="general_keep_editing_action">Halda áfram að breyta</string>
|
<string name="general_keep_editing_action">Halda áfram að breyta</string>
|
||||||
<!-- Short recording warning -->
|
<!-- Short recording warning -->
|
||||||
<string name="debug_debuglog_short_recording_title">Upptökun of stutt</string>
|
<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_continue">Halda áfram upptöku</string>
|
||||||
<string name="debug_debuglog_short_recording_stop">Stoppa úvis</string>
|
<string name="debug_debuglog_short_recording_stop">Stoppa úvis</string>
|
||||||
<!-- Recorder screen -->
|
<!-- Recorder screen -->
|
||||||
<string name="debug_debuglog_screen_title">Kembingarskrá</string>
|
<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_subtitle">Flytja út villuleitarupplýsingar til úrræðaleitar</string>
|
||||||
<string name="debug_debuglog_screen_sensitive_title">Vikvæmar upplýsingarr</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_session_path_label">Slóð lotunnar</string>
|
||||||
<string name="debug_debuglog_screen_log_files_label">Skráarskrár</string>
|
<string name="debug_debuglog_screen_log_files_label">Skráarskrár</string>
|
||||||
<plurals name="debug_debuglog_screen_log_files_ready">
|
<plurals name="debug_debuglog_screen_log_files_ready">
|
||||||
@@ -317,33 +317,33 @@
|
|||||||
<item quantity="other">%1$d kembingarskrár (%2$s)</item>
|
<item quantity="other">%1$d kembingarskrár (%2$s)</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="support_debuglog_clear_action">Hreinsa geymdar kembingarskrár</string>
|
<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_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_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_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_session_delete_message">Þessari villuleitarlotu verður eytt til frambúðar.</string>
|
||||||
<string name="support_debuglog_clear_all_message">Allar kembingarlotur verða varðlegt eyð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_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_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_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 -->
|
<!-- Contact form -->
|
||||||
<string name="support_contact_label">Hafa samband við þróunarmann</string>
|
<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_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_welcome">Ég les hvert einasta skilaboð sjálfur og geri mitt besta til að svara, en þar sem ég vinn að þ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. 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 að þróunarmaðurinn geti að skilja hann.</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_label">Flokkur</string>
|
||||||
<string name="support_contact_category_question_label">Spurning</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_feature_label">Beiðni um eiginleika</string>
|
||||||
<string name="support_contact_category_bug_label">Villutilkyðning</string>
|
<string name="support_contact_category_bug_label">Villutilkynning</string>
|
||||||
<string name="support_contact_description_label">Lýsing</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_question_hint">Lýstu spurningunni þinni í smáatriðum. Vinsamlegast vertu nákvæmur.</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_feature_hint">Lýstu þeim eiginleika sem þú vilt sjá. Vinsamlegast vertu nákvæmur.</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_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 héguð</string>
|
<string name="support_contact_expected_label">Vænt hegðun</string>
|
||||||
<string name="support_contact_expected_hint">Lýstu þver þver sem þú bjóst við að geriðist.</string>
|
<string name="support_contact_expected_hint">Lýstu því sem þú bjóst við að gerðist.</string>
|
||||||
<plurals name="support_contact_word_count">
|
<plurals name="support_contact_word_count">
|
||||||
<item quantity="one">%1$d / %2$d orð</item>
|
<item quantity="one">%1$d / %2$d orð</item>
|
||||||
<item quantity="other">%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_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_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_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_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_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_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 -->
|
<!-- Post-share confirmation -->
|
||||||
<string name="support_debuglog_sent_title">Fannst skráin?</string>
|
<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>
|
<string name="support_debuglog_sent_message">Var villuleitarskráin send? Skráin verður eytt.</string>
|
||||||
|
|||||||
@@ -249,8 +249,7 @@
|
|||||||
<string name="profiles_save_action">프로필 저장</string>
|
<string name="profiles_save_action">프로필 저장</string>
|
||||||
<string name="profiles_drag_handle_description">드래그하여 순서 변경</string>
|
<string name="profiles_drag_handle_description">드래그하여 순서 변경</string>
|
||||||
<string name="profiles_delete_title">프로필 삭제</string>
|
<string name="profiles_delete_title">프로필 삭제</string>
|
||||||
<string name="profiles_delete_message">프로필을 삭제하시겠습니까?
|
<string name="profiles_delete_message">프로필을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.</string>
|
||||||
이 작업은 취소할 수 없습니다.</string>
|
|
||||||
<string name="profiles_delete_action">삭제</string>
|
<string name="profiles_delete_action">삭제</string>
|
||||||
<string name="profiles_basic_info_title">기기 정보</string>
|
<string name="profiles_basic_info_title">기기 정보</string>
|
||||||
<string name="profiles_basic_info_description">기기의 이름, 모델 및 선택적 블루투스 페어링을 설정하세요.</string>
|
<string name="profiles_basic_info_description">기기의 이름, 모델 및 선택적 블루투스 페어링을 설정하세요.</string>
|
||||||
@@ -268,8 +267,7 @@
|
|||||||
<string name="profiles_key_status_not_set">설정되지 않음</string>
|
<string name="profiles_key_status_not_set">설정되지 않음</string>
|
||||||
<string name="profiles_signal_quality_min_label">끄기</string>
|
<string name="profiles_signal_quality_min_label">끄기</string>
|
||||||
<string name="profiles_signal_quality_max_label">엄격</string>
|
<string name="profiles_signal_quality_max_label">엄격</string>
|
||||||
<string name="profiles_priority_hint">프로필 순서는 우선순위를 결정합니다. 프로필을 드래그하여 순서를 변경하세요.
|
<string name="profiles_priority_hint">프로필 순서는 우선순위를 결정합니다. 프로필을 드래그하여 순서를 변경하세요. 리스트의 위에 있을수록 여러 장치가 일치할때 우선순위가 높습니다.</string>
|
||||||
리스트의 위에 있을수록 여러 장치가 일치할때 우선순위가 높습니다.</string>
|
|
||||||
<!-- Theme Mode -->
|
<!-- Theme Mode -->
|
||||||
<string name="ui_theme_mode_label">테마 모드</string>
|
<string name="ui_theme_mode_label">테마 모드</string>
|
||||||
<string name="ui_theme_mode_system_label">기기 테마 사용</string>
|
<string name="ui_theme_mode_system_label">기기 테마 사용</string>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
<string name="debug_debuglog_size_compressed_label">ຂະໜາດທີ່ບີບອັດ</string>
|
<string name="debug_debuglog_size_compressed_label">ຂະໜາດທີ່ບີບອັດ</string>
|
||||||
<string name="debug_notification_channel_label">ການແຈ້ງເຕືອນດີບັກ</string>
|
<string name="debug_notification_channel_label">ການແຈ້ງເຕືອນດີບັກ</string>
|
||||||
<string name="debug_debuglog_file_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_record_action">ບັນທຶກບັນທຶກດີບັກ</string>
|
||||||
<string name="debug_debuglog_stop_action">ຢຸດການບັນທຶກ</string>
|
<string name="debug_debuglog_stop_action">ຢຸດການບັນທຶກ</string>
|
||||||
<string name="debug_debuglog_sensitive_information_message">ໄຟລ໌ທີ່ສ້າງຂຶ້ນມີຂໍ້ມູນທີ່ละเอียดອ່ອນ (ຕົວຢ່າງ: ລາຍລະອຽດອຸປະກອນ Bluetooth). ແບ່ງປັນມັນກັບຝ່າຍທີ່ເຊື່ອຖືໄດ້ເທົ່ານັ້ນ.</string>
|
<string name="debug_debuglog_sensitive_information_message">ໄຟລ໌ທີ່ສ້າງຂຶ້ນມີຂໍ້ມູນທີ່ละเอียดອ່ອນ (ຕົວຢ່າງ: ລາຍລະອຽດອຸປະກອນ Bluetooth). ແບ່ງປັນມັນກັບຝ່າຍທີ່ເຊື່ອຖືໄດ້ເທົ່ານັ້ນ.</string>
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
<string name="upgrade_screen_iap_action">Eenmalig kopen</string>
|
<string name="upgrade_screen_iap_action">Eenmalig kopen</string>
|
||||||
<string name="upgrade_screen_iap_action_hint">Eenmalige aankoop: %s</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_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_purchase_message">Geen aankopen gevonden. Gebruikt je wel het juiste account?</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_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_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="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>
|
<string name="settings_monitor_mode_label">Monitor modus</string>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
<string name="widget_configuration_title">උපාංගය තෝරන්න</string>
|
<string name="widget_configuration_title">උපාංගය තෝරන්න</string>
|
||||||
<string name="widget_configuration_description">මෙම විජට් එක කුමන උපාංග පැතිකඩ පෙන්විය යුතුද යන්න තෝරන්න.</string>
|
<string name="widget_configuration_description">මෙම විජට් එක කුමන උපාංග පැතිකඩ පෙන්විය යුතුද යන්න තෝරන්න.</string>
|
||||||
<string name="widget_no_data_label">දත්ත නොමැත</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_appearance_label">පෙනුම</string>
|
||||||
<string name="widget_config_preset_label">පෙර සැකසුම්</string>
|
<string name="widget_config_preset_label">පෙර සැකසුම්</string>
|
||||||
<string name="widget_config_background_color_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_corrupt_zip">අසහය වී: zip ගොනුව විනාශ වී</string>
|
||||||
<string name="support_debuglog_failed_zip_failed">අසහය වී: සංකලනය අසහය වී</string>
|
<string name="support_debuglog_failed_zip_failed">අසහය වී: සංකලනය අසහය වී</string>
|
||||||
<!-- Contact form -->
|
<!-- Contact form -->
|
||||||
<string name="support_contact_label">සංවර්ධකයා සමග හය්ක්ළ ගන්න</string>
|
<string name="support_contact_label">සංවර්ධකයා අමතන්න</string>
|
||||||
<string name="support_contact_desc">සංවර්ධකයාට ඉකිමෙල් දෙනුම් යළ්ලීමට ප්රශ්නාවලියක් පිරළන්න.</string>
|
<string name="support_contact_desc">සංවර්ධකයාට ඊමේල් යැවීමට පෝරමයක් පුරවන්න.</string>
|
||||||
<string name="support_contact_welcome">මම හට හට් ගිණුම් එකක්මක් බවට ප්රතික්ෂේප කරන අතර, මම එකක්ම් ගිණුම් කරනබින් පිලිතුරුයක් ගත හැක. ඉවත් එකක්මක් අවෛ එය්ක කරනබින් ඉවත් ගත හැකිය.</string>
|
<string name="support_contact_welcome">මම සෑම පණිවිඩයක්ම තනිව කියවා හැකි උපරිමයෙන් පිළිතුරු දීමට උත්සාහ කරමි, නමුත් මෙය තනිවම කරන නිසා, ටිකක් කාලය ගත විය හැකිය. ඔබේ ඉවසීමට ස්තූතියි.</string>
|
||||||
<string name="support_contact_footer">ඔබ෪ය සදහන ඉකිමෙල් යන්නේ ඇති යළ්ලනු ලැබේ. උපකරණ සහ සූදානම් හය ස්වයංක්රීයව ගල කරයි. ඉකිමෙල්යට ස්ක්රීන්ෂොට් හො විඩියොයක් සංලග් කර ගත හැක. ඉකිමෙල්ය෪් කලක් කෝටස් ඉංග්රීසි යයින් විය හෙත් සංවර්ධකයාට සහලසින් ඇතික් ගත හැක.</string>
|
<string name="support_contact_footer">ඔබේ පණිවිඩය ඊමේල් හරහා යවනු ලැබේ. උපාංග සහ සැකසීම් තොරතුරු ස්වයංක්රීයව අමුණා ඇත. ඔබට ඊමේල් වලට තිරදර්ශන හෝ වීඩියෝ අමුණා ගත හැක. ඊමේල් හි සමහර කොටස් ඉංග්රීසියෙන් ලියා ඇති අතර, සංවර්ධකයාට පහසුවෙන් තේරුම් ගත හැකිය.</string>
|
||||||
<string name="support_contact_category_label">කත්තාව</string>
|
<string name="support_contact_category_label">කත්තාව</string>
|
||||||
<string name="support_contact_category_question_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_category_bug_label">දෝෂ වාර්තාව</string>
|
||||||
<string name="support_contact_description_label">විවරණය</string>
|
<string name="support_contact_description_label">විවරණය</string>
|
||||||
<string name="support_contact_description_question_hint">ඔබ෪ය ප්රශ්නය විස්තරව විවරණ කරන්න. කරුණාකර වන්න.</string>
|
<string name="support_contact_description_question_hint">ඔබේ ප්රශ්නය විස්තරාත්මකව විස්තර කරන්න. කරුණාකර නිශ්චිත වන්න.</string>
|
||||||
<string name="support_contact_description_feature_hint">ඔබ දකින් ගන්නා ළන් විශේෂාංගය විවරණ කරන්න. කරුණාකර වන්න.</string>
|
<string name="support_contact_description_feature_hint">ඔබ දකිනු කැමති ලක්ෂණය විස්තර කරන්න. කරුණාකර නිශ්චිත වන්න.</string>
|
||||||
<string name="support_contact_description_bug_hint">සිදු වීයද සහ ගැටළුව නැවත ඇති කරන්නේ කෙසේද යන්න විවරණ කරන්න. කරුණාකර වන්න.</string>
|
<string name="support_contact_description_bug_hint">කුමක් සිදු වූයේ දැයි සහ ගැටළුව නැවත නිර්මාණය කරන්නේ කෙසේදැයි විස්තර කරන්න. කරුණාකර නිශ්චිත වන්න.</string>
|
||||||
<string name="support_contact_expected_label">පදු කරනු ලැබීයක්</string>
|
<string name="support_contact_expected_label">අපේක්ෂිත හැසිරීම</string>
|
||||||
<string name="support_contact_expected_hint">සිදු වේයේ කෙයි යන්න ඔබ යන්න ඇති කලා විවරණ කරන්න.</string>
|
<string name="support_contact_expected_hint">ඔබ සිදු වනු අපේක්ෂා කළ දෙය විස්තර කරන්න.</string>
|
||||||
<plurals name="support_contact_word_count">
|
<plurals name="support_contact_word_count">
|
||||||
<item quantity="one">%1$d / %2$d වට පදය</item>
|
<item quantity="one">%1$d / %2$d වට පදය</item>
|
||||||
<item quantity="other">%1$d / %2$d වට් පද</item>
|
<item quantity="other">%1$d / %2$d වට් පද</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="support_contact_debuglog_label">දෝෂහරණ ලොගය</string>
|
<string name="support_contact_debuglog_label">දෝෂහරණ ලොගය</string>
|
||||||
<string name="support_contact_debuglog_picker_hint">පළ්කාලිය දෝෂහරණ ලොගයක් සංලග් කරන්න, හො අලුත් කරනු ලදියක් රෙකර්ඩ් කරන්න.</string>
|
<string name="support_contact_debuglog_picker_hint">පවතින නිදොස් ලොගයක් අමුණන්න, නැතිනම් නව එකක් පටිගත කරන්න.</string>
|
||||||
<string name="support_contact_debuglog_picker_empty">දෝෂහරණ ලොග් හිසි නේ. පළ්වේන් එකක් රෙකර්ඩ් කරන්න.</string>
|
<string name="support_contact_debuglog_picker_empty">නිදොස් ලොග කිසිවක් හමු නොවිණි. පළමුව එකක් පටිගත කරන්න.</string>
|
||||||
<string name="support_contact_debuglog_zip_error">දෝෂහරණ ලොගය සංකලනය අසහය වී</string>
|
<string name="support_contact_debuglog_zip_error">දෝෂහරණ ලොගය සංකලනය අසහය වී</string>
|
||||||
<string name="support_contact_send_action">ඉකිමෙල් යෙදුම ඇහල්න්න</string>
|
<string name="support_contact_send_action">ඊමේල් යෙදුම විවෘත කරන්න</string>
|
||||||
<string name="support_contact_no_email_app">මෙම උපකරණයේ ඉකිමෙල් යෙදුමක් පළ්ක් නැත.</string>
|
<string name="support_contact_no_email_app">මෙම උපාංගයේ ඊමේල් යෙදුමක් හමු නොවිණි.</string>
|
||||||
<string name="support_contact_debuglog_delete_title">දෝෂහරණ ලොගය දුරු කරන්නද?</string>
|
<string name="support_contact_debuglog_delete_title">දෝෂහරණ ලොගය දුරු කරන්නද?</string>
|
||||||
<string name="support_contact_debuglog_delete_message">මෙම දෝෂහරණ ලොගය හෙළලා දුරු කරනු ලැබේ.</string>
|
<string name="support_contact_debuglog_delete_message">මෙම දෝෂහරණ ලොගය හෙළලා දුරු කරනු ලැබේ.</string>
|
||||||
<!-- Post-share confirmation -->
|
<!-- Post-share confirmation -->
|
||||||
|
|||||||
@@ -308,8 +308,20 @@
|
|||||||
<string name="debug_debuglog_screen_sensitive_title">Citlivé informácie</string>
|
<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_session_path_label">Cesta relácie</string>
|
||||||
<string name="debug_debuglog_screen_log_files_label">Log súbory</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>
|
<string name="debug_debuglog_screen_discard_action">Vymazať</string>
|
||||||
<!-- Log management -->
|
<!-- 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_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_label">Ladíace relacie</string>
|
||||||
<string name="support_debuglog_sessions_desc">Spravovať nahrané relacie ladíacich záznamov</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_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_label">Očakávané správanie</string>
|
||||||
<string name="support_contact_expected_hint">Popíšte, čo ste očakávali, že sa stane.</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_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_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>
|
<string name="support_contact_debuglog_picker_empty">Žiadne ladíace záznamy sa nenašli. Najskôr nahraje jeden.</string>
|
||||||
|
|||||||
@@ -309,10 +309,10 @@
|
|||||||
<string name="debug_debuglog_screen_session_path_label">Pot seje</string>
|
<string name="debug_debuglog_screen_session_path_label">Pot seje</string>
|
||||||
<string name="debug_debuglog_screen_log_files_label">Dnevniške datoteke</string>
|
<string name="debug_debuglog_screen_log_files_label">Dnevniške datoteke</string>
|
||||||
<plurals name="debug_debuglog_screen_log_files_ready">
|
<plurals name="debug_debuglog_screen_log_files_ready">
|
||||||
<item quantity="one">Datoteka je pripravljena (%2$s).</item>
|
<item quantity="one">%1$d datoteka je pripravljena (%2$s)</item>
|
||||||
<item quantity="two">Datoteki sta pripravljeni (%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="few">%1$d datoteke so pripravljene (%2$s)</item>
|
||||||
<item quantity="other">%1$d datotek je pripravljenih (%2$s).</item>
|
<item quantity="other">%1$d datotek je pripravljenih (%2$s)</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="debug_debuglog_screen_discard_action">Izbriši</string>
|
<string name="debug_debuglog_screen_discard_action">Izbriši</string>
|
||||||
<!-- Log management -->
|
<!-- Log management -->
|
||||||
|
|||||||
@@ -333,7 +333,7 @@
|
|||||||
<string name="support_contact_label">Kontakto zhvilluesin</string>
|
<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_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_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_label">Kategoria</string>
|
||||||
<string name="support_contact_category_question_label">Pyetje</string>
|
<string name="support_contact_category_question_label">Pyetje</string>
|
||||||
<string name="support_contact_category_feature_label">Kërkgesë për veçori</string>
|
<string name="support_contact_category_feature_label">Kërkgesë për veçori</string>
|
||||||
|
|||||||
@@ -336,9 +336,9 @@
|
|||||||
<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_category_bug_label">錯誤回報</string>
|
||||||
<string name="support_contact_description_label">說明</string>
|
<string name="support_contact_description_label">說明</string>
|
||||||
<string name="support_contact_description_question_hint">詳細描述您的問題,請尽量具體說明。</string>
|
<string name="support_contact_description_question_hint">請詳細描述您的問題,請盡量具體說明。</string>
|
||||||
<string name="support_contact_description_feature_hint">描述您希望看到的功能,請尽量具體說明。</string>
|
<string name="support_contact_description_feature_hint">請描述您希望看到的功能,請盡量具體說明。</string>
|
||||||
<string name="support_contact_description_bug_hint">描述發生了什麼以及如何復現問題,請尽量具體說明。</string>
|
<string name="support_contact_description_bug_hint">請描述發生了什麼事以及如何重現此問題,請盡量具體說明。</string>
|
||||||
<string name="support_contact_expected_label">預期行為</string>
|
<string name="support_contact_expected_label">預期行為</string>
|
||||||
<string name="support_contact_expected_hint">描述您預期發生的事情。</string>
|
<string name="support_contact_expected_hint">描述您預期發生的事情。</string>
|
||||||
<plurals name="support_contact_word_count">
|
<plurals name="support_contact_word_count">
|
||||||
|
|||||||
@@ -325,7 +325,7 @@
|
|||||||
<string name="support_debuglog_session_delete_title">Susa iseshini?</string>
|
<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_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_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_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_corrupt_zip">Kwehlulekile: ifayela le-zip lonakele</string>
|
||||||
<string name="support_debuglog_failed_zip_failed">Kwehlulekile: ukucindezela kwehlulekile</string>
|
<string name="support_debuglog_failed_zip_failed">Kwehlulekile: ukucindezela kwehlulekile</string>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<string name="settings_signal_minimum_label">Minimum signal quality</string>
|
<string name="settings_signal_minimum_label">Minimum signal quality</string>
|
||||||
<string name="settings_signal_minimum_description">The minimum signal quality that a device needs to have to be considered yours.</string>
|
<string name="settings_signal_minimum_description">The minimum signal quality that a device needs to have to be considered yours.</string>
|
||||||
<string name="settings_autoconnect_label">Auto connect</string>
|
<string name="settings_autoconnect_label">Auto connect</string>
|
||||||
<string name="settings_autoconnect_description">If Android does not automatically connect, we can ask it too. This will set the monitor mode setting to \'Always\'.</string>
|
<string name="settings_autoconnect_description">If Android does not automatically connect, we can ask it too. This will set the monitor mode setting to \'Always\'. May not work on newer Android versions.</string>
|
||||||
<string name="settings_autoconnect_condition_label">Auto connect condition</string>
|
<string name="settings_autoconnect_condition_label">Auto connect condition</string>
|
||||||
<string name="settings_autoconnect_condition_description">When should we try to connect to your device?</string>
|
<string name="settings_autoconnect_condition_description">When should we try to connect to your device?</string>
|
||||||
<string name="settings_devices_label">Devices</string>
|
<string name="settings_devices_label">Devices</string>
|
||||||
@@ -351,6 +351,7 @@
|
|||||||
<item quantity="other">%1$d files ready (%2$s)</item>
|
<item quantity="other">%1$d files ready (%2$s)</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="debug_debuglog_screen_discard_action">Delete</string>
|
<string name="debug_debuglog_screen_discard_action">Delete</string>
|
||||||
|
<string name="debug_debuglog_screen_keep_action">Keep</string>
|
||||||
|
|
||||||
<!-- Log management -->
|
<!-- Log management -->
|
||||||
<plurals name="support_debuglog_folder_summary">
|
<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
-85
@@ -1,6 +1,5 @@
|
|||||||
package eu.darken.capod.common.datastore
|
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.bluetooth.ScannerMode
|
||||||
import eu.darken.capod.common.theming.ThemeColor
|
import eu.darken.capod.common.theming.ThemeColor
|
||||||
import eu.darken.capod.common.theming.ThemeMode
|
import eu.darken.capod.common.theming.ThemeMode
|
||||||
@@ -11,16 +10,15 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
|
|||||||
import eu.darken.capod.profiles.core.DeviceProfilesContainer
|
import eu.darken.capod.profiles.core.DeviceProfilesContainer
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import kotlinx.serialization.SerialName
|
import io.kotest.matchers.string.shouldContain
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import testhelpers.BaseTest
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests that verify @SerialName values match @Json(name=...) values,
|
* Tests that verify kotlinx-serialization produces the expected wire format
|
||||||
* ensuring Moshi-serialized SharedPreferences data can be read by kotlinx-serialization
|
* and can decode legacy JSON strings (originally written by Moshi).
|
||||||
* after the DataStore migration.
|
|
||||||
*/
|
*/
|
||||||
class DataStoreMigrationCompatTest : BaseTest() {
|
class DataStoreMigrationCompatTest : BaseTest() {
|
||||||
|
|
||||||
@@ -28,106 +26,145 @@ class DataStoreMigrationCompatTest : BaseTest() {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
explicitNulls = false
|
explicitNulls = false
|
||||||
|
classDiscriminator = "type"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Test
|
||||||
* For each enum with both @SerialName and @Json annotations,
|
fun `ThemeMode encodes to canonical string`() {
|
||||||
* verify they produce the same string. This catches typos in @SerialName values.
|
json.encodeToString(serializer<ThemeMode>(), ThemeMode.DARK) shouldBe "\"theme.mode.dark\""
|
||||||
*/
|
}
|
||||||
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)
|
|
||||||
|
|
||||||
if (moshiAnnotation != null && serialNameAnnotation != null) {
|
@Test
|
||||||
serialNameAnnotation.value shouldBe moshiAnnotation.name
|
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
|
@Test
|
||||||
fun `SerialName matches Json name - ThemeMode`() = verifyEnumSerialNameParity<ThemeMode>()
|
fun `ScannerMode encodes to canonical string`() {
|
||||||
|
json.encodeToString(serializer<ScannerMode>(), ScannerMode.BALANCED) shouldBe "\"scanner.mode.balanced\""
|
||||||
@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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Moshi-serialized ScannerMode string is readable by kotlinx`() {
|
fun `ScannerMode legacy string decodes correctly`() {
|
||||||
val moshiOutput = "\"scanner.mode.balanced\""
|
val legacyOutput = "\"scanner.mode.balanced\""
|
||||||
val result = json.decodeFromString(serializer<ScannerMode>(), moshiOutput)
|
json.decodeFromString(serializer<ScannerMode>(), legacyOutput) shouldBe ScannerMode.BALANCED
|
||||||
result shouldBe ScannerMode.BALANCED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Moshi-serialized MonitorMode string is readable by kotlinx`() {
|
fun `ScannerMode round-trip`() {
|
||||||
val moshiOutput = "\"monitor.mode.automatic\""
|
ScannerMode.entries.forEach { mode ->
|
||||||
val result = json.decodeFromString(serializer<MonitorMode>(), moshiOutput)
|
val encoded = json.encodeToString(serializer<ScannerMode>(), mode)
|
||||||
result shouldBe MonitorMode.AUTOMATIC
|
json.decodeFromString(serializer<ScannerMode>(), encoded) shouldBe mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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) {
|
for (model in PodDevice.Model.entries) {
|
||||||
val field = PodDevice.Model::class.java.getField(model.name)
|
val encoded = json.encodeToString(serializer<PodDevice.Model>(), model)
|
||||||
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
|
json.decodeFromString(serializer<PodDevice.Model>(), encoded) shouldBe model
|
||||||
if (moshiAnnotation != null) {
|
|
||||||
val moshiOutput = "\"${moshiAnnotation.name}\""
|
|
||||||
val result = json.decodeFromString(serializer<PodDevice.Model>(), moshiOutput)
|
|
||||||
result shouldBe model
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Moshi-serialized ThemeStyle string is readable by kotlinx`() {
|
fun `PodDevice Model legacy string decodes correctly`() {
|
||||||
val moshiOutput = "\"theme.style.materialyou\""
|
val legacyOutput = "\"airpods.pro\""
|
||||||
val result = json.decodeFromString(serializer<ThemeStyle>(), moshiOutput)
|
json.decodeFromString(serializer<PodDevice.Model>(), legacyOutput) shouldBe PodDevice.Model.AIRPODS_PRO
|
||||||
result shouldBe ThemeStyle.MATERIAL_YOU
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Moshi-serialized ThemeColor string is readable by kotlinx`() {
|
fun `legacy DeviceProfilesContainer JSON decodes correctly`() {
|
||||||
val moshiOutput = "\"theme.color.green\""
|
val legacyJson = """
|
||||||
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 = """
|
|
||||||
{
|
{
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
@@ -143,7 +180,7 @@ class DataStoreMigrationCompatTest : BaseTest() {
|
|||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
|
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), legacyJson)
|
||||||
result.profiles.size shouldBe 1
|
result.profiles.size shouldBe 1
|
||||||
|
|
||||||
val profile = result.profiles[0] as AppleDeviceProfile
|
val profile = result.profiles[0] as AppleDeviceProfile
|
||||||
@@ -157,9 +194,43 @@ class DataStoreMigrationCompatTest : BaseTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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"
|
// Base64 encoded: [0x01, 0x02, 0x03] = "AQID"
|
||||||
val moshiJson = """
|
val legacyJson = """
|
||||||
{
|
{
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
@@ -176,7 +247,7 @@ class DataStoreMigrationCompatTest : BaseTest() {
|
|||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
|
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), legacyJson)
|
||||||
val profile = result.profiles[0] as AppleDeviceProfile
|
val profile = result.profiles[0] as AppleDeviceProfile
|
||||||
profile.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
|
profile.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
|
||||||
profile.encryptionKey!!.toList() shouldBe listOf<Byte>(0x04, 0x05, 0x06)
|
profile.encryptionKey!!.toList() shouldBe listOf<Byte>(0x04, 0x05, 0x06)
|
||||||
|
|||||||
+5
@@ -33,5 +33,10 @@ class RecorderModuleStateTest : BaseTest() {
|
|||||||
fun `currentLogPath is null`() {
|
fun `currentLogPath is null`() {
|
||||||
RecorderModule.State().currentLogPath shouldBe null
|
RecorderModule.State().currentLogPath shouldBe null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `persistedLogDir is null`() {
|
||||||
|
RecorderModule.State().persistedLogDir shouldBe null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+134
@@ -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.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -81,6 +82,9 @@ class OverviewViewModelTest : BaseTest() {
|
|||||||
|
|
||||||
permissionTool = mockk<PermissionTool>(relaxed = true).also {
|
permissionTool = mockk<PermissionTool>(relaxed = true).also {
|
||||||
every { it.missingPermissions } returns missingPermissionsFlow
|
every { it.missingPermissions } returns missingPermissionsFlow
|
||||||
|
every { it.missingScanPermissions } returns missingPermissionsFlow.map { perms ->
|
||||||
|
perms.filter { it.isScanBlocking }.toSet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
generalSettings = mockk<GeneralSettings>().also {
|
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) }
|
|
||||||
}
|
|
||||||
+18
-16
@@ -1,10 +1,8 @@
|
|||||||
package eu.darken.capod.common.datastore
|
package eu.darken.capod.common.datastore
|
||||||
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import com.squareup.moshi.Json as MoshiJson
|
|
||||||
import eu.darken.capod.common.upgrade.core.FossUpgrade
|
import eu.darken.capod.common.upgrade.core.FossUpgrade
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -25,6 +23,7 @@ class FossUpgradeSerializationTest : BaseTest() {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
explicitNulls = false
|
explicitNulls = false
|
||||||
|
classDiscriminator = "type"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDataStore() = PreferenceDataStoreFactory.create(
|
private fun createDataStore() = PreferenceDataStoreFactory.create(
|
||||||
@@ -50,24 +49,27 @@ class FossUpgradeSerializationTest : BaseTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SerialName matches Json name - FossUpgrade Reason`() {
|
fun `FossUpgrade Reason encodes to canonical string`() {
|
||||||
val enumClass = FossUpgrade.Reason::class.java
|
json.encodeToString(serializer<FossUpgrade.Reason>(), FossUpgrade.Reason.DONATED) shouldBe "\"foss.upgrade.reason.donated\""
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Moshi-serialized FossUpgrade JSON is readable by kotlinx`() {
|
fun `legacy FossUpgrade JSON decodes correctly`() {
|
||||||
val moshiJson = """{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}"""
|
val legacyJson = """{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}"""
|
||||||
val result = json.decodeFromString(serializer<FossUpgrade>(), moshiJson)
|
val result = json.decodeFromString(serializer<FossUpgrade>(), legacyJson)
|
||||||
result.upgradedAt shouldBe Instant.ofEpochMilli(1709553600000)
|
result.upgradedAt shouldBe Instant.ofEpochMilli(1709553600000)
|
||||||
result.reason shouldBe FossUpgrade.Reason.DONATED
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ fun DependencyHandlerScope.addDagger() {
|
|||||||
kspAndroidTest("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
|
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() {
|
fun DependencyHandlerScope.addOkio() {
|
||||||
implementation("com.squareup.okio:okio:3.1.0")
|
implementation("com.squareup.okio:okio:3.1.0")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ object Versions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
object Compose {
|
object Compose {
|
||||||
const val bom = "2025.06.01"
|
const val bom = "2025.10.00"
|
||||||
}
|
}
|
||||||
|
|
||||||
object Navigation3 {
|
object Navigation3 {
|
||||||
|
|||||||
Executable
+99
@@ -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 @@
|
|||||||
CAPod - AirPods laguntzailea
|
CAPod - Companion for AirPods
|
||||||
@@ -1 +1 @@
|
|||||||
CAPod - Kumppani AirPodsille
|
CAPod - Companion for AirPods
|
||||||
@@ -1 +1 @@
|
|||||||
CAPod - Kasama para sa AirPods
|
CAPod - Companion for AirPods
|
||||||
@@ -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.
|
* Մարտկոցի մակարդակ pods-ների և պատյանների համար։
|
||||||
* Charging status for pods and case.
|
* Լիցքավորման կարգավիճակ pods-ների և պատյանի համար։
|
||||||
* 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.
|
* Ավտոմատ կերպով կապել սարքը և AirPods-ը։
|
||||||
* Show popup when case is opened.
|
* Ցուցադրել թռուցիկ պատուհան, երբ պատյանը բացվի։
|
||||||
|
|
||||||
CAPod is ad-free. Some features require an in-app purchase.
|
CAPod-ը գովազդից ազատ է։ Որոշ հնարավորություններ պահանջում են ծրագրի ներսի գնում։
|
||||||
|
|
||||||
Most popular AirPods and Beats devices are supported.
|
Աջակցվում են AirPods-ի և Beats-ի ամենատարածված սարքերը։
|
||||||
If your device is similar to AirPods but not yet supported, send me a short mail.
|
Եթե ձեր սարքը նման է AirPods-ին, բայց դեռ չի աջակցվում, ուղարկեք ինձ կարճ նամակ։
|
||||||
|
|
||||||
Got a cool idea for a new feature? Reach out!
|
Հետաքրքիր գաղափար ունե՞ք նոր հնարավորության համար: Կապվե՛ք մեզ հետ:
|
||||||
@@ -1 +1 @@
|
|||||||
CAPod - Félagi fyrir AirPods
|
CAPod - Companion for AirPods
|
||||||
@@ -1 +1 @@
|
|||||||
CAPod - AirPods-ის თანამგზავრი
|
CAPod - Companion for AirPods
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user