Compare commits

...
Author SHA1 Message Date
Matthias Urhahn 911a09c743 fix(permissions): Offer battery optimization card in AUTOMATIC mode 2026-07-17 17:24:48 +02:00
Matthias Urhahn 2672dbdcf6 feat(monitor): Detect OS kills of the monitor and show a recovery hint 2026-07-17 17:24:41 +02:00
d4rken-org-releaser[bot] 3ddd5f3cfd Release: 5.2.1-rc0 2026-07-08 11:21:18 +00:00
Matthias Urhahn 31f4d4aafa fix(battery): Stop remaining-time jumping up when toggling ANC
A mode's own drain-rate bucket starts empty until it accumulates history,
so toggling ANC into an unlearned mode fell straight through to Apple's
optimistic spec rating while the mode just left showed its worse measured
rate. Result: enabling ANC could make the displayed time jump up ~1h.

Fill an empty ANC bucket at display time with the less-optimistic of the
mode-agnostic UNKNOWN reading and a sibling mode's learned rate, scaled by
the ratio of the two modes' rated drain. Scoped to spec'd models and
device-supported modes; picks the best-evidenced sibling, tie-broken by
closest rated drain then recency. No persistence or UI change.

The existing spec ceiling and display clamp still backstop the borrowed rate.
2026-07-07 22:23:01 +02:00
Matthias Urhahn e06dc933b8 chore(logging): Log BLE scan reception nanos in scan summaries 2026-07-06 12:42:10 +02:00
Matthias Urhahn 1418be999d feat(reaction): Time-cap auto-pause debounce on slow BLE scanners 2026-07-06 12:42:10 +02:00
d4rken-org-releaser[bot] df06a49949 Release: 5.2.0-rc0 2026-07-03 10:53:28 +00:00
darken b6dcc74383 fix: Hide charging indicator for disconnected devices 2026-07-03 12:43:33 +02:00
darken fead8381bc General: Update app translations from Crowdin 2026-07-03 11:25:05 +02:00
darken 2ab9f8ace6 chore(screenshots): Show battery estimates in dashboard screenshots 2026-07-02 17:32:29 +02:00
darken 81effcb349 chore(screenshots): Regenerate smoke locale screenshots 2026-07-02 17:32:29 +02:00
darken 3dd1f8151a fix(widget): Correct typo in the configuration resize hint 2026-07-02 17:32:29 +02:00
darken 272a7121a9 fix(screenshots): Use resource-qualifier locales for region-coded languages 2026-07-02 17:32:29 +02:00
darken 52a7850c1d feat(battery): Show battery estimates in widget and expanded notification 2026-07-02 17:32:29 +02:00
darken b297ad8564 ui(battery): Simplify the health placeholder text 2026-07-02 17:32:29 +02:00
darken a3810353f7 Revert "feat(battery): Add experimental case charge ETA and case battery health"
This reverts commit 87a9881024.
2026-07-02 17:32:29 +02:00
darken d4dddb1f38 feat(battery): Add experimental case charge ETA and case battery health
- Show the time until the case is full inside its charging chip, learned from
  the case's own rising level with the existing charge-band model; no rating
  exists for case charging, so the first charge learns before it shows
- Derive a case battery health from observed transfer efficiency: pod percent
  gained per case percent spent while docked and unplugged, corrected by each
  pod's own health, compared against Apple's "with charging case" totals
- Case data is only genuine while a pod is docked — both transports silently
  freeze the last value otherwise. Battery updates now flag whether the case
  entry is live, and BLE gains strict same-frame case accessors, so estimates
  never learn from frozen echoes
- The case deliberately gets no runtime estimate: idle-then-burst drain has no
  meaningful hourly rate
- Mark the case metrics as experimental in the Battery settings card
2026-07-02 17:32:29 +02:00
darken 21560a1f3a ui(battery): Tighten estimate setting titles and descriptions 2026-07-02 17:32:29 +02:00
darken bdfb6bb073 ui(battery): Match the divider style of the sibling settings cards 2026-07-02 17:32:29 +02:00
darken aac0590017 ui(battery): Surface health before data exists, reorder the Battery card
- Show a "Still determining — check back after a few more listening sessions"
  placeholder under Battery Health in the device info sheet while the estimate
  is still accumulating data, so the feature is discoverable from day one; no
  placeholder for profiles without a paired device, where it could never resolve
- Reorder the Battery card: time remaining & health settings first, then a
  divider, then the charging-side settings (charge limit, charged notification)
- Mention the battery-health component in the estimate toggle and reset texts
- Ground the charge-band boundaries in Apple's documented fast-to-80%/trickle
  charging behavior and standard lithium CC/CV charging references
2026-07-02 17:32:29 +02:00
darken afba33dd83 feat(battery): Model charge taper per band, base health on listening-only drain
- Replace the single linear charge rate with a three-band model (bulk / taper /
  trickle) matching lithium CC/CV charging: each band learns its own rate, the
  ETA walks the remaining bands, and the spec seed gets a taper haircut for the
  slow bands — no more over-promising above 80%
- Base the battery-health figure exclusively on drain observed while the pod is
  worn, audio is playing, AND this device is the system's audio sink; idle wear
  previously diluted health upward against Apple's listening ratings
- Listening segments are flushed for persistence the moment their gate breaks
  (playback stop, docking, transport flip) instead of being discarded with the
  cleared window
- The time-remaining estimate keeps learning from all usage — actual current
  drain, idle included, is the right basis for "how long will they last"
2026-07-02 17:32:29 +02:00
darken 5de5dee52f ui(battery): Show charge ETA in the charging chip, make health per-pod
- The gauge line under the percentage now always shows the runtime estimate
  ("if used now") even while charging; the time-until-charged moved into the
  charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
  the info sheet, mirroring the serial rows) — single-pod listening habits or
  a replaced earbud make the sides genuinely diverge, and a combined figure
  would mask a failing pod
2026-07-02 17:32:29 +02:00
darken ba3e253569 fix(battery): Seed charge ETA from quick-charge specs, hide projection while charging
- While charging, the gauge showed the bare runtime projection when no charge
  rate existed yet — "1% · 4m" next to a charging chip reads as a four-minute
  charge. The line now shows the "until charged" ETA or nothing
- Seed the charge rate from Apple's published quick-charge claims ("5 minutes
  in the case provides around 1 hour of listening"), normalized against the
  rated listening hours, so the ETA is present from the very first charge; a
  live fit still takes over within minutes
2026-07-02 17:32:29 +02:00
darken 4488af0ab7 feat(battery): Add time-until-charged and derived battery health
- While a pod charges, fit its rising level and show the time until full in the
  gauge instead of the runtime estimate; learned charge rates are persisted per
  slot so the ETA appears immediately on later charges
- Suppress the charge ETA during Optimized Battery Charging holds, the final
  trickle phase, and whenever the level stalls longer than one visible step
  should take (granularity-aware: 1% AAP steps vs 10% BLE steps)
- Clear a slot's fit window when its readings switch between AAP and BLE — the
  granularity jump would otherwise read as a fake level step
- Derive a battery-health percentage (median of accumulated drain rates vs the
  model's rated life) and show it in the device info sheet; the info button now
  also appears for BLE-only devices once health data exists
- Tag learned rates with the model they came from so re-pointing a profile at
  different hardware starts learning fresh instead of inheriting foreign rates
- Track how many sessions blended into each learned rate and require three
  before a health figure is shown
2026-07-02 17:32:29 +02:00
darken aae8ad62bd feat(battery): Make estimate per-device and seed from model specs
- Replace the global estimate toggle with a per-device toggle stored on the profile
- Seed the estimate from each model's rated battery life and show it immediately, using
  the rating as a hard upper bound on displayed life while the measured rate converges
- When the ANC mode is unknown, seed from the shorter of a model's ANC-on/off ratings
- Show a projection while charging ("if used now") without ever learning from a rising battery
- Consolidate charge limit, "notify when charged", the estimate toggle and reset into one
  Battery card; the charge notification now works for any live device, not only classic
  audio connections
- Smooth the displayed time asymmetrically (drop fast, rise slow) so a faster-than-rated
  drain stops over-promising within a couple of updates
2026-07-02 17:32:29 +02:00
darken 9606a33f15 feat: Add earbud battery time-remaining estimate 2026-07-01 17:58:12 +02:00
darken f626d70538 fix(monitor): Show worn device in notification and popup 2026-06-30 23:29:34 +02:00
d4rken-org-releaser[bot] a91ef44686 Release: 5.1.10-rc0 2026-06-22 19:00:07 +00:00
darken e3bef4e74b General: Update app translations from Crowdin 2026-06-22 20:42:35 +02:00
darken 358bf5e244 fix(monitor): Drive monitor mode from first addressed profile 2026-06-22 20:16:37 +02:00
d4rken-org-releaser[bot] fba2d3a59d Release: 5.1.9-rc0 2026-06-18 12:00:29 +00:00
darken 0abba4ece2 test(reaction): Harden Conversational Awareness pod-removal coverage 2026-06-18 13:57:07 +02:00
darken e9b0694b67 fix(reaction): Honor One-Pod Mode in CA resume worn-guard 2026-06-18 12:07:19 +02:00
darken 49bbf1bc85 fix(reaction): Settle cold CA terminals to catch pod-removal re-key race 2026-06-18 12:07:19 +02:00
darken f5b6a5c605 fix(reaction): Ignore CA terminals from pod removal/insertion re-key 2026-06-18 12:07:19 +02:00
darken fccc6cedd5 fix(reaction): Restore CA status 6 as a terminal 2026-06-18 12:07:19 +02:00
darken a03766448e fix(reaction): Apply resume age-guard only to the inferred stale-end 2026-06-18 12:07:19 +02:00
darken f0c9d318ff fix(aap): Report speaking=true for Conversational Awareness resume (status 5) 2026-06-18 12:07:19 +02:00
darken 5c5cc6f28e fix(reaction): Refresh CA keep-alive timestamp so long conversations resume 2026-06-18 12:07:19 +02:00
darken 246b96bfb4 fix(reaction): Treat CA status 5 as speech-resume, not a stop 2026-06-18 12:07:19 +02:00
darken 352e020b54 test(aap): Make handshake-timeout test deterministic with real-time bounds 2026-06-15 09:16:51 +02:00
darken 72f5690ea6 test(aap): Stabilize flaky handshake-timeout test under CI load 2026-06-15 09:16:51 +02:00
darken 1ed1199574 fix(ble): Stop foreign same-model pods from hijacking a device's identity 2026-06-15 09:16:51 +02:00
darken fd4a43de96 fix(aap): Bound handshake with watchdog and escalate reconnect backoff 2026-06-14 21:28:42 +02:00
darken e406fd4cc1 fix(monitor): Restore tint on product icons to prevent invisibility on light backgrounds
Without tint, white PNG product icons (AirPods photos) are invisible on
light notification backgrounds. Restore src_in tint with notification_icon_tint
(a reliable day/night color resource) in both the PodInfoItemIcon.Notification
style (covers small layouts) and the big layout product ImageViews that do
not use that style.
2026-06-14 17:52:45 +02:00
darken 1e555cc712 fix(monitor): Fix notification color inconsistency in dark mode on One UI
Remove Theme.Material3.DynamicColors.DayNight from the notification
container style; it does not resolve correctly in the notification host
process, causing light-mode colors to appear in dark mode. Replace all
?attr/colorAccent and ?android:attr/textColorPrimary refs in RemoteViews
layouts with explicit day/night color resources (notification_icon_tint)
and TextAppearance.Compat.Notification.Title defaults. Change tintMode
from multiply to src_in for status glyphs; remove tint from product PNG
icons to prevent silhouette rendering.

Fixes #613
2026-06-14 17:52:45 +02:00
d4rken-org-releaser[bot] 227a6b2a7b Release: 5.1.8-rc0 2026-06-10 18:25:44 +00:00
darken 5f661d71a6 General: Update app translations from Crowdin 2026-06-10 20:20:37 +02:00
darken 2804543b9d feat(reaction): Add charged notification 2026-06-10 18:13:51 +02:00
darken 380397321c fix(reaction): Apply volume reduction slider only on drag release
The conversation volume slider committed a profile write on every
drag frame — laggy dragging, and racing async writes persisted stale
mid-drag values (observed: 78 stored while the UI showed 70). Use
local state while dragging and commit on release, matching the
adaptive-noise and tone-volume sliders.
2026-06-10 11:39:58 +02:00
darken d9c8902d34 fix(reaction): Restore volume when conversation end frame never arrives
With one AirPod in the case the pod deterministically drops the
terminal 0x4B frame: the wind-down flurry ends on a transitional
status (1,2,3,0xB,4 then nothing), so the reaction never disengaged
and the volume stayed low until the 5-minute backstop (#608).
Reproduced on Pro 3 (fw 6589, 6503) and Pro 2 USB-C (fw 6814) —
all three share one firmware train; with both pods worn the terminal
always arrives.

The pod sends no frames during active speech, so any non-START frame
means the wind-down has begun and a terminal is imminent. A HOLD now
arms a short 6s fuse instead of the 5-minute backstop: if no terminal
(or fresh START) follows, the reaction disengages anyway. The fuse
must stay above ~5s — gaps up to 2.8s were observed between
consecutive wind-down frames. STARTs still arm the long backstop,
since the pod stays engaged and silent against ambient noise for
20-30s+ and a short timeout there resumes media mid-conversation.

Validated on hardware on both models: 7/7 single-pod conversations
restored exactly 6s after the last wind-down frame.
2026-06-10 11:39:58 +02:00
darken a1b9abd4cb fix(ble): Drop proximity messages with unsupported payload prefix
AirPods Gen4 emit a one-off type 0x07 frame from their identity address
on connect whose payload prefix is 0x07 instead of 0x01. Its bytes are
not the plaintext status format, but the model bytes happen to match,
so it minted a fresh device tracker and showed as a duplicate device
card until the stale timeout.

Every known plaintext status broadcast (including pairing mode) uses
prefix 0x01; pairing state lives in the suffix byte. Reject anything
else at the decoder, logging the dropped frame's hex.

Fixes #603
2026-06-10 10:44:48 +02:00
d4rken-org-releaser[bot] 5d30844fa8 Release: 5.1.7-rc0 2026-06-08 07:22:52 +00:00
Matthias Urhahn af1b1b4249 Revert #605's cooldown-only mitigation in favor of the root-cause fix
@mvanhorn independently fixed #598 in #605 by no longer resetting the
case cooldown on close, throttling the re-pop. We cherry-picked that
commit above to keep his authorship/credit, but this PR instead removes
the underlying lid-state flapping at its source (out-of-case pod's stale
lid byte -> UNKNOWN), so the cooldown can keep resetting on close and a
genuine close->reopen still shows the popup. Reverting his change here so
the two approaches don't stack; thanks @mvanhorn for the parallel work.
2026-06-08 08:05:50 +02:00
Matt Van Horn 2cce9ae56c fix: case-open popup re-appears after the case is closed 2026-06-08 08:05:50 +02:00
Matthias Urhahn 4bab7c61a9 fix(reaction): Stop case popup flicker when one AirPod is out of case
When one pod is out of the case, the out-of-case pod broadcasts bit4-only
frames whose lid byte is stale and decodes to a phantom OPEN even while the
case is shut, interleaved ~1:1 with the correct in-case-pod (bit6) frames.
The derived lid state flapped OPEN<->CLOSED, so the case-open popup re-popped
~0.5s after closing and sometimes lingered. Verified on AirPods Pro 1 (issue
log) and Pro 3 (live BLE capture).

- Trust the lid bit only from in-case-pod (bit6) or both-in-case (bit2) frames;
  bit4-only frames now decode to UNKNOWN, and the real state is recovered from a
  recent in-case broadcast (matches LibrePods).
- getLatestCaseLidState recovers from history within a 2s age window instead of a
  fixed frame count, so a missed CLOSED can't keep a stale OPEN.
- Don't refresh the show-cooldown on a non-CLOSED hide, so a transient UNKNOWN
  can't suppress a genuine re-open.
- Add a freshness backstop: dismiss the popup if a fresh OPEN broadcast stops
  arriving (device left BLE range while open).
- Key popup/auto-connect de-duplication on the derived lid state, not just raw
  advertisement bytes, since the effective lid can change while bytes don't.

Closes #598
2026-06-08 08:05:50 +02:00
Matthias Urhahn 81e2bc1dba feat(overview): Suggest troubleshooter when a connected device sends no data
Adds a dashboard hint card pointing at the existing Troubleshooter when a profile is connected to the system (audio) but CAPod is receiving no live data — the symptom of a phone dropping AirPods BLE broadcasts (e.g. some HyperOS devices, #603). Debounced ~15s so it doesn't flash during the gap between an audio connection and the first broadcast, and suppressed whenever any pod is live so it never claims 'no data' while data is visibly arriving.
2026-06-08 07:45:29 +02:00
Matthias Urhahn df2783d8a1 feat(troubleshooter): Persist only the minimal working compat combo
Probe compatibility options through a transient in-memory override on BlePodMonitor instead of writing the user's persisted settings on every attempt. Only the winning combo is persisted, and only on success; failure or cancellation clears the override, restoring the user's original settings. Combos are tried fewest-disables-first so a phone that only needs batching disabled isn't left with filtering disabled too. Per-attempt cache clearing plus a freshness cutoff stop a previous combo's cached devices from satisfying the next one, and the 'found' checks now require a fresh live BLE observation rather than cached/AAP state.
2026-06-08 07:45:29 +02:00
Matthias Urhahn 20a6953b54 General: Update app translations from Crowdin 2026-06-07 13:30:43 +02:00
darken fdd4f69247 feat(overview): Add setting to hide unmatched devices 2026-05-31 10:04:26 +02:00
darken 5ee777b196 fix(reaction): Don't resume media mid-talk on Conversational Awareness
The pod only signals CA start and end, not continuous keep-alives. On fw 6861 it held CA engaged for 21s with zero 0x4B frames while the wearer kept talking, so the 12s stale-timeout fired mid-speech and resumed media; the pod never re-sent a start frame, so it didn't re-pause.

Disengage now waits for the explicit not-speaking frame (status 5 added as a terminal STOP, since that firmware winds down 3->5 and never reaches 6/8/9). Transitional/unknown statuses stay engaged. The stale timer is demoted to a long 5min backstop for a fully-dropped terminal frame; constants moved to Kotlin Durations.
2026-05-31 06:39:52 +02:00
d4rken-org-releaser[bot] dd827af3cd Release: 5.1.6-rc0 2026-05-26 13:32:39 +00:00
Matthias Urhahn 948205667b General: Update translations for 75 languages (#600)
* General: Update app translations from Crowdin

* General: Include latest Polish community translation updates
2026-05-26 12:20:17 +02:00
darken dbbfa75429 feat(reaction): Add Conversational Awareness media reaction
Reacts to the AirPods speaking-detection event (AAP 0x4B): lowers media volume by a configurable amount or pauses playback when you start talking, and reverts when you stop. Per-profile, Pro-gated, opt-in (default off).

Decodes the speaking status from the last payload byte ({1,2}=start, {6,8,9}=stop, else keep-alive); engage/disengage with a frame-idle stale timeout to recover a dropped stop, plus disconnect and service-stop cleanup.
2026-05-25 23:55:55 +02:00
Matthias Urhahn 78790f73df Delete Screenshot_20220914_174644.png~ 2026-05-11 12:39:16 +02:00
darken 2950d8b3f9 docs: Update README screenshots to current fastlane set 2026-05-11 12:37:41 +02:00
Matthias Urhahn 4d3a9bc165 refactor: Remove General Settings Debug menu and dead supporting code 2026-05-07 15:41:57 +02:00
Matthias Urhahn 7d57845502 fix(monitor): Make Extra notification toggle reactive 2026-05-07 15:23:06 +02:00
Matthias Urhahn 83ef111599 refactor: Move nudge persistence into GeneralSettings
Consolidate the nudge availability DataStoreValue with the rest of the persisted settings instead of carrying it in a separate Hilt module + DataStore file. Matches the existing convention where compat-style flags (offloaded filtering, indirect callbacks, etc.) live alongside theme/notification settings.

NudgeCapabilityStore keeps the verdict-mapping behavior; only the source of the persisted value changes.
2026-05-07 14:10:16 +02:00
Matthias Urhahn 0e6848d886 fix: Address Codex review on auto-monitor-mode
- Seed connectedDevices flow with onStart so ALWAYS autolaunch fires before HEADSET profile binds

- Gate UnavailableMissingPermission classification on Android 12+ (BLUETOOTH_CONNECT only exists from API 31)

- Treat blank/empty profile.address as unpaired (matches AutoConnect)

- Subscribe DeviceSettingsViewModel to nudgeCapabilityStore.availability so UI updates immediately on verdict change

- Seed availability StateFlow with the persisted value via valueBlocking to close the cold-start race
2026-05-07 14:10:16 +02:00
Matthias Urhahn 1eeeb63a76 test: Cover nudge persistence, permission gating, and autolaunch reactivity 2026-05-07 14:10:16 +02:00
Matthias Urhahn a4ba302223 refactor(monitor): Compute monitor mode automatically from profile state 2026-05-07 14:10:16 +02:00
Matthias Urhahn 8c9094d85a ui(reaction): Tighten Auto play / Start music on wear descriptions 2026-05-07 14:08:22 +02:00
Matthias Urhahn 4529b107dd fix(reaction): Only auto-resume after ear-removal pause, not stem or sleep
Matches Apple's iOS/macOS behavior: stem-press pauses and sleep-detection pauses are explicit user intent, not eligible for auto-resume on next pod-in. sendPause gains a rememberForResume parameter (default false); only PlayPause's auto-pause branch passes true. New sendStop wrapper clears the auto-resume flag for stem-mapped MEDIA_STOP.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 17663bc759 refactor(reaction): Switch to sticky resume flag with opt-in cold-wear
Replaces the origin-tracking machinery with a simpler model that matches Apple's iOS/macOS behavior: auto-play strictly resumes a CAP-dispatched pause, gated by a sticky boolean cleared on inactive→active transitions. The original fire-on-cold-wear behavior is preserved as a per-device opt-in 'Start music on wear' setting.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 278257998b fix(reaction): Don't auto-resume music after a user-initiated pause 2026-05-07 14:08:22 +02:00
d4rken-org-releaser[bot] 9d41979499 Release: 5.1.5-rc0 2026-05-06 10:16:00 +00:00
darken 985b73754e fix: Eliminate Float? boxing from battery display and cache merge
Two SIGSEGV native crashes recurred on Android 10 in 5.1.4-rc0 inside JIT-cached code at toBatteryFloat+4 (popup) and mergeBatterySlot+40 (cache merge). Both functions had a boxed Float? unbox at function entry that R8 horizontally merged into stdlib host classes, where Android 10 ART JIT miscompiled the unbox.

Convert PodDevice battery getters to non-null Float with BATTERY_UNKNOWN sentinel and propagate primitive Float through every display/persistence consumer. mergeBatterySlot now takes primitive Float; toBatteryFloat and toBatteryOrNull are deleted. Add isKnownBattery and batteryProgress helpers used everywhere instead of scattered nullable checks. Raw live extraction in toCachedState avoids touching the unified getter so cached values aren't refreshed as live.
2026-05-06 11:54:25 +02:00
Matthias Urhahn e4e4190523 General: Update zh-CN phone screenshots 2026-05-04 22:10:14 +02:00
Matthias Urhahn ebfb034fdd fix(navigation): Defer onNewIntent navCtrl call until Compose attaches
Move consumeUpgradeExtra() out of MainActivity.onNewIntent and into a Compose-level LaunchedEffect that collects a new MainActivity.warmIntents SingleEventFlow.

onNewIntent could fire before the setContent{} lambda registered the back stack with NavigationController, leaving navCtrl.goTo(Nav.Main.Upgrade) to throw IllegalStateException("NavigationController not initialized"). Same race shipped a fix for in octi (d4rken-org/octi#284).
2026-05-04 21:42:53 +02:00
Matthias Urhahn 8a81d354e6 chore(screenshots): Improve dashboard and add-profile previews
Use AAP-connected mocks for the dark dashboard to showcase full connectivity badges (BLE + IRK + encrypted + AAP) and the ANC mode selector. Populate the add-profile preview with a demo name, model, and paired device. Add user-facing labels to the dashboard mock devices.

Refactor BluetoothDevice2 so name/address are primary constructor fields, letting previews construct one without a real Android BluetoothDevice. Bump the screenshot test JVM heap to 4g — the smoke batch (42 renders) was hitting the test executor's default ceiling.
2026-05-04 21:30:25 +02:00
Matthias Urhahn ac0c4693dc chore(screenshots): Switch to smoke-only Play Store screenshot commits
Mirrors permission-pilot's policy: only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) check phoneScreenshots PNGs into the repo. Non-smoke locales are excluded via .gitignore. Drops fastlane/metadata/android/ from ~67 MB to ~7 MB and prevents future bloat from full regens. Play Store's supply retains previously-uploaded screenshots for locales not pushed, so full localization is maintained by occasional manual regen + screenshots_only upload.
2026-05-04 19:27:57 +02:00
Matthias Urhahn 4fd7a41c53 chore(screenshots): Regenerate Play Store screenshots
Replaces stale 7_settings.png and 8_reaction_settings.png with the current 7_device_settings_reactions.png across all 68 locales.
2026-05-04 18:50:30 +02:00
Matthias Urhahn cc1caf47f0 chore(fastlane): Add screenshots_only supply lane 2026-05-04 18:32:00 +02:00
darken 8b9f953818 feat(widget): Show profile name and nearby state when ANC widget is disconnected 2026-05-04 13:03:27 +02:00
d4rken-org-releaser[bot] d541de8c38 Release: 5.1.4-rc0 2026-05-03 17:32:42 +00:00
darken 2821da434d fix(settings): Hide Microphone & Press controls when AAP isn't ready 2026-05-03 19:27:52 +02:00
darken 07c72dc86c fix(aap): Cancel hung BluetoothSocket connect on timeout
BluetoothSocket.connect() is a blocking JNI call that ignores coroutine cancellation, so the previous withTimeout in AapAutoConnect only cancelled the suspending wrapper while the native thread stayed pinned. Hung threads accumulated and could trigger ANRs.

Move the timeout inside AapConnection and run the blocking connect on a daemon thread; on timeout, close the socket from the caller thread to unblock the native call (the documented Android pattern for cancelling in-flight L2CAP connects).

Also cancel appScope before delegating uncaught exceptions so coroutines have a best-effort window to release resources before the system handler terminates the process.
2026-05-03 15:39:03 +02:00
darken 7d93e772b4 fix(monitor): Prevent NPE in cache merge from freezing device flow
Battery slot percent comparisons in mergeBatterySlot/hasStateChanged compiled to Intrinsics.areEqual on boxed Float; R8 optimization on Android 10/11 dropped a null check during inlining and the resulting NPE escaped onEach { persistLiveDevices }, cancelling the upstream combine and freezing every observer of DeviceMonitor.devices.

Comparisons now operate on primitive float (cmpg-float in dex) so no Intrinsics.areEqual call remains in the merge path. The persist loop also catches and reports per-profile, and AAP-only profiles with active DeviceInfo are now persisted even when no BLE pod is in range.
2026-05-03 14:20:49 +02:00
darken 82dc88faac fix: Harden uncaught exception safety net
Wrap logging, reporting, and Looper resume calls so the foreground service timing exception suppression cannot itself trigger another crash. Extract handler into a dedicated class with seams for unit tests.
2026-05-03 12:41:32 +02:00
darken 06b41ec8eb fix(monitor): Recover from revoked Bluetooth scan permission 2026-05-03 11:54:11 +02:00
darken acc6e92b2d feat(tile): Add ANC Quick Settings tile 2026-05-03 10:58:05 +02:00
d4rken-org-releaser[bot] 39922df792 Release: 5.1.3-rc0 2026-05-01 19:52:12 +00:00
darken 5a8c4b94ef fix: Correct wear detection on AirPods Max 2
Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.

Closes #548
2026-05-01 21:50:51 +02:00
darken 0ffa87deea CI: Skip release bump-only workflow runs 2026-05-01 20:08:42 +02:00
darken fc98db09a2 fix: Correct PodModel feature flags for ear detection and call controls
Sync flags with what the BLE classes actually report and what iOS exposes:

- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle

- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)

- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)

- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)

- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual

- Generalize microphone mode description from 'AirPod' to 'earbud'

Tests rewritten as exhaustive set assertions plus implication invariants.
2026-05-01 20:08:29 +02:00
darken e1c0a702dd chore(ci): Rename stale step name and use client-id for App auth 2026-05-01 09:15:43 +02:00
d4rken-org-releaser[bot] 3f310016b1 Release: 5.1.2-rc1 2026-05-01 07:04:37 +00:00
darken d399a6d908 fix(ci): Drop redundant explicit dispatch in release-prepare 2026-05-01 09:00:29 +02:00
d4rken-org-releaser[bot] fb104cbe61 Release: 5.1.2-rc0 2026-05-01 06:56:07 +00:00
darken 97cbca7a6e fix(ci): Resolve bot slug via action output to avoid JWT-only endpoint 2026-05-01 08:55:15 +02:00
darken 02278dab17 chore(ci): Use GitHub App token for release-prepare push 2026-05-01 08:50:32 +02:00
darken 2240bd7ae5 chore(ci): Drop env gate on release-prepare to keep two-click approval 2026-05-01 08:16:33 +02:00
darken bc4096cb57 chore(ci): Run bats + shellcheck on release tooling 2026-05-01 08:00:15 +02:00
darken 5a0ba1b0bd chore(ci): Move release process to GitHub Actions 2026-05-01 08:00:15 +02:00
darken d04abdf80c General: Fix translation issues across multiple locales
Targeted character / escape / vandalism fixes:

- tr: %80 -> 80%% (UnknownFormatConversionException crash risk)

- pt: replace Cyrillic 'не' with 'não' in autoconnect description

- sk: fix 'ladUciaho' -> 'ladiaceho' (corruption)

- ms: restore 'Discord' brand name

- ca: 'maxima' -> 'minima' for signal_quality_title; fix 'suprimrira' typo

- zh-rTW: fix homophones and Japanese-kanji variants (4 strings)

- zh-rHK: Simplified -> Traditional for press_action_fast_forward

- zu: remove spurious 'Zn' prefix in 2 strings (vandalism)

- lv: fix wrong cedilla character in 3 Bluetooth a11y descriptions

- kmr-rTR: fix 4 encoding artifacts (a-feminine-ordinal substituted for e-circumflex)

- ko: fix 2 Hangul typos (conversation_awareness, allow_off)

Garbled translations removed (fall back to English source):

- hy-rAM: 13 press_controls/press_action strings

- mn-rMN: 2 strings (aap_unavailable_description, listening_mode_cycle_minimum)

- lt: settings_popup_warning_manual_mode

- mk-rMK: device_settings_allow_off_description (Syriac char vandalism)

- lo-rLA: signal_badge_ble_cd (English fallback was effectively unchanged)

- km-rKH: device_settings_microphone_mode_label (Thai script in Khmer file)
2026-05-01 05:19:34 +02:00
darken 0609352cc9 General: Update app translations from Crowdin 2026-05-01 05:19:34 +02:00
darken abde0b7d6a feat(reactions): Add discovery hint and outline settings icon 2026-04-30 17:46:27 +02:00
darken b952aff38d fix(widget): Re-render widgets reactively after initial session start
Glance only calls provideGlance() once per widget session; subsequent update() calls recompose the existing composition without re-running provideGlance. The previous capture-once approach left widgets frozen at initial state because the composition had no reactive State to read.

Subscribe to a widgetDeviceFlow(profileId) inside provideContent that pre-filters by WidgetDeviceKey, so the composition recomposes on visible state changes without firing on every BLE advertisement. Replace updateAll() with explicit per-GlanceId update() calls in WidgetManager, with a platform-id fallback when Glance returns no IDs.
2026-04-30 16:44:57 +02:00
darken c8f5ceda34 fix(monitor): Auto-tune BLE scanner mode by foreground state and connections
Replaces the user-facing scanner mode setting with an automatic policy that picks LOW_LATENCY when a profile-paired device is connected, BALANCED in the foreground, and LOW_POWER in the background. The TroubleShooter scopes a temporary LOW_LATENCY override via a refcounted withTemporaryOverride block so overlapping callers stay correct.

Fixes a regression where the controller could block BLE scanning entirely if BLUETOOTH_CONNECT was missing or the HEADSET profile proxy stalled, and adds a reactive bondedDeviceAddresses flow so bond changes propagate without waiting for an unrelated input. Cleans up the now-dead scanner mode strings across all locales and unused ScannerMode fields.
2026-04-30 15:26:45 +02:00
darken e6dbd2d660 fix(reaction): Restore BLE-only autoplay and stop false reactions on app start
- Apply seenLastAt freshness to all unauthenticated BLE samples (worn and not-worn). The earlier scoping to not-worn-only collapsed the second worn sample for BLE-only autoplay confirmation, so the staged play never fired.

- Replace distinctUntilChangedBy with a manual filter so worn samples that need to reset an active pause debounce (count went up) can pass through even when the monitor key is otherwise identical.

- Skip BLE-only autoplay confirmation for trusted sources. With BLE_IRK_MATCH and AAP, autoplay now fires on the first not-worn -> worn transition, mirroring the pause-debounce skip on the same sources.

- Skip the reaction entirely when the previous emission had no live evidence (NO_LIVE_BLE). Prevents app-process-start from synthesising a fake not-worn -> worn transition and firing autoplay while the user is already wearing the pods. Same guard handles mid-session BLE gap recoveries.

- Add MonitorFlowTests covering process-start-worn, genuine-insertion-after-startup, mid-session BLE-gap recovery, IRK-matched immediate autoplay, BLE-only autoplay confirmation, 3-sample pause debounce, and rebound-tolerated debounce reset.
2026-04-29 18:14:51 +02:00
darken 228d7d1f0c fix(reaction): Tighten auto-pause debounce after review findings
- Commit pending pause when a trusted source (AAP / BLE_IRK_MATCH) corroborates the not-worn condition mid-debounce, instead of dropping pending silently.

- Scope debounceFreshness to not-worn samples only; identical both-in samples no longer pass distinctUntilChangedBy and can't accidentally trigger BLE-only auto-play confirmation.

- Add resetTolerance to PendingPauseDebounce so a single corrupt count-up advert no longer kills a legitimate pending pause; reorder reset checks so rawDecision.shouldPlay resets immediately.

- Drop bleKeyState from the INFO autoPause log; source already encodes trust without leaking key-configuration state to logcat.

- Add flow-level MonitorFlowTests verifying the distinctUntilChangedBy interaction with seenLastAt freshness, plus the #557-direction test (AAP-worn vs corrupt-BLE-not-worn) and rebound-tolerance test.

- Clarify in BLE_ANONYMOUS KDoc that the path is unreachable in production via DeviceMonitor.primaryDevice.
2026-04-29 18:14:51 +02:00
darken 67fead3589 fix(reaction): Debounce auto-pause for unauthenticated BLE sources
Classifies the ear-detection source (AAP / BLE_IRK_MATCH / BLE_PROFILE_FALLBACK / BLE_ANONYMOUS / NO_LIVE_BLE) and applies a 3-sample debounce only to unauthenticated BLE paths. AAP and IRK-authenticated BLE pass through unchanged.

Also tightens toEarDetectionState() to prefer AAP aggregate over BLE per-side bits whenever AAP EarDetection is present, and suppresses pause on NO_LIVE_BLE (cache-only state) to avoid firing without live evidence.
2026-04-29 18:14:51 +02:00
darken d0aab583d2 fix(presscontrols): Auto-exit when device disconnects 2026-04-28 22:39:42 +02:00
darken 367c8d28ff chore: Drop stale app-common exclude from _config.yml
app-common module was merged into app/ in be8f4919; the exclude has been a no-op since.
2026-04-28 12:08:23 +02:00
darken b04e1fe628 chore(ci): Also rebuild Pages on main pushes for content edits
Adds push: [main] alongside workflow_dispatch so edits to README.md, _config.yml, _layouts, or the CHANGELOG.md template publish without a manual dispatch. The chain step in release-tag.yml still runs after release publish to guarantee the new release is in site.github.releases by the time Pages rebuilds — concurrency: cancel-in-progress: false serialises the two runs.

Adds an if: github.ref == 'refs/heads/main' guard on the deploy job so workflow_dispatch from a non-main branch builds for verification but doesn't deploy.
2026-04-28 12:08:23 +02:00
darken ea3ef1ff3b chore: Drop dead Gemfile excludes and ignore Jekyll artifacts
After moving Gemfile/Gemfile.lock to fastlane/, the - Gemfile / - Gemfile.lock entries in _config.yml's exclude list are no-ops; the parent fastlane exclude already covers everything inside.

.gitignore picks up _site/, .jekyll-cache/, vendor/bundle/ so local Jekyll runs don't leave tracked artifacts.
2026-04-28 12:08:23 +02:00
darken ff8b9cf6ed chore(ci): Chain Pages deploy from release-tag.yml and adopt org refinements
release: published events triggered by secrets.GITHUB_TOKEN do not start new workflow runs (only workflow_dispatch and repository_dispatch are exceptions). The Pages workflow's release: published trigger would never have fired in production since release-tag.yml's softprops/action-gh-release uses GITHUB_TOKEN to publish.

Fix: drop the release: published trigger and have release-tag.yml's release-github job explicitly run gh workflow run pages.yml --ref main after the release is created. release-github gains actions: write to authorize the dispatch.

Also adopts refinements from sibling org PRs (permission-pilot#356, bluemusic#220):

- Top-level permissions reduced to contents: read; pages: write and id-token: write moved to the deploy job only (least privilege)

- JEKYLL_GITHUB_TOKEN on the build step so jekyll-github-metadata authenticates when fetching site.github.releases

- Sanity-check step (test -f _site/index.html && _site/CNAME) fails fast if Jekyll produced nothing

- Explicit upload-pages-artifact path: ./_site matches the build's destination

- Verify fastlane Bundler wiring step (bundle exec fastlane --version) lets workflow_dispatch dry_run=true exercise the relocated Gemfile before the next real release
2026-04-28 12:08:23 +02:00
darken 96bc3f5df7 chore(ci): Move Gemfile to fastlane/ for tidier layout
The root Gemfile only ever declared the fastlane gem and lived next to fastlane configuration anyway. Moving it under fastlane/ matches that ownership and keeps the repo root cleaner.

release-gplay job now sets BUNDLE_GEMFILE=fastlane/Gemfile and runs ruby/setup-ruby with working-directory=fastlane so bundler-cache resolves the moved Gemfile. fastlane lanes still run from the repo root.
2026-04-28 12:08:23 +02:00
darken 1891a6d73a chore(ci): Migrate Pages to custom workflow with Node 24 actions
Replaces the auto pages-build-deployment (which still uses Node-20 actions/checkout@v4 and actions/upload-artifact@v4) with a custom workflow using configure-pages@v6, jekyll-build-pages@v1.0.13, upload-pages-artifact@v5, deploy-pages@v5.

Triggered by release publication so the changelog Liquid template (which reads site.github.releases) only rebuilds when a release actually exists. workflow_dispatch is kept for manual rebuilds when debugging Pages content.
2026-04-28 12:08:23 +02:00
darken 698415237d chore(ci): Drop unused Android build-cache step 2026-04-28 12:08:23 +02:00
darken 7ef672a11f chore(ci): Bump action-gh-release to v3.0.0 (Node 24) 2026-04-28 12:08:23 +02:00
darken f67e8aa7d1 fix: Harden foreground service exception handler 2026-04-25 23:40:10 +02:00
darken bcb4bdff08 fix(overview): Prevent crash from duplicate LazyList keys 2026-04-25 23:39:59 +02:00
darken 70d9432b2b Release: 5.1.1-rc0 2026-04-25 21:58:56 +02:00
darken 9d06839a1c General: Update app translations from Crowdin 2026-04-25 21:58:10 +02:00
darken 97f294e5f7 fix(widget): Persist theme settings across device reboot
Closes #549
2026-04-25 20:39:40 +02:00
darken a40599c1dc feat(settings): Tailor device settings to connection state
- Hide reactions and AAP sections unless device is classically connected

- Move advanced-settings-unavailable card to the bottom of the list

- Show 'device not nearby' infobox when out of range

- Show missing-paired-device banner with edit-profile action

- Replace pending banner with snackbar on user-initiated change
2026-04-25 19:00:19 +02:00
darken 175aef9e2f feat(logging): Log raw advertisement and decoded payload bytes 2026-04-25 16:19:21 +02:00
darken 36ae35918a refactor(widget): Move WidgetDeviceKey into widget package 2026-04-25 16:19:21 +02:00
darken 60ad1943b6 fix(widget): Gate refresh on display fields, drop log to VERBOSE 2026-04-25 16:19:21 +02:00
darken 5e727e907d refactor: Drop per-emission flow logger and redundant widget logs 2026-04-25 16:19:21 +02:00
darken 5360f54ea8 fix(privacy): Stop logging AirPods encryption keys 2026-04-25 16:19:21 +02:00
darken 486efe6e65 refactor: Reduce debug log noise from redundant state emissions 2026-04-25 16:19:21 +02:00
darken c2ff1f111e chore(reaction): Rename sleep notification channel label 2026-04-25 10:21:30 +02:00
darken 26b72726a7 feat(reaction): Pause media when AirPods detect sleep 2026-04-25 10:21:30 +02:00
darken f35fe968c6 docs(aap): Document SET_BAND_EDGES as RF band gating 2026-04-24 17:54:53 +02:00
darken 498f040a57 feat(aap): Drop experimental warning on Optimized Charge toggle
Verified on a real AirPods Pro 3: toggling flips pod charging state from CHARGING_OPTIMIZED to CHARGING and persists across reconnects. Apple-bool wire format is confirmed, so the 'experimental' warning box is no longer warranted.
2026-04-24 17:27:48 +02:00
darken 59b3106c3b feat(aap): Surface Optimized Charge indicator and Pro 3 toggle
Adds a per-battery 'Optimized' chip on the overview card when pods report wire value 0x05 (CHARGING_OPTIMIZED), which was already decoded but collapsed into a plain 'Charging' in the UI. On AirPods Pro 3, also adds a user-facing toggle for the device-side Optimized Charge Limit (AAP setting 0x3B).

- Decode setting 0x3B via decodeAppleBool so unknown values fall through instead of coercing to false

- Bypass ear-detection queue for SetDynamicEndOfCharge so the toggle works while pods sit in the closed case

- Expose per-slot ChargingState? on PodDevice; StatusChipRow renders 'Optimized' for CHARGING_OPTIMIZED, 'Charging' for CHARGING

- New BatteryCard in device settings with experimental warning (pattern matches Sleep Detection)

- Generic settingRejectedEvents flow alongside the existing offRejectedEvents so the toggle can show a dedicated snackbar on verification failure
2026-04-24 17:27:48 +02:00
darken 89e8b83be4 docs(aap): Revise PME hypothesis to Personal Medical Equipment 2026-04-24 15:27:18 +02:00
darken f0531796dd feat(device-settings): Surface hardware, pending firmware, and bonded dates 2026-04-24 13:08:57 +02:00
darken a701fdfba7 feat(aap): Build a comprehensive AAP protocol catalog
Adopts the Wireshark AAP dissector (pabloaul/apple-wireshark) as a third reference source alongside LibrePods and MagicPodsCore. Catalogues every known message type and control/setting ID, corrects DeviceInfo field labels, and adds sealed AapPacket hierarchy with Connect Response parsing. Case Info probe (Pro 3), Sleep event, and Dynamic End of Charge decoders are in place for future use.
2026-04-24 08:39:46 +02:00
darken 118eaa8d08 feat(widget): Add 1x1 size support to battery widget
Introduce BatteryLayout enum with size-driven dispatch. Drops minWidth from 80dp to 40dp so the battery widget can be placed at one cell. At 1-cell-wide placements render a compact icon+percent stack; wider sizes keep the current NARROW/WIDE layouts.
2026-04-23 16:11:07 +02:00
darken 016914ec05 fix(overview): Replace red triangle with inline missing-pairing banner 2026-04-23 14:17:16 +02:00
darken 0aadec0b75 feat(device-settings): Link AAP-unavailable card to compatibility tracker 2026-04-23 12:53:24 +02:00
darken e6cf507937 chore(claude): Refresh .claude/ docs to match current architecture
Drops stale app-common/Wear OS references, renames migrated classes

(ReactionSettingsFragment/PopUpPodViewFactory/PodMonitor), documents

the BLE/AAP split in monitor/core, the layered AAP stack, Widget

(Glance) and Upgrade subsystems, Navigation3 + legacy helpers. Fixes

localization examples and screenshot pipeline counts.
2026-04-23 08:58:27 +02:00
darken f27d84fec3 Release: 5.1.0-rc0 2026-04-17 21:59:40 +02:00
darken 7632ba9e0a fix(widget): Complete placement after upgrade purchase 2026-04-17 21:58:05 +02:00
darken fd20ef0500 fix(bluetooth): Keep connectedDevices retrying while BLUETOOTH_CONNECT is missing 2026-04-17 21:48:55 +02:00
darken 250d0ab154 fix(monitor): Gate AAP lifecycle on BLUETOOTH_CONNECT permission 2026-04-17 21:48:55 +02:00
darken 79886e0bfb General: Update app translations from Crowdin 2026-04-17 21:19:24 +02:00
darken 710d443074 General: Update fastlane translations from Crowdin 2026-04-17 21:19:24 +02:00
darken 86b685f28b feat(widget): Add ANC control widget
New home-screen widget that toggles AirPods ANC modes (Off / ANC / Transparency / Adaptive) directly, with six adaptive layouts that pick based on widget size (QUAD_CORNERS, ROW_ICONS, COLUMN_ICONS, GRID_2X2, ROW, COLUMN). Consolidated battery+ANC configuration into a single ViewModel that detects widget type from AppWidgetManager. ACTIVE state uses Material3 secondaryContainer/onSecondaryContainer for guaranteed contrast. Includes live config preview, preview subtitle, stale-selection guard, device-label toggle, and aligned icons with the app's AncModeSelector.
2026-04-17 20:10:48 +02:00
darken bba9155573 feat(press-controls): Add media and noise-control actions
Adds Stop, Fast Forward, Rewind, Mute, Cycle Noise Control, and Toggle Transparency as gesture targets. Migrates StemAction from enum to sealed interface with polymorphic serialization to unlock future parameterized actions. ANC actions are gated per-device capability and reuse the existing listening-mode cycle mask.
2026-04-17 17:56:31 +02:00
darken c19b0b35b6 chore(claude): Ignore scheduled_tasks.lock 2026-04-17 15:26:05 +02:00
darken ec087e4b07 feat(device-settings): Split stem actions into dedicated Press Controls screen
Move press timing, call controls, and stem mappings out of the Controls card into a new Press Controls screen. The screen name is device-agnostic so it applies to both stemmed AirPods and the AirPods Max's Digital Crown/noise button.

Stem mappings are now per-device (stored on the AppleDeviceProfile) rather than a single app-global DataStore. The Pro gate for mappings moves from screen entry to per-change with an Upgrade badge on the mappings card header, so free users can still access the non-Pro press timing and call-control settings that live on the same screen.
2026-04-17 15:26:05 +02:00
darken e7c280cb52 ui(overview): Add title icons to permission and device-limit cards 2026-04-17 13:02:14 +02:00
darken 8a3a6cade5 ui(device-settings): Remove Edit/Upgrade labels from listening mode cycle row 2026-04-17 13:02:14 +02:00
darken 77486fcd48 fix(overview): Make device limit upgrade card full width 2026-04-17 13:02:14 +02:00
darken f4ec4630e4 chore(upgrade): Bump foss upgrade cache key 2026-04-17 13:02:14 +02:00
darken 384d81be18 feat(aap): Persist learned ANC settings across reconnects 2026-04-17 13:02:14 +02:00
darken 07b3b95270 refactor(device-settings): Extract section cards into own files 2026-04-17 13:02:14 +02:00
darken 5b187c6d5b refactor(device-settings): Extract setting controls into own files 2026-04-17 13:02:14 +02:00
darken 5ee094da3c feat(device-settings): Split ANC-with-one-pod into its own toggle 2026-04-17 13:02:14 +02:00
darken 77ea31216f feat(overview): Sort and collapse inactive device cards
Sort profiled devices into tiers (system-connected > nearby > cached), ordered by profile list within each tier. Collapse non-pinned cards to a compact battery tray with mini gauge rings matching the expanded card design. System-connected and top devices stay expanded; others can be tapped to toggle.
2026-04-17 13:02:14 +02:00
darken 74e48e79eb Refactor packages 2026-04-17 13:02:14 +02:00
darken f141ce3f2a refactor(aap): Extract controllers and centralize timer lifecycle
Split AapSessionEngine into dedicated controllers (AapAncController, AapOutboundController), a typed inbound decoder (AapInboundInterpreter), a HID frame batcher (HidTracker), and a device-info diagnostics helper (AapDeviceInfoDiagnostics). Each controller returns typed decisions carrying state, timer actions, and logs instead of mutating engine state via callbacks. AapSettingsCoordinator is now stateless — pending queue and verification state live in engine runtime state.

All coroutine timer Jobs live in the engine's timerJobs map keyed by EngineTimerKey, with cancelAllTimers() on reset to prevent forgotten cancellations. Engine event dispatch is split: suspend path for user-initiated sends (errors propagate to caller), non-suspend for sync events (timer fires, inbound updates).
2026-04-16 10:32:21 +02:00
darken 14da81d231 fix(device-settings): Fix ANC OFF mode visibility and inference logic
Fix Elvis operator precedence bug in visibleAncModes() where OFF passed the filter unconditionally. Move filtering logic to PodDevice.visibleAncModes extension, unifying DualPodsCard, SinglePodsCard, and DeviceSettingsScreen. Gate AllowOffOption inference on pod-in-ear + 1.5s stability to prevent false positives from in-case OFF reports.
2026-04-16 10:32:21 +02:00
darken 789c23d24c feat(aap): Add structured HID descriptor logging with batched summaries
During case transitions, AirPods send 800+ cmd 0x0017 HID frames in ~20s. Previously each logged identically at VERBOSE with raw hex (~160KB noise). Now a HidTracker classifies frames (service directory, descriptor bulk, terminator) and batches consecutive bulk frames by (phase, fill), emitting 3-4 summary lines instead of 822.
2026-04-16 10:32:21 +02:00
darken 4ba59636c0 fix(aap): Fix flush ANC debounce, HANDSHAKING→READY regression, and flush ordering
Track lastAncSentAt separately so non-ANC commands during flush don't break the ANC echo debounce window. Prioritize ANC for post-flush verification.

Move HANDSHAKING→READY transition to top of processMessage so decoded battery, stem press, and device info messages also trigger it.

Sort flush: AllowOffOption before AncMode before others, preventing device rejection when enabling OFF mode.
2026-04-16 10:32:21 +02:00
darken e5821de4dd feat(aap): Unified command queue with ear-detection gating and session engine extraction
Queue all setting commands when no pod is in ear, flush when a pod goes in. Enable the adaptive noise slider when ADAPTIVE mode is pending. Show an info box when settings changes are pending.

Extract AapSessionEngine (state, send path, message processing, inference) and AapSettingsCoordinator (queue, optimistic updates, verification) from AapConnection, reducing it from 773 to 173 lines.
2026-04-16 10:32:21 +02:00
darken 59b4c404aa fix(device-settings): Use italic instead of cursive for name mismatch highlight 2026-04-16 10:32:21 +02:00
darken 0d566a5aec feat(device-settings): Inline edit icon next to device label, make label tappable 2026-04-16 10:32:21 +02:00
darken 35c06d9982 feat(device-settings): Move device details to bottom sheet
Move serial, firmware, build, manufacturer, and per-pod serials from

the info card into a ModalBottomSheet triggered by an info icon.

Firmware+build and left+right pod serials render as paired rows.
2026-04-16 10:32:21 +02:00
darken 3ad732ff09 feat(device-settings): Enrich device info card, extract shared ANC mode UI
Device info card: add model label with Apple model number, rename label to

Bluetooth Device Label, show cursive font on name mismatch with system BT

name, combine first/last seen on single row, add profile prefix to subtitle.

ANC mode: extract shared AncModeUi helpers for labels and icons, update

overview cards and device settings to use shared components.
2026-04-16 10:32:21 +02:00
darken 32ff7e5bdd feat(aap): Decode unknown settings, extend DeviceInfo, fix UTF-8 parsing
- Add UnknownSetting catch-all for 9 unconfirmed H2+ setting IDs (0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E)

- Downgrade known non-settings commands (0x0000, 0x0002, 0x000C, 0x0017, 0x002B, 0x004E, 0x0055, 0x0057) from INFO to VERBOSE

- Add earbud serials and build number to DeviceInfo (parsed from 0x001D segments 8-10, shown in DeviceInfoCard, persisted in cache)

- Fix UTF-8 device name parsing (curly quotes etc. no longer break segment indices)

- Add [was: ...] to setting change logs for easier protocol diff analysis

- Label DeviceInfoDump segments 5-12 (firmwareVersionDup, protocolVersion, updaterAppId, earbudSerials, buildNumber, encryptedBlob, timestamp)
2026-04-16 10:32:21 +02:00
darken fd6a9667e2 fix(aap): Correct EndCallMuteMic mapping based on real-device behavior
Swap the logical mapping for `EndCallMuteMic` settings to align with hardware states observed on AirPods Pro devices. Updated session tests for Pro 1, 2, and 3 to reflect the corrected protocol behavior.
2026-04-16 10:32:21 +02:00
darken c5d9bed5f0 feat(device-settings): Enable speculative feature flags for older models
Based on Apple's official documentation, enable features for models

that support them per iOS but were previously excluded in CAPod:

- Gen 1/2: microphone mode (auto/left/right)

- Gen 3: press speed, press hold duration, tone volume, microphone mode

- Gen 4 non-ANC: press speed, press hold duration, tone volume

- Max original/USB-C: listening mode cycle, allow off option
2026-04-16 10:32:21 +02:00
darken 90af17bf7d feat(device-settings): Add experimental info box, revise pro-gating, fix switch bypass
Add SettingsInfoBox title support and experimental feature warning for Sleep Detection and Personalized Volume toggles with issue tracker action.

Pro-gate tone volume, microphone mode. Un-gate sleep detection, conversation awareness. Fix SettingsSwitchItem allowing direct switch toggle to bypass requiresUpgrade.
2026-04-16 10:32:21 +02:00
darken 6f90687b72 refactor(aap): Send InitExt unconditionally to all devices
Older devices silently ignore the 0x4D packet, so there is no need to gate it per model. Sending it unconditionally prevents silent feature degradation when new H2+ models are added without the flag.

Remove needsInitExt from PodModel.Features.
2026-04-15 08:07:12 +02:00
darken f17b6ca99f feat(device-settings): Merge one-pod mode settings into a single toggle
Combine reaction one-pod mode (autoplay/autopause) and firmware NC-with-one-AirPod into one setting. The merged toggle always sets the app-local flag and additionally syncs the firmware NC setting via AAP when connected.

Also remove redundant @OptIn(ExperimentalCoroutinesApi::class) annotations from tests, already covered by the global compiler opt-in flag.
2026-04-15 07:37:51 +02:00
darken 397b32b425 fix(aap): Update feature flags for Max 2 and Powerbeats Pro 2, sync ear detection
Add H2-chip feature flags (adaptive ANC, conversation awareness, personalized volume, listening mode cycle, InitExt) to AirPods Max 2. Add sleep detection and ear detection toggle to Powerbeats Pro 2.

Auto-sync device ear detection setting when auto-play/auto-pause reactions are toggled. Add flag invariant tests to prevent future drift.
2026-04-15 06:47:05 +02:00
darken 4b80ac7336 feat(overview): Add dynamic Bluetooth status icon to toolbar 2026-04-15 06:46:54 +02:00
Matthias Urhahn 5d5e6670d4 fix(settings): Improve AAP unavailable description with update guidance (#513) 2026-04-14 19:23:32 +02:00
darken da64406638 feat(device-settings): Show Auto Connect caveat as conditional info box 2026-04-14 19:21:34 +02:00
darken 77b8992355 refactor(aap): Enrich unhandled message log with payload and send context 2026-04-14 18:15:22 +02:00
darken f3b540987d fix(aap): Use compact write format for EndCallMuteMic
Real AirPod firmware silently drops writes with the LibrePods-documented 0x21 subtype. Switch to the compact 0x20 format that matches what devices actually emit in their echo frames. Also enforce the complementary-pair invariant at the SetEndCallMuteMic command boundary so validation runs before the optimistic UI update.
2026-04-14 17:38:08 +02:00
darken 1a0bfbb3f4 refactor(aap): Log outgoing AAP commands with raw hex at VERBOSE
Mirrors the existing incoming MSG log so a full transcript can verify round-trips for settings the device doesn't echo back.
2026-04-14 16:07:29 +02:00
darken 02fd83440b fix(test): Update AdaptiveAudioNoise tests for inverted wire semantics
The encode/decode now flips UI 0..100 to wire 100..0. Update the clamp and decode expectations accordingly, and add a round-trip test to guard against asymmetric changes in either direction.
2026-04-14 15:57:07 +02:00
darken 7f82119001 fix(aap): Correct Adaptive noise capability flags and level semantics
Switch the 0x4D init packet flags from 0x0E to 0xD7 to match the value captured from Apple's own stack (librepods AACPManager). The previous value traced to MagicPodsCore and matches no known Apple capture.

Invert the Adaptive Audio Noise level on write and read: wire 0 means max noise reduction, wire 100 means transparency-like. UI stays in intuitive 100 = max NC semantics, matching librepods' slider behavior.
2026-04-14 15:57:07 +02:00
darken 8229163f70 fix(settings): Gate Adaptive Audio Noise slider on Adaptive noise mode 2026-04-14 15:57:07 +02:00
darken 6d313c077d feat(stem): Note that long-press overrides native ANC cycling 2026-04-14 14:44:22 +02:00
darken 60970a2a0c refactor(device-settings): Group Reactions items with dividers
Three dividers separate the logical clusters inside the Reactions section: ear-detection behaviors, AAP auto-behaviors (when connected), connection settings, and pop-up notifications. AAP divider only renders when at least one AAP item is shown.
2026-04-14 14:39:01 +02:00
darken c28502fc8a refactor(device-settings): Fold Conversation Awareness and Sleep Detection into Reactions
AAP vs BLE is an implementation detail. From a user perspective these are 'when X happens, do Y' toggles like Auto Play/Pause. Gate them on device.isAapConnected so they stay hidden on phones without L2CAP support, avoiding confusion. The separate 'Smart features' section and its string resource are removed.
2026-04-14 14:39:01 +02:00
darken 2f3cae2b2c refactor(device-settings): Reorganize sections and extract components
Visual: wrap Noise Control in SettingsSection for consistency with Sound/Controls/Smart features, add divider between ANC mode picker and trailing items.

Categories: rename 'Other' to 'Smart features', move Microphone Mode to Sound, move Conversation Awareness to Smart features alongside Sleep Detection, place Adaptive Noise adjacent to ANC mode picker.

Extraction: split DeviceInfoCard, NotConnectedCard, AapUnavailableCard into cards/; AutoConnectConditionDialog, RenameDialog, SystemRenameUnavailableDialog into dialogs/; NoiseControlCombined into components/. Each with isolated previews.
2026-04-14 14:39:01 +02:00
darken 566a6cd120 feat(settings): Replace rename-failure snackbar with dialog 2026-04-14 11:38:09 +02:00
darken 7d08a44a19 refactor: Standardize DeviceSettingsViewModelTest with runTest and explicit VM cleanup 2026-04-14 11:28:41 +02:00
darken dae12e588f fix(test): Drop shared scheduler context from runTest2 calls 2026-04-14 11:28:41 +02:00
darken 6d5c5d29cc fix(test): Use runTest2 with autoCancel for DeviceSettingsViewModelTest 2026-04-14 11:28:41 +02:00
darken bac970fa61 fix(l10n): Remove extra translations missing from default locale 2026-04-14 11:28:41 +02:00
darken 6e8439e2f4 fix: Add missing WARN import in AppleFactory 2026-04-14 11:28:41 +02:00
darken df0fcede6f fix(logging): Reduce PlayPause diagnostic noise
Remove Pre-distinct log that fired on every emission. Replace full PodDevice dump in Checking log with compact ear detection summary.
2026-04-14 11:28:41 +02:00
darken d4a355c356 fix(reaction): Fix autoplay/autopause for AAP-based ear detection
PlayPause coerced null per-side ear values to false, making all ear states invisible when resolvedPrimaryPod was unknown. Fall back to AAP aggregate state (isBeingWorn/isEitherPodInEar) when per-side mapping is unavailable, keeping PodDevice.isLeftInEar/isRightInEar truthful for UI consumers. Add isEitherPodInEar to PlayPauseMonitorKey for proper dedup. Add diagnostic logging at the distinctUntilChangedBy boundary.
2026-04-14 11:28:41 +02:00
darken 200e32f67a fix(logging): Promote user-triggered action logs to INFO level 2026-04-14 11:28:41 +02:00
darken c0060ce5ca feat(popup): Suppress popup when app is visible and add settings hints
Hide the overlay popup when MainActivity is in the foreground since the user can already see battery info in the app. Show an info card when popups are enabled explaining they only appear outside the app. Show a warning card with a Fix button when monitor mode is MANUAL.

Refactor SettingsInfoBox into a reusable component with INFO/WARNING types and optional action slot.
2026-04-14 11:28:41 +02:00
darken 34c23751b3 refactor(ui): Split PodCardComponents into per-component files
Move each composable from PodCardComponents.kt into its own file under a new cards/components/ subpackage. Add previews to each file. Also add getBatteryIcon() for Compose Material Icon battery levels used in the popup.
2026-04-14 11:28:41 +02:00
darken 04c23ecf63 fix(ble): Prefer case-context pod when merging split BLE broadcasts
When both pods broadcast independently (one in case, one on desk), the pod inside the case carries authoritative case state via hasCaseContext bits. Previously, whichever address was processed last would overwrite the other, causing case state to flip-flop between OPEN and NOT_IN_CASE every scan cycle.

Two-layer fix: BlePodMonitor.processWithCache() now prefers the pod with case context when two scan results map to the same identity in one batch. ApplePodsFactory.getLatestCaseLidState() no longer treats NOT_IN_CASE as authoritative when recent history contains a broadcast with case context.
2026-04-14 11:28:41 +02:00
darken b1aee5ee83 fix(popup): Show charging indicator in connection popup 2026-04-14 11:28:41 +02:00
darken 2a8416d146 fix(settings): Fix OFF visibility toggle not reacting on first press
Serialize AapConnection.send() with a dedicated sendMutex so two concurrent optimistic state updates cannot clobber each other. Collapse the OFF toggle click into a single combined VM call that runs both SetListeningModeCycle and SetAllowOffOption sequentially.
2026-04-14 11:28:41 +02:00
darken 1f7bc7cfd8 fix(popup): decode lid state from case context 2026-04-14 11:28:41 +02:00
darken 46fcf5a4cb fix(popup): use fresh broadcast timing for connection popup 2026-04-14 11:28:41 +02:00
darken a4e7820e6f Add delayed retry for permission recheck when returning from settings 2026-04-14 11:28:41 +02:00
darken e6e66c3831 fix(logging): Demote noisy BLE logs and redact summaries 2026-04-14 11:28:41 +02:00
darken 71ba531025 Break cache feedback loops in monitor and reactions 2026-04-14 11:28:41 +02:00
darken 6825abaa41 Guard BLE-only autoplay and clarify one-pod mode 2026-04-14 11:28:41 +02:00
darken a4e1a3ba2e fix(overview): Hide stale cached age on live cards 2026-04-14 11:28:41 +02:00
darken 1cd8ae5ace Refactor time access behind TimeSource 2026-04-14 11:28:41 +02:00
darken c1601c105c Fix auto-resume after CAP pause 2026-04-14 11:28:41 +02:00
darken b8f342f096 Resolve live pod profiles from current repo state 2026-04-14 11:28:41 +02:00
darken a6f292f737 Render device settings before BT profile state arrives 2026-04-14 11:28:41 +02:00
darken a37b9b0213 fix(settings): Show passive info when device is connected but AAP unavailable 2026-04-14 11:28:41 +02:00
darken 65a74e9bc1 feat(settings): Group device settings into card containers
Wrap setting sections in SettingsSection cards using Material 3 surfaceContainerLow surfaces with rounded corners. Reorder Controls by usage frequency, move Microphone Mode to Other section, and replace ear detection info row with a contextual info box that only appears for BLE-only connections.
2026-04-14 11:28:41 +02:00
darken 609423b27d fix: Force single line for UpgradeBadge text 2026-04-14 11:28:41 +02:00
darken 067eb2e9e0 feat(reactions): Move reaction settings to per-device profiles
Migrate reaction toggles (auto-play, auto-pause, auto-connect, popups) from global ReactionSettings singleton to per-profile fields on AppleDeviceProfile. Each paired device can now have independent reaction behavior.

Extract ReactionConfig snapshot to decouple PodDevice from AppleDeviceProfile — reaction consumers read device.reactions instead of device.profile. Remove auto-connect from upgrade benefits (now free). Add LegacyReactionSettingsReader for one-shot DataStore migration.
2026-04-14 11:28:41 +02:00
darken 29df90628f fix: Handle ForegroundServiceDidNotStartInTimeException crash 2026-04-14 11:24:49 +02:00
darken 70d9c047dc fix: Prevent duplicate device cards for stranger AirPods with same model 2026-04-13 14:09:33 +02:00
darken 7f2b6976ce fix(upgrade): Replace TopAppBar with floating back button to prevent mascot clipping 2026-04-13 14:09:16 +02:00
darken 37e2593d8a fix(overview): Warn when profile has no paired Bluetooth device 2026-04-11 17:56:21 +02:00
darken 30163a3f69 feat(settings): Add flavor-specific label to upgrade badge
Makes the inline upgrade badge a tiny star + short label (Pro on gplay, FOSS on foss) instead of icon-only. Text is flavor-switched via upgrade_badge_label in each flavor's strings.xml, primary color, labelSmall typography, no pill background to keep it lightweight when multiple rows stack it. Accessibility uses the existing common_upgrade_required_label.
2026-04-11 17:03:40 +02:00
darken ed6c939284 refactor(settings): Rename ProBadge to UpgradeBadge for flavor neutrality
The 'Pro' label is gplay-specific vocabulary — FOSS unlocks the same features via sponsorship. Aligns with the existing flavor-neutral terms already used in UpgradeRepo, Nav.Main.Upgrade and launchUpgrade(). Renames the proLocked parameter to requiresUpgrade across SettingsBaseItem and its three wrappers, and adds a new common_upgrade_required_label string (Requires upgrade) for the badge content description.
2026-04-11 17:03:40 +02:00
darken a51b24f284 feat(settings): Show pro indicators on gated settings rows
Unifies the three inconsistent pro-gating UI patterns (text button header, star-replaces-switch, silent gates) into a single inline star badge next to the title, driven by a new proLocked parameter on SettingsBaseItem that propagates to all wrappers. Switches stay visible so users can see state and disable ex-pro toggles. Hides the Noise Control visibility buttons when non-pro and surfaces a dedicated cycle customization row with the indicator. Deletes two duplicate local ProGated composables.
2026-04-11 17:03:40 +02:00
darken 09c0692205 refactor(overview): Thread isPro/onUpgrade into pod cards (groundwork)
Groundwork for a future pro indicator on the overview card's ANC mode selector. Adds isPro + onUpgrade parameters to OverviewScreen, PodDeviceCard, DualPodsCard, SinglePodsCard (currently unused inside the cards, defaults keep existing previews working). Wraps AncModeSelector in a Box as a scaffold for an overlay. Bumps surfaceContainerLowDark to match surfaceContainerDark in all three theme palettes (Amber/Blue/Green) across standard/medium/high contrast. No user-facing change yet.
2026-04-11 15:01:47 +02:00
darken 4174072dbf docs: Update supported devices list in README 2026-04-10 08:18:21 +02:00
RomaZinkevich aeaf92d7e6 support for airpods max 2 added 2026-04-10 07:58:46 +02:00
darken a650405c2d chore: Bump Android Gradle Plugin to 9.1.0 2026-04-09 07:47:56 +02:00
darken 7883dcbafb Release: 5.0.1-rc0 2026-04-08 20:27:19 +02:00
darken 300e6b8c49 chore: .gitignore 2026-04-08 20:25:13 +02:00
darken 33b6fbd0e0 General: Update app translations from Crowdin
Pull latest string translations from Crowdin. Validated and reverted any problematic changes.
2026-04-08 20:24:34 +02:00
darken f2bf39bda1 fix(monitor): Use AAP message time for Last Seen while connected 2026-04-08 19:21:19 +02:00
darken 87d366bcb7 refactor(ui): Improve device settings layout and grouping 2026-04-08 19:09:59 +02:00
darken df5732569f docs: Add Crowdin to README acknowledgments 2026-04-08 18:11:47 +02:00
darken 56e01cfde9 chore: Streamline PR description format in Claude rules 2026-04-08 18:07:52 +02:00
darken ed37e142ad fix(ui): Fix signal bars and restore individual connection icons 2026-04-08 18:07:06 +02:00
darken cb915d6263 feat(aap): Also update system Bluetooth alias when renaming
After the AAP rename succeeds, try to update Android's per-device bond alias via the hidden BluetoothDevice.setAlias(String) method, so the new name also shows up in the system Bluetooth settings on this phone.

Known failure mode on Android 12+: setAlias is gated behind a Companion Device Manager (CDM) association at the service layer, and raises 'does not have a CDM association with the Bluetooth Device' for third-party apps that don't hold one. In that case, a dedicated snackbar explains that the system rename didn't go through and suggests renaming manually in system settings or re-pairing.

The AAP-level rename is always attempted first and is the load-bearing part; the system alias is a best-effort extra.
2026-04-08 15:36:18 +02:00
darken 41245d928c fix(aap): Make AirPods rename actually apply on device
Switch the AAP rename packet to the opcode 0x1A format (04 00 04 00 1A 00 01 [size] 00 [name]) matching the LibrePods documentation and Linux implementation. The previous 0x1E variant (from the LibrePods Android code) was silently ignored by AirPods Pro 2 USB-C firmware — no 0x001D echo, no persistence across reconnect.

Verified on AirPods Pro 2 USB-C (firmware 81.2675...): the device now echoes the new name back via the next 0x001D INFORMATION message, and the name persists after disconnect/reconnect.

Also hardens the rename UX: gate the edit icon on isAapReady (was isAapConnected, which allowed sending during HANDSHAKING), apply an optimistic deviceInfo update with a scoped rollback on send failure, surface send errors via a new Event.SendFailed + snackbar, and restrict dialog input to ASCII with inline error feedback. Unifies send() / sendProGated() through a single sendInternal() helper so error plumbing benefits every command, not just rename.

Note: this only updates the AirPods firmware's self-reported name. Android's system Bluetooth settings read from the bond database and are not affected — renaming there still requires the Android system Bluetooth UI.
2026-04-08 15:36:18 +02:00
darken ccd95a0981 feat(aap): Show 'Not connected' card when AAP is unavailable 2026-04-08 11:49:07 +02:00
darken 2d38db5cba feat(aap): Log decoded 0x1D segments for engraving discovery
Diagnostic-only NUL-delimited UTF-8 segmentation of the 0x1D INFORMATION packet, logged at INFO so it lands in debug recordings via the existing FileLogger pipeline. The production decode path stays untouched — this only adds visibility, no behavior change. Refs #173.
2026-04-08 11:21:15 +02:00
darken 175f8e36b5 fix(aap): Address device settings review feedback
- Show connected device MACs in correct byte order (remove stale reversal)

- Rename 'Volume Swipe Length' setting to 'Volume Swipe Wait Time'

- Hide 'Charging Sounds' toggle: real case tones go over ATT and the actual effect of this AAP setting is unknown. Decode kept internally so AAP freshness signal still refreshes.

- Fix duplicate commands on End Call/Mute Mic radio buttons: use Modifier.selectable with Role.RadioButton, short-circuit when already selected, and wrap options in selectableGroup for TalkBack.
2026-04-07 18:53:54 +02:00
darken a102b03261 fix: Show signal strength as bar indicator instead of percent 2026-04-07 17:56:29 +02:00
darken 57fc4d8e7b ui: Remove noise control label from DeviceSettingsScreen 2026-04-07 15:36:20 +02:00
darken f67336d4fe fix(aap): Synthesize AAP-only devices for cold-start window 2026-04-07 15:24:03 +02:00
darken a511c57b7d fix(aap): Keep device settings visible when BLE scan goes stale 2026-04-07 13:28:37 +02:00
darken 66e3ab1670 Release: 5.0.0-beta0 2026-04-04 11:47:09 +02:00
darken 7e73ac80e9 fix(aap): Fix stem actions volume control, reset button, and Default/No Action semantics
Fix volume up/down doing nothing by using adjustSuggestedStreamVolume instead of dispatchMediaKeyEvent which ignores volume keycodes.

Add reset-to-defaults button with confirmation dialog in the stem actions TopAppBar.

Rename 'None' to 'Default' (firmware handles the press) and add 'No Action' (claimed but do nothing) to correctly model per-press-type claim mask semantics.

Remove disableNone lock; add cross-side auto-set logic so selecting Default resets both sides and selecting an action promotes the other side from Default to No Action.
2026-04-04 11:41:26 +02:00
darken 1ae9ae587e feat: Add device settings and controls to upgrade screen benefits 2026-04-04 11:41:26 +02:00
darken 4631aa8bb2 fix: Hide stale cached battery indicator when AAP is connected 2026-04-04 11:41:26 +02:00
darken 33c8fe76a6 fix(aap): Polish noise control UI and fix ANC mode switching
Combine ANC mode selector with listening mode cycle into a card with eye icon visibility toggles and radio button mode selection. Send AllowOff command when toggling OFF visibility.

Fix ANC resend logic fighting rapid mode changes by cancelling pending resend jobs on new commands. Reorder settings into Noise Control, Sound, Controls, General, and Connections sections.
2026-04-04 11:41:26 +02:00
darken e356344ba6 feat(aap): Add new device settings, stem actions, and noise control UI
Add 8 new AAP writable settings (microphone mode, ear detection toggle, listening mode cycle, allow off, stem config, sleep detection, in-case tone, device rename) and 4 new data reception features (stem press events, connected devices, audio source, EQ data).

Implement stem press action system with per-bud configurable Android actions (play/pause, next/prev track, volume), auto-sent stem config on connection, and dedicated config screen.

Combine ANC mode selector with listening mode cycle visibility into a unified noise control component. Eye icons control which modes appear in the dashboard card and settings. Pro gating with stars icon for upgrade-required features.

Add 64 unit tests covering all new decoders, model feature flags, malformed payloads, and rename byte-length validation.
2026-04-04 11:41:26 +02:00
darken e014ad8045 feat: Slim down overview cards and fix cached device icons
Remove timestamps, connection state, and conversation awareness from overview cards. Move signal badge inline with model subtitle. Relocate removed info to device settings screen info card. Fix cached devices showing Gen 1 icons by adding per-pod icon properties to PodModel. Add profile name subtitle to device settings toolbar. Simplify preview coverage to full/minimal/cached variants.
2026-04-03 15:36:27 +02:00
darken 9d446938bd fix(aap): Re-send ANC mode when firmware overrides after ear insertion 2026-04-03 13:38:53 +02:00
darken fea1092a95 refactor: Move AAP auto-connect from MonitorService to DeviceMonitor scope
AAP connections now run in appScope via AapLifecycleManager, independent of MonitorMode. Fixes AAP not connecting when monitor mode is MANUAL.

Also fixes reconnect cleanup on flow cancellation (try/finally).
2026-04-03 13:27:52 +02:00
darken adfedc1cce feat: Add device settings screen for AAP controls 2026-04-03 13:09:31 +02:00
darken c609b03148 feat(overview): Gate dashboard device count for free users
Free users see only their highest-priority profiled device. Additional devices are hidden behind an upgrade card showing the count and a flavor-aware CTA (Upgrade/Donate).
2026-04-02 18:30:40 +02:00
Matthias Urhahn 927a198d8e Merge pull request #475 from d4rken-org/feat/profile-drag-reorder
Device: Add drag-to-reorder for device profiles
2026-04-02 18:18:08 +02:00
darken 1f5f406b33 feat(profiles): Add drag-to-reorder for profile list
Wire up the missing drag-to-reorder UI that the priority hint already advertises. Long-press a profile row to reorder. Extracts a reusable ReorderableState component into common/compose.

Backend (repo + ViewModel) was already in place; this adds the gesture handling, auto-scroll, visual feedback, and ID-based reorder validation.
2026-04-02 18:17:00 +02:00
Matthias Urhahn 4a475419e8 Merge pull request #469 from d4rken-org/feat/device-state-cache
Device: Remember battery levels and show offline device cards
2026-04-02 17:36:59 +02:00
darken 7c37854076 refactor: Extract toCachedState extension and add tests
Move per-device persist logic from DeviceMonitor into a pure PodDevice.toCachedState() extension in the cache package. Add ToCachedStateTest with coverage for creates, skips, dedup, and slot preservation. Delete unused PodSorter.
2026-04-02 16:46:56 +02:00
darken 30d7efeec2 Moving packages around 2026-04-02 16:40:56 +02:00
darken 942e03af36 refactor: Merge device state persistence into DeviceMonitor
Eliminate DeviceStatePersister class by chaining persistence as a side effect in DeviceMonitor's flow. The flow is now shared via replayingShare(appScope) so persistence runs once per emission regardless of subscriber count, and works for BLE-only devices without MonitorService.
2026-04-02 16:36:12 +02:00
darken 24a84a49f1 refactor: Remove meta from PodDevice public API and delete unused PodMonitorExtensions
All external consumers now use top-level PodDevice properties. BlePodMonitor extensions were dead code — all callers use DeviceMonitor equivalents.
2026-04-02 16:01:00 +02:00
darken e3693628c2 refactor: Use PodDevice properties instead of reaching through BLE metadata
Replace device.meta?.profile?.address/id/label with device.address/profileId/label in AutoConnect, PopUpReaction, and BatteryGlanceWidget.
2026-04-02 15:42:12 +02:00
darken 94156b6200 fix: Show profile name on cached-only device cards
Add label property to PodDevice, populated from the profile in DeviceMonitor. Cards now use device.label instead of reaching through BLE metadata, so cached-only cards display the profile name instead of '?'.
2026-04-02 15:37:33 +02:00
darken 5ae15d828d refactor: Use PodDevice charging properties instead of duplicating fallback logic 2026-04-02 15:23:16 +02:00
darken 360067b07e refactor: Move persisters to monitor package and fix stale BLE device eviction
Move DeviceStatePersister and AapKeyPersister from reaction to monitor package since they are always-on infrastructure, not user-togglable reactions.

Add periodic ticker to BlePodMonitor to force stale device eviction when BLE scanner produces no results, fixing cached card not appearing after disconnect.
2026-04-02 15:14:47 +02:00
darken e91243e577 feat: Add unified device state cache for persistent battery display
Replace PodDeviceCache (raw BLE scan bytes) with DeviceStateCache that stores decoded combined device state (battery, charging, model) per profile.

Battery values persist across app restarts with per-slot timestamps. Cached-only cards appear for offline devices with muted visuals and a staleness indicator. Fallback chain: AAP -> BLE -> cached.
2026-04-02 13:56:09 +02:00
darken 19d61ca5cb feat: Add AAP session tests for AirPods Pro 2 USB-C
Add AirPodsPro2UsbcAapSessionTest with real captured bytes from a live

device session (model A3048, Pixel 8, 2026-04-02). Covers handshake,

device info, battery states, private keys, all Pro 2 USB-C settings

(including ADAPTIVE ANC, VolumeSwipe, ConversationalAwareness),

ear detection across 6 transitions, and unhandled messages.

Also document ChargingState observations across models and

EndCallMuteMic subtype variations in code comments.
2026-04-02 13:45:52 +02:00
darken 7b403defab fix: Add Compose stability annotations and fix lazy list keys
Add @Stable to PodDevice to enable Compose referential equality checks, reducing unnecessary recompositions from the 3-second update ticker. Make icon properties non-null with built-in defaults and fix lazy list keys to use stable string identifiers.
2026-04-02 13:18:05 +02:00
darken ceb4108306 feat(profiles): Filter paired devices already used by other profiles
Show devices claimed by other profiles as disabled in the dropdown with the claiming profile's name. Enforce address uniqueness at the repo layer.
2026-04-02 12:23:06 +02:00
darken fe4ac306cf fix: Correct feature flags for multiple PodModel entries
Remove false positive hasEarDetection from Beats Studio Buds and Studio Buds+ (neither has in-ear detection). Remove false positive hasVolumeSwipe and hasVolumeSwipeLength from AirPods 4 ANC (volume swipe is exclusive to AirPods Pro). Add missing hasEarDetection to AirPods Max, AirPods Max USB-C, AirPods Gen 4, Beats Solo Pro, and Beats Studio 3 (all have head/ear detection per Apple docs).
2026-04-01 06:57:45 +02:00
darken 618229b96b feat: Add AAP session tests for AirPods Pro Gen 1
Add AirPodsProAapSessionTest with real captured protocol data from AirPods Pro (A2084). Fix AIRPODS_PRO feature flags: remove hasVolumeSwipe/Length (hardware limitation), add hasEndCallMuteMic (firmware-supported). Fix primary pod decoder to accept byte[2]=0x00 format sent by Pro 1 on initial connect.
2026-03-31 22:36:23 +02:00
darken 1931e60b07 feat: Speed up AAP L2CAP reconnection
Only attempt AAP connections to classically-connected devices, eliminating futile retries for devices connected to other phones.

- Filter initialConnect() to devices in connectedAddresses

- Use mapLatest so stale retry loops cancel on state changes

- Parallelize per-profile connection attempts

- Add 5s connect timeout (best-effort) to cap retry cycles

- Add classic BT check to reconnectOnDisconnect()

- Keep MonitorService alive when AAP connections are active
2026-03-31 22:06:23 +02:00
darken 7bdc9ca212 feat: Auto-correct device model from AAP device info
When connected via AAP, the device reports its hardware model number. If the profile has a wrong or missing model, detect the mismatch and automatically correct it with a reconnect to apply correct feature flags (ANC modes, InitExt, etc.).

Adds modelNumbers field to PodModel enum with Apple hardware identifiers for all known devices, and a fromModelNumber() lookup function.
2026-03-31 20:52:57 +02:00
darken 04ea3d0cd8 feat: Add retry logic for AAP L2CAP connection failures
Initial connect had no retry — a single failed L2CAP attempt was silently swallowed. Reconnect-on-disconnect used separate longer backoff delays.

Both paths now share the same retry schedule (3s,3s,3s,5s,5s,10s,10s) giving 7 retries over ~39s. Initial connect checks if another path already reconnected before each retry.
2026-03-31 19:17:09 +02:00
darken fef6c97d2d feat: Detect primary pod via AAP cmd 0x0008 for faster ear detection 2026-03-31 19:17:09 +02:00
darken dc2e318374 fix: Trigger AAP connection when classic Bluetooth connects
initialConnect() only ran on profile changes, so if the L2CAP connection failed at service start (device not yet connected), it was never retried. Now also triggers on connectedDevices changes.

Also fix reconnect BLE address comparison: was comparing BLE RPA with bonded BR/EDR address (never matches), now uses profile address.
2026-03-31 19:17:09 +02:00
darken 71638c13ab feat: Decode AAP ear detection and stabilize ANC mode changes
Decode cmd 0x0006 as EarDetection with per-pod placement (IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED). Map AAP primary/secondary to left/right using BLE primary pod bit.

Queue ANC mode changes when no pod is in ear, auto-send when a pod goes in ear. Show pending mode in UI with secondary color treatment.

Debounce device-initiated ANC mode cycling during ear transitions. Skip debounce for user-initiated commands and initial handshake. Optimistic UI update on send for instant feedback.
2026-03-31 19:17:09 +02:00
darken bd5c6bf858 feat: Add MagicPodsCore and LibrePods to acknowledgements 2026-03-31 19:17:09 +02:00
darken 70e5b99e1d feat: Add connection type indicators to SignalBadge
Move BLE key and AAP connection icons into the SignalBadge pill.

Add BleKeyState enum on PodDevice to expose IRK/ENC state cleanly.

Key icon: outlined for IRK-only, solid for IRK+ENC.

Bluetooth icon shown when AAP transport is active.
2026-03-31 19:17:09 +02:00
darken f65c6e4a29 feat: Add BLE device support for 6 new Beats models
Solo Pro (0x0C20), Solo 4 (0x2520), Solo Buds (0x2620), Studio Buds (0x1120), Studio Buds+ (0x1620), Studio Pro (0x1720). Tests use handcrafted data pending real captures.
2026-03-31 19:17:09 +02:00
darken c860ecc430 feat: Integrate AAP connection freshness into signal quality indicator 2026-03-31 19:17:09 +02:00
darken a8593eecd3 refactor: Reorganize pods package into ble/ and aap/ domains
Move BLE-specific code (snapshots, devices, factories, protocol) under apple/ble/. Move AAP code under apple/aap/ with protocol/ subdirectory. PodModel stays in apple/ as the only shared type.
2026-03-31 19:17:09 +02:00
darken 94e28fb0b5 refactor: Move Proximity* classes into protocol.ble package 2026-03-31 19:17:09 +02:00
darken 4808e04146 fix: Update Dagger component reference after BaseBlePodsTest rename 2026-03-31 19:17:09 +02:00
darken aebf43c355 style: Reformat PodModel enum for better maintainability
Standardize the formatting of pod models and their feature sets. This improves the readability of device-specific capabilities and ensures cleaner diffs for future hardware additions.
2026-03-31 19:17:09 +02:00
darken c1cd99f701 feat: Add AAP L2CAP protocol support for reading and writing AirPods settings
Full integration of the Apple Accessory Protocol (AAP) over L2CAP, enabling direct communication with AirPods for 1% battery granularity, ANC mode control, Conversation Awareness toggle, and private key exchange for BLE encrypted battery.
2026-03-31 19:17:09 +02:00
darken 3fa01bb510 refactor: Rename PodDevice to BlePodSnapshot, MonitoredDevice to PodDevice, extract PodModel
Complete type rename chain: PodDevice (interface) becomes BlePodSnapshot, MonitoredDevice (facade) claims PodDevice name, Model enum extracted to top-level PodModel. Delete redundant type alias files. Fix stale comments and log tags.
2026-03-31 19:17:09 +02:00
darken e84f8762b4 Extract Model to PodModel.kt 2026-03-31 19:17:09 +02:00
darken ac21261d4d refactor: Migrate all consumers from PodDevice to MonitoredDevice
Replace direct PodDevice/PodMonitor usage with MonitoredDevice/DeviceMonitor across ViewModels, UI screens, notifications, widgets, reactions, and service. All interface cast-based property access replaced with flat MonitoredDevice properties. Make L2capSocketFactory injectable.
2026-03-31 19:17:09 +02:00
darken c19ee88ae6 refactor: Add BlePodSnapshot and BlePodMonitor type aliases
Introduce type aliases for the planned rename (PodDevice -> BlePodSnapshot, PodMonitor -> BlePodMonitor). New code uses the aliases to clarify BLE-specific types vs the unified MonitoredDevice facade.

Full codebase rename deferred to IDE refactoring pass (103 files, 470 occurrences).
2026-03-31 19:17:09 +02:00
darken f751cdb37e feat: Add MonitoredDevice facade and DeviceMonitor merge point
MonitoredDevice unifies BLE and AAP data sources with dynamic resolution.

DeviceMonitor combines PodMonitor + AapConnectionManager into a single Flow.

To be renamed to PodDevice/PodMonitor when the old types are renamed to BlePodSnapshot/BlePodMonitor.
2026-03-31 19:17:09 +02:00
darken 28cd3b1340 test: Add unit tests for AAP protocol layer and Model.Features 2026-03-31 19:17:09 +02:00
darken 3e2852e622 feat: Add AAP protocol layer, L2CAP socket factory, and Model.Features
Add L2CAP infrastructure (HiddenApiBypass, ArtMirror, L2capSocketFactory) in common/bluetooth/l2cap/ for BR/EDR L2CAP socket creation on Android 17+.

Add AAP protocol abstraction (AapSetting, AapCommand, AapMessage, AapFramer, AapDeviceProfile, AapConnection, AapConnectionManager) with sealed domain classes containing zero wire bytes.

Add Model.Features capability flags (hasDualPods, hasCase, hasEarDetection, hasAncControl) to all 25 device model enum entries.

Add debug-only L2capPocActivity for testing AAP connections.
2026-03-31 19:17:09 +02:00
darken b919d8d19b chore: Harden GitHub Actions workflow permissions
Add explicit permissions and persist-credentials: false to all workflows.

Without an explicit permissions block, GITHUB_TOKEN inherits the repo default (write-all). These CI workflows only need contents: read. The release workflow already declares contents: write at job level where needed.

persist-credentials: false prevents the token from lingering in .git/config for subsequent steps, reducing attack surface if a third-party action is compromised.
2026-03-28 09:22:01 +01:00
darken 9d6ec2b6bb Release: 4.0.3-rc0 2026-03-23 14:06:37 +01:00
darken 9a85b23d91 chore: Enable support-investigator plugin in .claude/settings.json 2026-03-23 14:01:34 +01:00
darken 217d9be177 fix: Show key icon for single-pod devices when encryption keys are set 2026-03-23 13:00:48 +00:00
darken 53447f3fb9 fix: Stop retrying auto-connect after SecurityException
BluetoothHeadset.connect() requires MODIFY_PHONE_STATE on modern Android, which is a system-only permission. Detect the SecurityException and stop further attempts instead of retrying every second.
2026-03-23 11:24:23 +00:00
darken db759bac7c Release: 4.0.2-rc1 2026-03-23 06:11:39 +01:00
darken 6837db5b84 fix: Remove mapping file param from Fastlane supply lanes
AGP 9.0.1 no longer generates a separate mapping.txt for bundle tasks when -dontobfuscate is active. The mapping param was causing supply to fail on a non-existent file.
2026-03-23 06:11:09 +01:00
darken 83b24cced2 Release: 4.0.2-rc0 2026-03-22 23:16:51 +01:00
darken d913e72c5c General: Update app translations from Crowdin (retranslated strings) 2026-03-22 21:45:33 +00:00
darken 5c16c08dee General: Update hy-AM and th fastlane translations from Crowdin 2026-03-22 21:45:33 +00:00
darken 20e22c0910 General: Update app translations from Crowdin 2026-03-22 21:45:33 +00:00
darken fa3e56b0f3 General: Update fastlane translations from Crowdin 2026-03-22 21:45:33 +00:00
darken f25718ab2c fix: Catch Hilt DI failure in MonitorService.onCreate()
Wrap super.onCreate() (Hilt injection) in try-catch to prevent

DI failures from crashing the process. The service is already

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

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

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

clock before the BroadcastReceiver even ran, consuming timeout budget

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

started by BluetoothEventReceiver, BootCompletedReceiver, and

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

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

- Migrate PodDeviceCache from Moshi to kotlinx Json injection

- Add MapIntByteArrayBase64Serializer for BleScanResult cache compat

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

- Delete Moshi adapters, ProGuard rules, and build config

- Convert compat tests to pure kotlinx round-trip tests
2026-03-09 20:29:19 +00:00
darken 8675d73a98 fix(debug): Persist recording session across app restarts
The debug recording trigger file now stores the session directory path and start timestamp. On app restart, the recorder resumes into the same session directory and log file instead of creating a new one, preserving the original start time so the short-recording guard doesn't reset.
2026-03-09 17:34:29 +00:00
darken 3423786f06 chore: Remove redundant package name from manifest 2026-03-09 17:41:08 +01:00
darken 00083707c6 fix(debug): Harden debug session recording and sharing 2026-03-09 16:39:56 +00:00
darken 0cdd2a4f7a Release: 4.0.0-rc0 2026-03-09 16:55:58 +01:00
darken 8e3b235c81 ui: Improve PodGauge layout alignment in DualPodsCard 2026-03-09 16:46:25 +01:00
darken 879ec1b69c General: Update app translations from Crowdin 2026-03-09 14:33:56 +00:00
darken c833fd2c92 General: Update fastlane translations from Crowdin 2026-03-09 14:33:56 +00:00
darken d7d9d53ff3 refactor: Replace MaterialAlertDialogBuilder with Compose AlertDialog
Replace all 11 instances of MaterialAlertDialogBuilder across 5 files with Compose AlertDialog. Use sealed dialog state per screen to prevent dialog stacking. Add shared ConfirmationDialog composable. Delete dead ErrorDialog.kt.
2026-03-09 12:46:23 +00:00
darken cba239f411 General: Update app translations from Crowdin 2026-03-09 12:40:01 +00:00
darken 853e877a59 General: Update fastlane translations from Crowdin 2026-03-09 12:40:01 +00:00
darken de89c51457 fix(debug): Use human-readable UTC timestamp in debug session directory names
Parse creation time from the embedded filename timestamp instead of relying on filesystem attributes, which are non-deterministic and caused flaky test failures in CI.
2026-03-09 11:57:42 +00:00
darken 7f8daddfe0 feat(debug): Show confirmation dialog after sharing debug logs
After sharing a debug log or sending a contact form email, show a dialog on return asking if the send was successful. Confirming deletes the log session and closes/navigates back.
2026-03-09 11:17:50 +00:00
darken a76e43ac04 chore: Use List in Zipper and add global ExperimentalMaterial3Api opt-in
Replace Array<String> with List<String> in Zipper.zip().

Add global -opt-in flag for ExperimentalMaterial3Api in build.gradle.kts

and remove per-file @OptIn annotations from 12 Compose screens.
2026-03-09 04:53:01 +00:00
darken fd0dc49396 test(debug): Add session ID round-trip and edge case tests 2026-03-09 04:53:01 +00:00
darken a5f5feacae fix(debug): Fix deadlock risk, missing IO dispatcher, and thread safety in session manager
Replace sessions.first() inside fsMutex.withLock with synchronous volatile

read to avoid potential deadlock. Wrap file deletions in IO dispatcher.

Log partial delete failures. Make pendingAutoZips thread-safe via

Collections.synchronizedSet. Add test for ext/cache same-basename IDs.
2026-03-09 04:53:01 +00:00
darken 70a3114876 chore(crowdin): Update Crowdin CLI binary 2026-03-09 04:53:01 +00:00
darken 557ee9b3ed fix(test): Fix flaky sort test that relied on filesystem creation time 2026-03-09 04:53:01 +00:00
darken 58bd0f91f5 refactor(debug): Use Instant for createdAt, extract scanSessions helpers, add pendingAutoZips guard 2026-03-09 04:53:01 +00:00
darken b2f8586dc9 fix(recorder): Use red delete button and rename Done to Close 2026-03-09 04:53:01 +00:00
darken 62797e8ea2 test(support): Add ContactFormViewModel.State unit tests 2026-03-09 04:53:01 +00:00
darken c614e7bd4f test(debug): Add unit tests for Zipper, DebugLogZipper, RecorderModule.State, and scanSessions edge cases 2026-03-09 04:53:01 +00:00
darken 62e39ce3d2 refactor(support): Add zipFile/compressedSize to Ready, harden session manager
Add zipFile and compressedSize fields to DebugSession.Ready, populated during scan. Simplify RecorderActivityVM by reading compressed size directly from session model. Apply code review fixes: fsMutex in zipSessionAsync, CancellationException handling, ZipOutputStream use{}, atomic file moves, dedup deriveSessionId, LifecycleResumeEffect for session refresh.
2026-03-09 04:53:01 +00:00
darken 7df74e3b44 refactor(support): Unify debug log session model and add session manager bottom sheet
Introduce DebugSession sealed interface as single source of truth for session lifecycle (Recording, Compressing, Ready, Failed). Auto-compress after recording stops. Replace scattered clear/delete actions with a session manager bottom sheet on the Support screen.
2026-03-09 04:53:01 +00:00
darken 06e5954ef5 fix(upgrade): Reduce FOSS sponsor unlock timer from 10s to 5s 2026-03-07 14:26:35 +00:00
darken 450a187b65 test(reaction): Add ViewModel and reaction logic unit tests
Extract pure decision functions from AutoConnect and PopUpReaction for testability, following the existing PlayPause pattern. Add FakeDataStoreValue test helper.

Fix reversed Duration.between args in PopUpReaction connection monitor that caused the false-positive age filter to never trigger.
2026-03-07 14:19:17 +00:00
darken da6a6f6ff2 fix(widget): Broaden work-runtime keep rule and add widget error handling
R8 full mode strips no-arg constructors from work-runtime classes accessed via

Class.newInstance() reflection (WorkDatabase_Impl, OverwritingInputMerger, etc).

Replace narrow WorkDatabase_Impl keep rule with broad androidx.work.** rule.

Also add @Keep to Hilt WidgetEntryPoint and wrap provideGlance setup in

try-catch so the widget shows an error message instead of spinning forever.
2026-03-06 18:47:46 +00:00
darken d37c8aa3a5 fix(proguard): Add keep rule for WorkDatabase_Impl stripped by R8 full mode 2026-03-06 18:47:46 +00:00
darken 5cd97805b4 test(upgrade): Add unit tests for Google Play billing and upgrade logic 2026-03-05 13:41:34 +00:00
darken e35bce4d19 feat(upgrade): Add yearly subscription with trial to Google Play billing
Rewrite SKU type system from data class to interface hierarchy supporting both IAP and subscriptions. Add subscription query, offer matching, and billing flow launch for yearly plans with optional free trial.

FOSS: Add sponsor-gate with 10-second timer and snackbar nudge.
2026-03-04 22:38:23 +00:00
darken 1a196689db fix(test): Move FossUpgrade tests to testFoss source set for gplay compat
FossUpgrade is foss-flavor-only, so tests referencing it must live in testFoss/ to avoid compilation failures in testGplayDebugUnitTest.
2026-03-04 15:23:34 +00:00
darken 8ada7b071d test(settings): Add missing DataStore serialization and migration compat tests
Cover round-trip tests for ThemeStyle, ThemeColor, MonitorMode, ScannerMode, AutoConnectCondition, FossUpgrade, and InstantEpochMillisSerializer. Add Moshi decode tests for ThemeStyle, ThemeColor, AutoConnectCondition, FossUpgrade, and FossUpgrade.Reason annotation parity.
2026-03-04 15:23:34 +00:00
darken a92e364ca6 refactor(settings): Migrate preferences from SharedPreferences to DataStore
Replace FlowPreference<T> wrapping SharedPreferences with DataStoreValue<T> wrapping AndroidX DataStore. Includes SharedPreferencesMigration for preserving existing user data, kotlinx-serialization for complex types (replacing Moshi for preferences), and comprehensive unit tests for the new infrastructure.
2026-03-04 15:23:34 +00:00
darken 6326f4a5b9 fix(monitor): Gate BLE scanning on permission state and stop retrying on SecurityException
PodMonitor's retryWhen always returned true, causing infinite 3-second retry loops when BLUETOOTH_SCAN permission was missing (e.g. on emulators). Now the scan flow checks missingPermissions before starting, and SecurityException aborts retries since it requires user action.
2026-03-03 20:52:32 +00:00
darken abb189f43c feat(upgrade): Replace upgrade dialog with full-screen upgrade screen
Add dedicated Compose upgrade screens for both FOSS and gplay flavors, presenting pro benefits before the purchase/sponsor action. Migrate all upgrade dialog callers to navigate to the new screen instead.
2026-03-03 20:35:32 +00:00
darken e80bf1db6c feat(support): Add wiki link to support screen 2026-03-03 17:22:04 +00:00
darken cd6488a713 refactor(ui): Replace waitForState and shareLatest with lifecycle-aware state collection
Add collectAsStateWithLifecycle for all Compose state collection (except Glance widgets). Add asLiveState() extension to ViewModel2 as a simpler replacement for shareLatest(scope = vmScope). Remove custom waitForState helper and shareLatest utility.
2026-03-03 17:21:55 +00:00
darken e13331dfcf feat(monitor): Show disable hint in foreground notification when extra notification is enabled 2026-03-03 15:26:10 +00:00
darken dcf2ade9ec feat(dashboard): Reorder top bar icons and use star for donate 2026-03-03 15:16:50 +00:00
darken 40f0aba462 docs: Add pull request guidelines with user-facing changelog conventions 2026-03-03 15:24:05 +01:00
darken c760d5758d feat(support): Polish support screen, recorder UI, and settings
Reorder support screen items with category headers, add log session count to clear action, fix contact form hint strings, filter active recordings from log picker, convert RecorderActivity to Compose with proper system bar insets and Material 3 color tokens, refresh log metadata on resume, add wiki link to settings, and move support entry under Other category
2026-03-03 13:20:31 +01:00
darken b609c1aced feat(support): Add contact support form and enhanced debug recorder
Add structured contact form with category selection (Question/Feature/Bug), description with word count validation, expected behavior field for bugs, and debug log picker with inline recording.

Enhance RecorderActivity with full-screen hero layout, file list, Share/Keep/Discard actions. Migrate debug logs from flat files to session directories in external files dir. Add DebugLogZipper, EmailTool attachment support, log folder size display, and clear stored logs.
2026-03-03 13:20:31 +01:00
darken b3b55f1ca3 feat(settings): Make upgrade prompt a clickable button inline with appearance header 2026-03-02 16:36:52 +01:00
darken 073de3cf35 feat(settings): Add pro gating for theme customization
Gate theme mode, style, and color settings behind pro/upgrade status.

Add Material Design 3 surface container color tokens to all color palettes.

Move upgrade prompt string to main resources with generic wording.

Disable theme preference items for non-pro users instead of late rejection.
2026-03-02 16:36:52 +01:00
darken 8c278f2a3c fix(settings): Point changelog to project website instead of GitHub releases 2026-03-01 15:57:11 +01:00
darken c103c6adc9 feat(widget): Migrate from RemoteViews to Jetpack Glance
Replace the legacy AppWidgetProvider/RemoteViews implementation with Glance AppWidget for reactive updates, proper centering, and a unified render state model.

- Add BatteryGlanceWidget with collectAsState for live device/profile/upgrade data

- Add WidgetRenderState sealed class and WidgetRenderStateMapper

- Add GlanceWidgetContent (Glance composables) and ComposeWidgetPreview (config preview)

- Fix centering by passing spacing via modifier param (no trailing padding on last item)

- Remove old XML widget layouts and RemoteViews rendering code
2026-03-01 15:56:43 +01:00
darken 4db2edd29a refactor(monitor): Clean up collapsed notification layouts
Normalize root containers to FrameLayout, remove weight-based spacers,

standardize icon sizes to 16dp, and use consistent margins.
2026-03-01 00:15:42 +01:00
darken eefe740b6f feat(monitor): Add expanded notification with battery progress bars
Add distinct expanded (big) notification layouts with visual battery

progress bars for dual pods, single pods, and unknown devices.

Collapsed views remain unchanged. Status icon containers use fixed

width to ensure uniform progress bar lengths across rows.
2026-03-01 00:15:42 +01:00
darken a47addc8bc Adjust enabled plugins 2026-02-28 21:21:42 +01:00
darken 9cc5a82adf fix(widget): Sync window background with Compose theme in config activity
The widget configuration activity used enableEdgeToEdge() but never synced the Android window background with the Compose Material theme. When the system dark/light mode disagreed with the app's theme setting, text became invisible (dark on dark). Also extend the bottom bar Surface under the navigation bar to eliminate the white gap.
2026-02-26 12:50:28 +01:00
darken bec24bfd6f chore(screenshots): Regenerate profile creation screenshots 2026-02-26 01:12:22 +01:00
darken e6eb0d818f docs: Add screenshot pipeline documentation 2026-02-26 01:12:22 +01:00
darken 25e3e7d792 feat(profiles): Modernize profile creation/edit screen UI 2026-02-26 00:05:24 +01:00
darken ebcb997bdd feat(screenshots): Generate full localized Play Store screenshots (544 images)
- 67 locales × 8 screens at Pixel 8 resolution (1080×2400, 428dpi)

- Reduce batch size to 2 to avoid layoutlib OOM at 1080p (~10MB per render)
2026-02-25 22:57:34 +01:00
darken 76c55bf3be feat(screenshots): Add case pop-up screenshot, reorder Play Store sequence
- Add CasePopUp as screenshot 3 (popup shown on case open)

- Reorder: WidgetConfiguration moves to 4, app screens follow at 5-8

- Add Widget Configuration screen title heading

- Fix appearance card reset button to always wrap below title

- Balance padding on requires-Pro notice in widget config bottom bar
2026-02-25 22:57:34 +01:00
darken fc9bcafae9 feat(screenshots): Set up localized Play Store screenshot pipeline
- Add Compose screenshot test infrastructure (build config, screenshotTest source set)

- Add 7 localized screenshots covering all key app screens

- Device spec set to Pixel 8 (1080x2400, 428dpi); batch size 4 for 1080p memory budget

- Add screen title heading and fix appearance card header layout in WidgetConfigurationScreen

- Balance padding on requires-Pro notice in widget config bottom bar
2026-02-25 22:57:34 +01:00
darken c4e58917da Add instruction manual for automated screenshots via debugbadger 2026-02-25 22:57:34 +01:00
Matthias Urhahn 3730a78e7a Update links and badges in README.md 2026-02-25 10:27:01 +01:00
Matthias Urhahn 222a6c43c9 Update README.md 2026-02-25 10:25:38 +01:00
Matthias Urhahn b1fe8490d3 Merge pull request #412 from d4rken-org/fix/settings-category-ordering
fix(settings): Move monitor and scanner mode out of appearance category
2026-02-25 10:20:29 +01:00
darken a0fe20e296 fix(settings): Move monitor and scanner mode above appearance category
Monitor mode and scanner mode are functional settings, not appearance
settings. Give them their own "Monitoring" category header and place
them at the top of the general settings screen.
2026-02-25 10:18:04 +01:00
Matthias Urhahn e2d127dae9 Merge pull request #411 from d4rken-org/fix/popup-flicker-on-case-open
fix(popup): Fix double popup flicker when opening AirPods case
2026-02-25 09:55:14 +01:00
darken b5c48be08e fix(popup): Update popup content in place instead of recreating overlay
When two popup triggers fire in rapid succession (e.g. connection + case open),
the overlay is now updated via Compose MutableState rather than torn down and
recreated, eliminating the visual flicker.
2026-02-25 00:34:19 +01:00
Matthias Urhahn 90f0c89b79 Merge pull request #410 from d4rken-org/feat/modernize-pod-cards
feat(ui): Modernize pod cards with circular battery gauges
2026-02-25 00:20:20 +01:00
darken 8dd165e1c8 feat(ui): Modernize pod cards with circular battery gauges
Replace LinearProgressIndicator bars with circular progress rings
around pod icons for a more visual, glanceable battery display.
2026-02-25 00:17:51 +01:00
Matthias Urhahn 119139cbc5 Merge pull request #409 from d4rken-org/feat/modernize-widget-config-screen
feat(widget): Modernize widget configuration screen
2026-02-24 23:36:44 +01:00
darken 5159e4c21b feat(widget): Modernize widget configuration screen with card layout and animations
Add card-based section grouping, animated transitions for custom mode,
animated color swatch selection, preset chip color indicators, and
Surface-based bottom bar and preview wrapper.
2026-02-24 23:35:07 +01:00
Matthias Urhahn 1c94354831 Merge pull request #408 from d4rken-org/fix/profile-unsaved-changes-false-positive
Fix false-positive unsaved changes dialog and popup notifications
2026-02-24 23:17:49 +01:00
darken 75d2bf0169 fix(popup): Prevent false-positive popups from BLE signal oscillation
Use profile-based identity matching and cooldown keys so two BLE
signals for the same AirPods share one cooldown timer. Revert
connection monitor to show-once-per-connection behavior.
2026-02-24 23:08:16 +01:00
darken 7e73777469 fix(profiles): Pass profileId from NavKey and reset state on exit
Navigation 3 doesn't auto-populate SavedStateHandle from NavKey args
like Navigation 2.x did. Pass profileId explicitly from the entry
lambda through the ScreenHost to the ViewModel via initialize().

Reset initialized flag on every exit path so re-entering a profile
reloads fresh data from the repository.
2026-02-24 20:38:04 +01:00
darken 3d07c284b9 fix(profiles): Don't show unsaved changes dialog when nothing was edited
In create mode, the init block pre-filled _currentState with a default
name but left _initialState empty. The hasUnsavedChanges() check saw
the non-blank name as a change, triggering the dialog on back press
even without user edits.

Set _initialState to match _currentState defaults in create mode and
unify the comparison logic to always use current != initial.
2026-02-24 20:38:04 +01:00
darken f6b1c69edc fix(profiles): Pass profileId from NavKey and reset state on exit
Navigation 3 doesn't auto-populate SavedStateHandle from NavKey args
like Navigation 2.x did. Pass profileId explicitly from the entry
lambda through the ScreenHost to the ViewModel via initialize().

Reset initialized flag on every exit path so re-entering a profile
reloads fresh data from the repository.
2026-02-24 20:33:54 +01:00
darken f421deca49 fix(profiles): Don't show unsaved changes dialog when nothing was edited
In create mode, the init block pre-filled _currentState with a default
name but left _initialState empty. The hasUnsavedChanges() check saw
the non-blank name as a change, triggering the dialog on back press
even without user edits.

Set _initialState to match _currentState defaults in create mode and
unify the comparison logic to always use current != initial.
2026-02-24 18:48:32 +01:00
darken e19608f567 chore: Remove stale artifacts from Compose migration
Delete dead XML layouts, unused drawable icons, the orphaned
RecyclerView adapter infrastructure, and Fragment-specific
extension functions left behind by the Compose migration.
2026-02-24 18:45:55 +01:00
darken 06f4115808 feat(ui): Add theme mode, style, and color settings
Add user-facing theme preferences with three independent axes:
- Theme mode (System/Dark/Light)
- Theme style (Default/Material You/Medium Contrast/High Contrast)
- Theme color (Blue/Green/Amber)

Includes safe Moshi enum fallback for corrupted preference values,
color palettes for all combinations, and window background sync
to prevent flash during navigation transitions.
2026-02-24 18:33:33 +01:00
darken 2ca8f686c8 feat(ui): Add Compose previews for all UI components and screens
Add @Preview2 multi-preview annotation (light/dark), mock data provider
with realistic device scenarios, and ~45 preview functions across cards,
screens, popup, and settings components.
2026-02-24 17:19:44 +01:00
darken 679250f7da fix(ui): Determine start destination before composing backstack
Check onboarding state in MainActivity before creating the NavBackStack,
so the correct screen is shown from the first frame. Removes the
redundant async check from OverviewViewModel that caused the dashboard
to briefly flash before redirecting to onboarding on fresh installs.
2026-02-24 16:50:05 +01:00
darken 8df2d78070 refactor(ui): Replace drawable icons with Material TwoTone Compose icons
Switch all Compose screens from Icons.Default/Filled and painterResource
drawable references to Icons.TwoTone for a consistent two-tone icon style.
Delete 24 drawable XML files that are no longer referenced.
2026-02-24 15:23:47 +01:00
darken 63692595a8 refactor(ui): Migrate from Fragments to Jetpack Compose
Replace Fragment-based UI with Jetpack Compose screens, adopting
Navigation3 for type-safe routing and Material3 theming throughout.

- Replace all Fragments/ViewHolders with Compose screens and cards
- Introduce NavigationController with Navigation3 runtime
- Add CapodTheme with Material3 design system
- Migrate PopUpWindow overlay to ComposeView with proper lifecycle
- Rewrite WidgetConfigurationActivity in Compose
- Add reusable settings composables (switch, slider, list preference)
- Update Kotlin to 2.2.10, add Compose BOM 2025.06.01
- Fix reactive state for unmatched devices toggle
- Add safety timeout for BT device enumeration
- Fix profile name validation error message
2026-02-24 15:05:09 +01:00
darken 576c6db8d0 Release: 3.1.0-rc0 2026-02-23 09:56:40 +01:00
darken 524cac8d30 General: Update app translations from Crowdin 2026-02-23 09:53:52 +01:00
darken 8939f788ad General: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-23 00:38:50 +01:00
darken 9ad3f3c180 refactor(monitor): Remove shared mutable builder from MonitorNotifications
Replace shared NotificationCompat.Builder + Mutex with a stateless
design where each method builds a fresh notification. Introduces a
baseBuilder() helper to deduplicate common setup. Removes builderLock
and suspend modifiers from getNotification/getNotificationConnected.
2026-02-22 06:32:12 +01:00
darken 5b59ddab0f fix(monitor): Prevent crash when OS denies foreground service start
Add promoteToForeground() helper that catches ForegroundServiceStartNotAllowedException
(Android 12+) and SecurityException, allowing graceful service exit instead of crashing.
2026-02-22 06:24:51 +01:00
darken ab24c48024 General: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-21 20:09:52 +01:00
darken a6482c7d76 feat(popup): Move signal quality indicator to top-right corner
Always show signal quality when available instead of gating on debug
mode. Position icon + percentage text in the top-right corner of the
card, aligned with the title. Remove unused DebugSettings dependency.
2026-02-21 19:22:07 +01:00
darken 99aa44b448 chore: Remove obsolete vector XML drawables replaced by PNGs 2026-02-21 19:22:07 +01:00
darken a801f2d701 feat(popup): Add product photo icons for all device models
Replace vector silhouette placeholders with actual product photo PNGs
for 14 device families across 5 DPI buckets. Fix drawable naming prefix
(devic_ -> device_), add per-model icon overrides for popup left/right/case
views, disable image tinting for color PNGs, fix inverted signal visibility
logic, and use monochrome icon for notification small icon.
2026-02-21 19:22:07 +01:00
darken 8405406d22 feat(popup): Redesign connection popup with Apple-inspired layout
Large centered device graphics (64dp), vertically stacked battery info,
adaptive width, rounded card with tonal close button.
2026-02-21 19:10:56 +01:00
darken 9785b5ea7f fix(playpause): Improve test coverage and clarify code comments
Add missing test cases for single-pod one-pod-mode pause direction,
EarDetectionState single-pod properties, and normal-mode steady-state.
Fix stale test comments that incorrectly claimed tests would fail.
Clarify onePodMode flow combine pattern and isWorn field semantics.
2026-02-21 06:21:07 +01:00
darken fd7f9f8ab3 Refactor PlayPause logic and fix behavior for SinglePods (e.g. AirPods Max)
Fix edge case where removing a pod while both were in ears didn't trigger pause in one-pod mode. The aggregate boolean (isEitherPodInEar) stayed true, masking the individual pod removal.

See thread by `ZV`:
https://discord.com/channels/548521543039189022/1437499888068726814
2026-02-21 06:21:07 +01:00
darken 46bb8c98de feat(widget): Add theme customization to widget configuration
Add color presets, custom color picker, transparency slider, and device
label toggle to the widget configuration screen. Widget preview updates
in real-time. Use profile label as device name in both preview and
actual widget.
2026-02-20 12:22:55 +01:00
darken b3ead17b12 Update Claude settings to remove various Bash command permissions 2026-02-20 10:16:21 +01:00
darken a794d59276 chore: Update Romanian app title in Fastlane metadata 2026-02-19 09:01:03 +01:00
darken 584c5c70d0 chore: Update Ruby dependencies in Gemfile.lock
Updated fastlane (2.232.1), aws-sdk, and google-cloud gems. Added several standard library shims (csv, logger, mutex_m, etc.) to support newer Ruby environments.
2026-02-19 09:01:03 +01:00
darken 5a55796518 General: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-19 09:01:03 +01:00
darken bdfdd93735 General: Update fastlane translations from Crowdin
Pull latest translations for app store listings from Crowdin.
Validated and reverted any problematic changes.
2026-02-19 09:01:03 +01:00
Matthias Urhahn e9a937e4f6 Remove duplicate RB Status badge
Removed duplicate RB Status badge from README.
2026-02-19 08:42:38 +01:00
Matthias Urhahn 0a8bae46a9 Add RB Status badge to README
Added RB Status badge to README.md
2026-02-19 08:30:01 +01:00
darken 7efcafbc71 chore: Bump Android Gradle Plugin to 9.0.1 2026-02-19 00:20:43 +01:00
darken 6b3f431e9e Release: 3.0.5-rc0 2026-02-16 10:38:54 +01:00
darken 6014004c0f Update translations (part 2) 2026-02-16 10:38:09 +01:00
darken ebbc5394a7 Update translations 2026-02-16 10:38:09 +01:00
darken e36868a2fc chore: Update app translations from Crowdin 2026-02-16 10:38:09 +01:00
darken 1c6fb8ed31 Update Claude settings to allow more Git and Crowdin commands 2026-02-15 17:42:42 +01:00
darken e780fd17d2 Update configuration paths for signing and Crowdin
Move local config files from `~/.appconfig/` to `~/.config/projects/`.
2026-02-14 15:36:16 +01:00
darken 86d3f3bc61 fix: Call startForeground() before Hilt DI to prevent timing crash
Move startForeground() before super.onCreate() in MonitorService to
avoid ForegroundServiceDidNotStartInTimeException when Hilt DI is slow
on backgrounded cold starts. An early minimal notification is shown
immediately, then replaced with the full one after DI completes.
2026-02-13 14:39:12 +01:00
1369 changed files with 103573 additions and 15702 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

+2
View File
@@ -0,0 +1,2 @@
worktrees/
scheduled_tasks.lock
+9 -8
View File
@@ -4,10 +4,7 @@ Android app that detects and monitors AirPods via Bluetooth LE. Displays battery
## Project Structure
| Module | Description |
|--------|-------------|
| `app/` | Main Android app (FOSS and Google Play flavors) |
| `app-common/` | Shared code between phone and Wear OS apps |
Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
## Build Flavors
@@ -20,16 +17,17 @@ Quick build check: `./gradlew assembleFossDebug`
| Path | Contains |
|------|----------|
| `app/src/main/java/` | Main app source (activities, fragments, services) |
| `app-common/src/main/java/` | Shared logic (monitor, bluetooth, models) |
| `app/src/main/java/` | Main app source (Compose screens, services, receivers, monitor, bluetooth, models) |
| `app/src/foss/java/`, `app/src/gplay/java/` | Flavor-specific code (e.g. upgrade/billing) |
| `app/src/main/res/` | Layouts, drawables, strings |
| `app-common/src/test/` | Unit tests |
| `app/src/test/`, `app/src/testFoss/`, `app/src/testGplay/` | Unit tests (shared + flavor-specific) |
| `app/build.gradle.kts` | App build config, dependencies, flavors |
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
## Development Tips
- Use `assembleFossDebug` as the fastest build variant for iteration
- Shared code goes in `app-common/`, app-specific code in `app/`
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
- Always use string resources for user-facing text (see localization rules)
- Check `git log --oneline -20` for commit message style before committing
@@ -42,4 +40,7 @@ Detailed guidelines are in `.claude/rules/`:
- `build-commands.md` — Build, test, lint, and release commands
- `localization.md` — String resource naming conventions
- `commit-guidelines.md` — Commit message format and prefixes
- `pull-requests.md` — PR title and description conventions
- `agent-instructions.md` — Sub-agent delegation and critical thinking
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
+71 -32
View File
@@ -2,16 +2,22 @@
description: Architecture overview, module structure, key components, data flow, and dependencies
globs:
- "app/**/*.kt"
- "app-common/**/*.kt"
- "**/*.gradle.kts"
---
# Architecture
## Multi-Module Structure
## Single-Module Structure
- **app/**: Main Android application with FOSS and Google Play flavors
- **app-common/**: Shared code between main app and Wear OS app
One Gradle module: `app/`. Source sets:
- `main` — shared code (Compose UI, services, monitor, bluetooth, AAP protocol, widgets)
- `foss` / `gplay` — flavor-specific code (e.g. upgrade/billing implementations)
- `debug` — debug-only code including screenshot content composables
- `test` / `testFoss` / `testGplay` — unit tests
- `screenshotTest` — Compose Preview Screenshot tests for Play Store assets
A previous `app-common/` module was merged into `app/` (commit `be8f4919`).
## Core Patterns
@@ -22,18 +28,62 @@ globs:
## Key Components
### PodMonitor System
### Device Monitoring
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE
- `MonitorControl`: Manages MonitorService lifecycle
- `MonitorService`: Foreground service that continuously scans for AirPods
- `BluetoothEventReceiver`: Handles system Bluetooth events
`monitor/core/` is split into two data-source siblings that `DeviceMonitor` merges:
- `monitor/core/ble/BlePodMonitor` — passive BLE scanning; reads Apple advertisement beacons (battery, case state, in-ear, etc.). Works for any pod in range; no pairing required
- `monitor/core/aap/` — AAP connection lifecycle layer on top of `AapConnectionManager`:
- `AapLifecycleManager` — starts/stops the AAP subsystem
- `AapAutoConnect` — auto-opens AAP sessions for bonded/known devices
- `AapKeyPersister`, `AapLearnedSettingsPersister` — persist session keys and learned pod settings across app restarts
- `StemConfigSender`, `StemPressReaction`, `AncGestureResolver` — push config and react to stem/HID events
- `monitor/core/cache/DeviceStateCache` — persisted last-known state so profiles still show data when a device is out of range
- `DeviceMonitor` — singleton that `combine`s `BlePodMonitor.devices + AapConnectionManager.allStates + DeviceStateCache + profiles` into unified `PodDevice` objects. ViewModels observe `DeviceMonitor.devices`; they do **not** reach into `BlePodMonitor` or the AAP layer directly
- `MonitorControl` / `MonitorService` — foreground service lifecycle holding the scan awake
- `BluetoothEventReceiver`, `BootCompletedReceiver` — system triggers that wake the service
**BLE vs AAP — what each path gives you:**
| | BLE (advertisements) | AAP (L2CAP session) |
|---|---|---|
| Direction | Read-only, passive | Bidirectional commands + events |
| Prerequisite | Bluetooth on | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
| Data | Battery, case open, in-ear, pod model | Settings, ANC mode control, press controls, stem events, device info |
| Availability | Any pod in range | Only your own paired pods |
### Reaction System
- `ReactionSettingsFragment`: Configuration for popup notifications
- `ReactionsCard`: Compose UI for reaction settings, embedded in the device settings screen
- `PopUpWindow`: Displays AirPods status when case is opened
- `PopUpPodViewFactory`: Creates UI components for different pod models
- `PopUpContent`: Compose pod rendering — model-specific UI branches inline, no factory class
### Widget System (Glance)
- `BatteryGlanceWidget`, `AncGlanceWidget`: Jetpack Glance-based home-screen widgets
- `WidgetConfigurationActivity`: Configuration UI launched on widget placement
- Lives under `app/src/main/java/eu/darken/capod/main/ui/widget/`
### Upgrade / Pro Features
- `UpgradeRepo` interface with two flavor implementations:
- `UpgradeRepoGplay` — billing-client backed, includes grace-period handling for interrupted purchases
- `UpgradeControlFoss` — cache/sponsor-backed; users are `isPro = false` until they call `upgrade()`, after which the pro flag is persisted via DataStore
- FOSS is **not** "always pro" — it's opt-in via a local sponsor flow
### AAP (Apple Accessory Protocol) Stack
Three-layer structure under `pods/core/apple/aap/`:
- **`protocol/`** — pure data: `AapMessage`, `AapCommand`, `AapSetting`, `AapDeviceProfile`, `AapDeviceInfo`, `StemPressEvent`, `KeyExchangeResult`. Plus `DefaultAapDeviceProfile` and `Model.Features` capturing per-model capability
- **`engine/`** — session state machine for one connection:
- `AapConnection` — the L2CAP socket wrapper
- `AapSessionEngine` — drives the session lifecycle; tested in `AapSessionEngineTest`
- `AapInboundInterpreter` / `AapOutboundController` — decode incoming messages, encode outgoing
- `AapSettingsCoordinator`, `AapAncController`, `HidTracker`, `AapDeviceInfoDiagnostics` — feature-specific coordinators that sit on top of the session
- **`AapConnectionManager`** (singleton) — owns all open AAP sessions keyed by `BluetoothAddress`, uses `L2capSocketFactory` to create sockets. Consumers don't touch `AapConnection` directly — they call `sendCommand(...)` and observe `allStates`
The monitor-layer glue (`monitor/core/aap/`) described above wires this stack into the foreground service and persists its learned state.
### Common Utilities
@@ -54,31 +104,20 @@ globs:
## Data Flow
The app follows a unidirectional data flow:
1. `BluetoothEventReceiver` detects Bluetooth events
2. `MonitorService` scans for AirPods beacon data
3. `PodMonitor` processes and stores device information
4. ViewModels observe monitor data via repositories
5. UI components react to ViewModel state changes
6. `ReactionSystem` triggers popups and notifications
## Bluetooth LE Implementation
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in `MonitorService` which runs as a foreground service.
## Multi-Platform Considerations
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure compatibility across both platforms.
1. `BluetoothEventReceiver` / `BootCompletedReceiver` wake `MonitorService` (foreground)
2. `MonitorService` keeps `BlePodMonitor` scanning (passive advertisements) and `AapLifecycleManager` running (active L2CAP sessions via `AapConnectionManager`)
3. `DeviceMonitor` merges BLE + AAP + cached state + profiles into `PodDevice` objects
4. ViewModels (`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel`, widget view models) observe `DeviceMonitor.devices`; settings/command changes are sent back through `AapConnectionManager.sendCommand(...)`
5. Reaction triggers (case-open popup, auto-play, notifications) and widget state updates react to the merged flow
## Testing Strategy
- **Unit Tests**: Located in `app-common/src/test/` for shared logic
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants
- **Unit Tests**: `app/src/test/` (shared), `app/src/testFoss/`, `app/src/testGplay/` (flavor-specific — e.g. `UpgradeRepoGplayTest`, `FossUpgradeSerializationTest`)
- **Screenshot Tests**: `app/src/screenshotTest/` — Compose Preview Screenshot Testing, powers the Play Store screenshot pipeline
## Key Dependencies
- **Hilt**: Dependency injection framework
- **AndroidX Navigation**: Fragment navigation with SafeArgs
- **Moshi**: JSON serialization for configuration and debugging
- **Material Design**: UI components following Material Design guidelines
- **Navigation**: Navigation3 (`addNavigation3()`) drives current Compose screen routing. Some legacy `androidx.navigation` helpers still exist (`NavDirectionsExtensions`, `ViewModel3`) — don't assume SafeArgs is fully gone
- **kotlinx.serialization**: JSON serialization for configuration and caching
- **Material Design 3**: Compose Material3 UI components
+1
View File
@@ -21,6 +21,7 @@ Use the existing commit history as reference. Common prefixes:
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
- **feat**: New features
- **refactor**: Code restructuring without behavior change
- **ui**: Visual/layout-only tweaks that aren't a full feature or refactor (e.g., `ui(overview): ...`)
- **chore**: Maintenance, dependency updates, build config
- **docs**: Documentation changes
+5 -3
View File
@@ -9,7 +9,7 @@ globs:
When adding new user-facing strings:
- **Always use string resources**: Never hardcode user-facing text in layouts or code
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_bluetooth_enabled`)
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_monitor_mode_label`)
- **Provide context**: String names should indicate usage and location
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
@@ -17,5 +17,7 @@ When adding new user-facing strings:
- `profiles_create_title` (screen title)
- `profiles_name_label` (form field label)
- `profiles_delete_confirmation` (dialog message)
- `error_network_unavailable` (error message)
- `profiles_name_default` (default value)
- `troubleshooter_ble_result_failure_title` (status/error title)
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
+99
View File
@@ -0,0 +1,99 @@
---
description: Pull request naming and description conventions
globs:
- "**"
---
# Pull Request Guidelines
## PR Title Format
```
<Category>: <Short user-facing summary>
```
PR titles appear in auto-generated changelogs and are read by users. Use **ELI5, user-facing language** — no internal class names, library names, or implementation details.
## Category Prefixes
| Prefix | Covers |
|--------|--------|
| **Widget** | Home screen widget |
| **Reaction** | Case-open popup, auto-play/pause, notification triggers |
| **Device** | AirPods detection, compatibility, Bluetooth scanning, battery reading, device profiles |
| **General** | Dashboard, settings, notifications, themes, onboarding, support, app-wide UI |
| **Fix** | Bug fixes spanning multiple areas |
### Title Examples
- `Widget: Add color themes and transparency slider`
- `Reaction: Fix popup appearing twice when opening AirPods case`
- `Device: Add support for AirPods 4 with ANC`
- `General: Add dark mode and color theme settings`
- `Fix: Fix battery display stuck at 0% after reconnecting`
### Bad Titles (too technical)
- `refactor(settings): Migrate preferences to AndroidX DataStore`
- `feat(widget): Migrate to Jetpack Glance`
- `refactor(ui): Migrate from Fragments to Jetpack Compose`
## PR Description Format
PRs are reviewed in **GitHub's web UI**, which already shows the file tree, the diff, and the tests. Don't duplicate any
of it. The description should answer questions the diff can't — not restate it.
Only these sections, in this order:
1. `## What changed`
2. `## Technical Context`
3. `## Review checklist` *(optional)*
No `Scope`, `Files changed`, `Tests`, or `Review guidance` sub-sections — GitHub shows the files and tests, and review
notes belong in the checklist. Fold anything critical into a Technical Context bullet.
### What changed
User-friendly explanation of what this PR does. Describe the problem that was fixed or the feature that was added from the user's perspective. No internal class or method names.
For non-user-facing PRs (refactors, tests, CI, dependency bumps): write "No user-facing behavior change" followed by a brief internal description.
### Technical Context
Explain what's hard to extract from the diff alone. Focus on:
- **Why** this approach was chosen (and alternatives considered/rejected)
- **Root cause** for bug fixes (the diff shows the fix, not what caused it)
- **Non-obvious side effects** or behavioral changes not apparent from reading the code
Format rules:
- **One bullet per point.** No prose paragraphs, no nested sub-headers like `**Bug 1** / **Bug 2**` — if a PR fixes
multiple bugs, one bullet per bug is enough.
- **Don't restate the diff.** File paths, class renames, test names, and line-level changes are all visible in the web
UI.
### Review checklist (optional)
For PRs with multiple non-trivial review points, add a `## Review checklist` section with `- [ ]` tasks the reviewer can
tick off as they verify. Skip it for small PRs — a single tricky thing can stay as a Technical Context bullet.
### Example
```markdown
## What changed
Fixed a crash that could happen when the AirPods case is opened while Bluetooth is turning off.
## Technical Context
- Root cause: `MonitorService` continued processing scan results during Bluetooth adapter state change, hitting a null adapter reference
- Chose to gate on adapter state in the scan callback rather than adding a separate BroadcastReceiver, since the service already observes adapter state for restart logic
- The timing window is ~200ms between ACTION_STATE_CHANGING and ACTION_STATE_OFF — only reproducible on Pixel devices with aggressive Bluetooth power management
```
## Conventions
- **Issue references**: Use "Closes #123", "Fixes #123", or "Resolves #123"
- **Breaking changes**: Mark with "BREAKING:" prefix if applicable
- **No `Co-authored-by` trailers** (per project convention)
+78
View File
@@ -0,0 +1,78 @@
# Release Process
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
## Dispatch
```bash
# Plan only — no commit, no tag, no push.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
# Real cut.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
```
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
## Inputs
| Input | Default | Notes |
|---|---|---|
| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` |
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
| `dry_run` | `true` | Default is plan-only. |
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
## Local
```bash
./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current
./tools/release/bump.sh --mode=check
bats tools/release/bump.bats
```
## Channel mapping
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|---|---|---|---|---|---|
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% |
| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console.
## Rollback
| Stage reached | Steps |
|---|---|
| Bump on `main`, downstream not started | `git push origin :refs/tags/v<bad>`, `git revert <bump-sha>`, push |
| GitHub release created | Above + `gh release delete v<bad> --yes --cleanup-tag` |
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
## Auth setup
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
The App is installed on this repo and added as a bypass actor to:
- The main-branch ruleset (PR + status check requirements)
- The tag ruleset (creation restriction on `v*`)
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
## Defense in depth
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
## Stuck-dispatch recovery
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
+95
View File
@@ -0,0 +1,95 @@
# Play Store Screenshot Pipeline
## Overview
Localized screenshots are generated using Compose Preview Screenshot Testing (alpha), rendered offline (no device needed), and sorted into fastlane metadata directories for Play Store upload.
## Pipeline
```
ScreenshotContent.kt (mock data + composables)
→ PlayStoreScreenshots.kt (@PreviewTest entry points)
→ PlayStoreLocales.kt (multi-preview locale annotations, auto-generated per batch)
→ generate_screenshots.sh (batched Gradle runs to avoid OOM)
→ copy_screenshots.sh (sort PNGs into fastlane locale dirs)
→ fastlane/metadata/android/{locale}/images/phoneScreenshots/
```
## Key Files
| File | Purpose |
|------|---------|
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (7 screens) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
## Commit policy
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
## Commands
```bash
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
# Use this for local iteration and PRs that touch screenshot content.
./fastlane/generate_screenshots.sh --smoke
# Full run — all 68 locales. Use only when intending to upload to Play Store
# (the non-smoke output is .gitignored and should not be committed).
./fastlane/generate_screenshots.sh
# Copy into fastlane directories (run after generate)
./fastlane/copy_screenshots.sh
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
./fastlane/copy_screenshots.sh --clean
```
## Adding a New Screenshot
1. Add a composable content function in `ScreenshotContent.kt` (e.g. `NewScreenContent()`)
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
## Removing or Renaming a Screenshot
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
2. Run `generate_screenshots.sh --smoke`
3. Run `copy_screenshots.sh --clean`**`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
## After UI Changes
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
```bash
./fastlane/generate_screenshots.sh --smoke
./fastlane/copy_screenshots.sh --clean
```
## Refreshing all locales on Play Store
Periodic, manual operation — not per-PR:
```bash
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
./fastlane/copy_screenshots.sh --clean
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
```
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
## Technical Notes
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
- Gradle daemon is stopped between batches to release memory
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
- Uses `com.android.compose.screenshot` plugin v0.0.1-alpha13
- Output: `app/src/screenshotTestGplayDebug/reference/`
+14 -28
View File
@@ -1,31 +1,17 @@
{
"permissions": {
"allow": [
"mcp__ide__getDiagnostics",
"Bash(./gradlew tasks:*)",
"Bash(./gradlew:*)",
"Bash(find:*)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(git add:*)",
"Bash(git log:*)",
"Bash(git commit:*)",
"Bash(git show:*)",
"Bash(git checkout:*)",
"Bash(git branch:*)",
"Bash(git stash:*)",
"Bash(gh issue list:*)",
"Bash(gh pr list:*)",
"Bash(gh label list:*)",
"WebSearch",
"WebFetch(domain:support.google.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:mvnrepository.com)",
"WebFetch(domain:kotlinlang.org)",
"WebFetch(domain:developer.android.com)",
"WebFetch(domain:issuetracker.google.com)"
],
"deny": []
"allow": [
"mcp__ide__getDiagnostics",
"WebSearch"
],
"deny": []
},
"enabledPlugins": {
"android-translation@claude-code-cafe": true,
"debugbadger@claude-code-cafe": true,
"jvm-tools@claude-code-cafe": true,
"frontend-design@claude-plugins-official": true,
"kotlin-lsp@claude-plugins-official": true,
"support-investigator@claude-code-cafe": true
}
}
}
+4 -13
View File
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Set up JDK 17
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
with:
java-version: '17'
distribution: 'temurin'
@@ -14,7 +14,7 @@ runs:
run: chmod +x gradlew
- name: Cache Gradle Wrapper
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -24,19 +24,10 @@ runs:
${{ runner.os }}-gradle-wrapper-
- name: Cache Gradle Dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.gradle/caches
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
restore-keys: |
${{ runner.os }}-gradle-caches-
- name: Cache Android Global Build-Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
with:
path: |
~/.android/build-cache
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-android-build-cache-
${{ runner.os }}-gradle-caches-
+50 -4
View File
@@ -3,9 +3,15 @@ name: Code tests & eval
on:
push:
branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
lint-vital:
name: Lint vitals
@@ -18,7 +24,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -36,7 +44,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -53,9 +63,45 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Test modules
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
check-fastlane-metadata:
name: Fastlane metadata
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Validate metadata lengths
run: bash fastlane/check_metadata_length.sh
check-release-tooling:
name: Release tooling
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Install bats and shellcheck
run: |
sudo apt-get update
sudo apt-get install -y bats shellcheck
- name: Shellcheck bump.sh
run: shellcheck tools/release/bump.sh
- name: Run bump.sh unit tests
run: bats tools/release/bump.bats
- name: Verify version.properties + VERSION are consistent
run: ./tools/release/bump.sh --mode=check
@@ -4,14 +4,22 @@ on:
push:
branches:
- main
paths-ignore:
- VERSION
- version.properties
pull_request:
branches:
- main
permissions:
contents: read
jobs:
validation:
name: "Validation"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
+66
View File
@@ -0,0 +1,66 @@
name: Deploy GitHub Pages
on:
push:
branches: [ main ]
paths:
- _config.yml
- _layouts/**
- README.md
- CHANGELOG.md
- PRIVACY_POLICY.md
- CNAME
- .github/workflows/pages.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Build Jekyll site
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
- name: Build with Jekyll
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 #v1.0.13
with:
source: ./
destination: ./_site
env:
JEKYLL_GITHUB_TOKEN: ${{ github.token }}
- name: Sanity-check Jekyll output
run: test -f _site/index.html && test -f _site/CNAME
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #v5.0.0
with:
path: ./_site
deploy:
name: Deploy to GitHub Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #v5.0.0
+235
View File
@@ -0,0 +1,235 @@
name: Release prepare
on:
workflow_dispatch:
inputs:
bump_kind:
description: 'How to bump the version (ignored if version_override is set)'
type: choice
options: [build, patch, minor, major]
default: build
version_type:
description: 'Channel for the new version (keep-current preserves current type)'
type: choice
options: [keep-current, rc, beta]
default: keep-current
version_override:
description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)'
type: string
default: ''
expected_current:
description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)'
type: string
default: ''
dry_run:
description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.'
type: boolean
default: true
permissions:
contents: read
concurrency:
group: release-prepare-main
cancel-in-progress: false
jobs:
compute-and-validate:
name: Compute and validate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
new_name: ${{ steps.plan.outputs.new_name }}
new_code: ${{ steps.plan.outputs.new_code }}
current_name: ${{ steps.plan.outputs.current_name }}
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }}
steps:
- name: Guard ref must be main
run: |
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "Must dispatch from main, got ${GITHUB_REF}" >&2
exit 1
fi
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Compute and validate
id: plan
run: |
set -euo pipefail
args=("--mode=plan")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then
args+=("--expected-current=${INPUT_EXPECTED_CURRENT}")
fi
./tools/release/bump.sh "${args[@]}" | tee plan.txt
{
grep -E '^current_name=' plan.txt
grep -E '^new_name=' plan.txt
grep -E '^new_code=' plan.txt
} >> "$GITHUB_OUTPUT"
- name: Tag collision check (local + remote)
env:
NEW_NAME: ${{ steps.plan.outputs.new_name }}
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Write step summary
env:
CURRENT_NAME: ${{ steps.plan.outputs.current_name }}
NEW_NAME: ${{ steps.plan.outputs.new_name }}
NEW_CODE: ${{ steps.plan.outputs.new_code }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
{
echo "## Release plan"
echo
echo "| | |"
echo "|---|---|"
echo "| Current | \`${CURRENT_NAME}\` |"
echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Dry run | \`${DRY_RUN}\` |"
echo
echo "### bump.sh output"
echo
echo '```'
cat plan.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
push-and-dispatch:
name: Push and dispatch
needs: compute-and-validate
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-22.04
permissions:
contents: read
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }}
NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }}
CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }}
steps:
- name: Mint App token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
with:
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Resolve bot identity
id: bot
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
set -euo pipefail
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
- name: Checkout main with credentials
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}
- name: Verify state still matches plan from Job 1
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
- name: Re-check tag collision (state may have moved between jobs)
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Apply bump
run: |
set -euo pipefail
args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
./tools/release/bump.sh "${args[@]}"
- name: Verify post-write state matches plan
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}"
- name: Configure git identity
env:
BOT_USER_NAME: ${{ steps.bot.outputs.user_name }}
BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
run: |
git config user.name "${BOT_USER_NAME}"
git config user.email "${BOT_USER_EMAIL}"
- name: Commit and tag
run: |
set -euo pipefail
git add version.properties VERSION
git commit -m "Release: ${NEW_NAME}"
git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}"
- name: Atomic push (commit + tag)
run: |
set -euo pipefail
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
- name: Write step summary
run: |
set -euo pipefail
{
echo "## Released"
echo
echo "| | |"
echo "|---|---|"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Version code | \`${NEW_CODE}\` |"
echo "| Bump commit | on \`main\` |"
echo "| Downstream | dispatched \`release-tag.yml\` |"
echo
echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload."
} >> "$GITHUB_STEP_SUMMARY"
+82 -31
View File
@@ -4,12 +4,64 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Build only, skip release/upload'
type: boolean
default: true
permissions:
contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
validate-tag:
name: Validate tag
runs-on: ubuntu-22.04
steps:
- name: Check tag-name format
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
echo "Releases must be cut via the 'Release prepare' workflow." >&2
exit 1
fi
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 1
persist-credentials: false
- name: Verify version.properties matches tag
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Strip leading 'v' to get the bare version name.
tag_name="${REF_NAME#v}"
# bump.sh in check mode emits current_name=...
parsed=$(./tools/release/bump.sh --mode=check)
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
if [[ "$current_name" != "$tag_name" ]]; then
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
exit 1
fi
release-github:
needs: validate-tag
name: Create GitHub release
permissions:
contents: write
actions: write
runs-on: ubuntu-22.04
environment: foss-production
steps:
@@ -24,23 +76,16 @@ jobs:
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Assemble beta APK
if: contains(steps.tagger.outputs.tag, '-beta')
if: contains(github.ref_name, '-beta')
run: ./gradlew assembleFossBeta
env:
VERSION: ${{ github.ref }}
@@ -49,7 +94,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production APK
if: "!contains(steps.tagger.outputs.tag, '-beta')"
if: "!contains(github.ref_name, '-beta')"
run: ./gradlew assembleFossRelease
env:
VERSION: ${{ github.ref }}
@@ -58,33 +103,42 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Create pre-release
if: contains(steps.tagger.outputs.tag, '-beta')
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
with:
prerelease: true
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
if: "!contains(steps.tagger.outputs.tag, '-beta')"
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
with:
prerelease: false
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger GitHub Pages deployment
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run pages.yml --ref main
release-gplay:
needs: validate-tag
name: Create Google Play release
runs-on: ubuntu-22.04
environment: gplay-production
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
steps:
- name: Decode Keystore
env:
@@ -107,29 +161,26 @@ jobs:
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Set up ruby env
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
with:
ruby-version: 3.3.6
bundler-cache: true
working-directory: fastlane
- name: Verify fastlane Bundler wiring
run: bundle exec fastlane --version
- name: Assemble beta and upload to Google Play
if: contains(steps.tagger.outputs.tag, '-beta')
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
run: bundle exec fastlane beta
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
@@ -137,7 +188,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production and upload to Google Play
if: "!contains(steps.tagger.outputs.tag, '-beta')"
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
run: bundle exec fastlane production
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
+18 -1
View File
@@ -14,4 +14,21 @@
/fastlane/report.xml
/fastlane/Appfile
/fastlane/README.md
.kotlin
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
# Full localization is uploaded by occasional manual regen + screenshots_only;
# Play Store retains previously-uploaded screenshots for locales not pushed.
fastlane/metadata/android/*/images/phoneScreenshots/*.png
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
.codex
protocol-research/
_site/
.jekyll-cache/
vendor/bundle/
+19 -8
View File
@@ -1,8 +1,9 @@
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400" alt="CAPod banner">
# Companion App for AirPods (CAPod)
[![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases)](https://github.com/d4rken-org/capod/releases/latest)
[![RB Status](https://shields.rbtlog.dev/simple/eu.darken.capod)](https://shields.rbtlog.dev/eu.darken.capod)
[![Code tests & eval](https://github.com/d4rken-org/capod/actions/workflows/code-checks.yml/badge.svg)](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
[![Crowdin](https://badges.crowdin.net/capod/localized.svg)](https://crowdin.com/project/capod)
[![Github Downloads](https://img.shields.io/github/downloads/d4rken-org/capod/total.svg?label=GitHub%20Downloads&logo=github)](https://github.com/d4rken-org/capod/edit/main/README.md#download)
@@ -33,11 +34,20 @@ Currently supported models:
* AirPods Pro 2. Generation (USB-C)
* AirPods Pro 3. Generation
* AirPods Max
* AirPods Max (USB-C)
* AirPods Max 2. Generation
* Power Beats Pro
* Power Beats Pro 2
* Power Beats 3
* Power Beats 4
* Beats Solo 3
* Beats Solo Pro
* Beats Solo 4
* Beats Solo Buds
* Beats Studio 3
* Beats Studio Buds
* Beats Studio Buds+
* Beats Studio Pro
* Beats X
* Beats Flex
* Beats Fit Pro
@@ -51,11 +61,12 @@ Currently supported models:
| Source | Status |
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&label=GitHub)](https://github.com/d4rken-org/capod/releases/latest) |
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [![](https://img.shields.io/f-droid/v/eu.darken.capod)](https://f-droid.org/en/packages/eu.darken.capod/) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [![https://play.google.com/store/apps/details?id=eu.darken.capod](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Deu.darken.capod%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=eu.darken.capod) |
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [![https://play.google.com/apps/testing/eu.darken.capod](https://img.shields.io/badge/Google%20Play-Beta-yellowgreen?style=flat&logo=google-play)](https://play.google.com/apps/testing/eu.darken.capod) | |
| [Github (Release)](https://github.com/d4rken-org/capod/releases) | ![https://github.com/d4rken-org/capod/releases](https://img.shields.io/github/v/release/d4rken-org/capod?display_name=release&logo=github&label=GitHub%20(Release)) ![](https://img.shields.io/github/downloads/d4rken-org/capod/latest/total?label=%20) |
| [Github (Pre-Release)](https://github.com/d4rken-org/capod/releases) | ![https://github.com/d4rken-org/capod/releases](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases&display_name=release&logo=github&label=GitHub%20(Pre-Release)) ![](https://img.shields.io/github/downloads-pre/d4rken-org/capod/latest/total?label=%20) |
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [![https://f-droid.org/en/packages/eu.darken.capod/](https://img.shields.io/f-droid/v/eu.darken.capod)](https://f-droid.org/en/packages/eu.darken.capod/) |
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [![https://apt.izzysoft.de/packages/eu.darken.capod/](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/eu.darken.capod)](https://apt.izzysoft.de/packages/eu.darken.capod/) |
## Support the project
@@ -70,8 +81,7 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
## Thanks to
@@ -85,6 +95,7 @@ Currently supported models:
check it out.
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
* [Crowdin](https://crowdin.com/) for supporting open-source projects.
## License
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -1 +1 @@
3.0.4-rc1 30004010
5.2.1-rc0 50201000
-3
View File
@@ -20,9 +20,6 @@ exclude:
- buildSrc
- gradle/wrapper
- fastlane
- Gemfile
- Gemfile.lock
- crowdin*
- app
- app-common
- CONTRIBUTING.md
+19 -5
View File
@@ -3,11 +3,12 @@ plugins {
id("com.android.application")
id("kotlin-android")
id("com.google.devtools.ksp")
id("kotlin-kapt")
id("kotlin-parcelize")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.android.compose.screenshot") version "0.0.1-alpha13"
}
apply(plugin = "dagger.hilt.android.plugin")
apply(plugin = "androidx.navigation.safeargs.kotlin")
android {
compileSdk = projectConfig.compileSdk
@@ -35,7 +36,7 @@ android {
}
signingConfigs {
val basePath = File(System.getProperty("user.home"), ".appconfig/${projectConfig.packageName}")
val basePath = File(System.getProperty("user.home"), ".config/projects/${projectConfig.packageName}")
create("releaseFoss") {
setupCredentials(File(basePath, "signing-foss.properties"))
}
@@ -94,9 +95,12 @@ android {
}
}
experimentalProperties["android.experimental.enableScreenshotTest"] = true
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
compileOptions {
@@ -114,6 +118,7 @@ android {
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlin.ExperimentalUnsignedTypes",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-Xannotation-default-target=param-property"
)
}
@@ -125,6 +130,7 @@ android {
//noinspection WrongGradleMethod
tasks.withType<Test> {
useJUnitPlatform()
maxHeapSize = "4g"
}
}
}
@@ -172,8 +178,6 @@ dependencies {
addDagger()
addMoshi()
addOkio()
addBaseAndroid()
@@ -183,8 +187,18 @@ dependencies {
addNavigation()
addCompose()
addGlance()
addDataStore()
addNavigation3()
addSerialization()
addTesting()
"gplayImplementation"("com.android.billingclient:billing:8.0.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:8.0.0")
"screenshotTestImplementation"(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
"screenshotTestImplementation"("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha13")
"screenshotTestImplementation"("androidx.compose.ui:ui-tooling")
}
-3
View File
@@ -1,3 +0,0 @@
-keepclassmembernames @com.squareup.moshi.JsonClass class * extends java.lang.Enum {
<fields>;
}
+7 -1
View File
@@ -1,2 +1,8 @@
-keep class eu.darken.capod.BuildConfig { *; }
-dontobfuscate
-dontobfuscate
# work-runtime 2.7.1 (pulled by Glance) uses Class.newInstance() reflection throughout.
# R8 full mode strips no-arg constructors not reachable by static analysis.
# Keep all constructors in the work package. Remove when Glance upgrades to work-runtime 2.10+.
# See: https://issuetracker.google.com/issues/243257364
-keep class androidx.work.** { <init>(...); }
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="eu.darken.capod.debug.l2cap.L2capPocActivity"
android:exported="true"
android:label="L2CAP PoC">
<intent-filter>
<action android:name="eu.darken.capod.L2CAP_POC" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,256 @@
package eu.darken.capod.debug.l2cap
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class L2capPocActivity : ComponentActivity() {
companion object {
private const val TAG = "L2capPoc"
private const val PSM = 0x1001
private val HANDSHAKE = byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
private val ANC_OFF = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x01, 0x00, 0x00, 0x00
)
private val ANC_ON = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x02, 0x00, 0x00, 0x00
)
private val TRANSPARENCY = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x03, 0x00, 0x00, 0x00
)
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) Log.d(TAG, "BLUETOOTH_CONNECT granted")
else Log.w(TAG, "BLUETOOTH_CONNECT denied")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED
) {
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
PocScreen()
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@SuppressLint("MissingPermission")
@Composable
private fun PocScreen() {
val btManager = remember { getSystemService(BluetoothManager::class.java) }
val adapter: BluetoothAdapter? = remember { btManager?.adapter }
val logEntries = remember { mutableStateListOf<String>() }
val logListState = rememberLazyListState()
var selectedDevice by remember { mutableStateOf<BluetoothDevice?>(null) }
var socket by remember { mutableStateOf<BluetoothSocket?>(null) }
var outputStream by remember { mutableStateOf<OutputStream?>(null) }
var connected by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var readerJob by remember { mutableStateOf<Job?>(null) }
val timeFmt = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.US) }
fun log(msg: String) {
val line = "${timeFmt.format(Date())} $msg"
Log.d(TAG, msg)
logEntries.add(line)
}
fun ByteArray.hex(): String = joinToString(" ") { "%02X".format(it) }
LaunchedEffect(logEntries.size) {
if (logEntries.isNotEmpty()) logListState.animateScrollToItem(logEntries.size - 1)
}
fun startReader(input: InputStream) {
readerJob = scope.launch(Dispatchers.IO) {
val buf = ByteArray(1024)
try {
while (isActive) {
val len = input.read(buf)
if (len == -1) {
launch(Dispatchers.Main) { log("<<< Stream closed by remote") }
break
}
val data = buf.copyOf(len)
launch(Dispatchers.Main) { log("<<< [${data.size}] ${data.hex()}") }
}
} catch (e: Exception) {
launch(Dispatchers.Main) { log("<<< Read error: ${e.message}") }
}
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("L2CAP PoC — AAP Connection", style = MaterialTheme.typography.titleMedium)
if (adapter == null) {
Text("No Bluetooth adapter found", color = MaterialTheme.colorScheme.error)
return@Column
}
// Bonded devices
if (!connected) {
Text("Bonded Devices:", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
val bonded = remember(adapter) {
try { adapter.bondedDevices?.toList() ?: emptyList() }
catch (_: SecurityException) { emptyList() }
}
bonded.forEach { device ->
val name = try { device.name ?: "Unknown" } catch (_: SecurityException) { "Unknown" }
val isSelected = selectedDevice == device
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { selectedDevice = device },
colors = if (isSelected) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else CardDefaults.cardColors()
) {
Row(modifier = Modifier.padding(8.dp)) {
Text("$name ${device.address}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
// Connection controls
FlowRow(modifier = Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
val dev = selectedDevice ?: return@Button
log("=== Connecting to ${dev.address} ===")
scope.launch(Dispatchers.IO) {
try {
val sock = L2capSocketFactory().createSocket(dev, PSM)
withContext(Dispatchers.Main) { log("Socket created, connecting...") }
sock.connect()
withContext(Dispatchers.Main) {
log("Connected!")
socket = sock
outputStream = sock.outputStream
connected = true
}
startReader(sock.inputStream)
withContext(Dispatchers.Main) { log(">>> Handshake [${HANDSHAKE.size}] ${HANDSHAKE.hex()}") }
sock.outputStream.write(HANDSHAKE)
sock.outputStream.flush()
} catch (e: Exception) {
withContext(Dispatchers.Main) { log("Failed: ${e::class.simpleName}: ${e.message}") }
}
}
},
enabled = selectedDevice != null && !connected
) { Text("Connect") }
Button(
onClick = {
log("=== Disconnecting ===")
readerJob?.cancel()
readerJob = null
try { socket?.close() } catch (_: Exception) {}
socket = null
outputStream = null
connected = false
},
enabled = connected
) { Text("Disconnect") }
}
// AAP command buttons
if (connected) {
FlowRow(modifier = Modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
fun sendCommand(name: String, cmd: ByteArray) {
scope.launch(Dispatchers.IO) {
try {
withContext(Dispatchers.Main) { log(">>> $name [${cmd.size}] ${cmd.hex()}") }
outputStream?.write(cmd)
outputStream?.flush()
} catch (e: Exception) {
withContext(Dispatchers.Main) { log(">>> Send error: ${e.message}") }
}
}
}
Button(onClick = { sendCommand("ANC Off", ANC_OFF) }) { Text("ANC Off") }
Button(onClick = { sendCommand("ANC On", ANC_ON) }) { Text("ANC On") }
Button(onClick = { sendCommand("Transparency", TRANSPARENCY) }) { Text("Transparency") }
Button(onClick = { sendCommand("Handshake", HANDSHAKE) }) { Text("Handshake") }
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
LazyColumn(state = logListState, modifier = Modifier.weight(1f)) {
items(logEntries) { entry ->
Text(text = entry, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 14.sp)
}
}
}
}
}
@@ -0,0 +1,295 @@
package eu.darken.capod.screenshots
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import androidx.compose.ui.platform.LocalContext
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.main.ui.widget.WidgetTheme
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.ui.DeviceManagerScreen
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationScreen
import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationViewModel
import eu.darken.capod.main.ui.devicesettings.DeviceSettingsScreen
import eu.darken.capod.main.ui.devicesettings.DeviceSettingsViewModel
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
@Composable
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
val devices = if (showAap) {
listOf(
MockPodDataProvider.dualPodMonitoredWithAap(),
MockPodDataProvider.singlePodMonitoredWithAap(),
MockPodDataProvider.unknownMonitored(),
)
} else {
listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
)
}
// Plausible estimates for the mock battery levels: runtime under each gauge, charge ETA in
// the charging chip of whichever mock pod is charging.
val estimates = if (showAap) {
mapOf(
"preview-dual-aap" to BatteryEstimate(
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE),
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
),
"preview-single-aap" to BatteryEstimate(
headset = BatteryEstimate.Pod(480, 0.05f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 45),
),
)
} else {
mapOf(
"preview-dual-mixed" to BatteryEstimate(
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 20),
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
),
"preview-single" to BatteryEstimate(
headset = BatteryEstimate.Pod(1020, 0.05f, BatteryEstimate.Source.LIVE),
),
)
}
OverviewScreen(
state = OverviewViewModel.State(
now = MOCK_NOW,
permissions = emptySet(),
devices = devices,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
),
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
showUnmatchedDevices = false,
batteryEstimates = estimates,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
onToggleUnmatched = {},
)
}
@Composable
internal fun DeviceProfilesContent() = PreviewWrapper {
DeviceManagerScreen(
state = DeviceManagerViewModel.State(
profiles = listOf(
MockPodDataProvider.profile("Work AirPods", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
MockPodDataProvider.profile("Gym Beats", PodModel.POWERBEATS_PRO),
),
),
onBack = {},
onAddDevice = {},
onEditProfile = {},
)
}
@Composable
internal fun AddProfileContent() = PreviewWrapper {
val pairedAirPods = BluetoothDevice2(
address = "AA:BB:CC:DD:EE:FF",
name = "AirPods Pro",
seenFirstAt = MOCK_NOW,
)
val bondedItems = listOf(
DeviceProfileCreationViewModel.BondedDeviceItem(
device = pairedAirPods,
claimedByProfile = null,
),
DeviceProfileCreationViewModel.BondedDeviceItem(
device = BluetoothDevice2(
address = "11:22:33:44:55:66",
name = "Living Room TV",
seenFirstAt = MOCK_NOW,
),
claimedByProfile = null,
),
)
DeviceProfileCreationScreen(
state = DeviceProfileCreationViewModel.State(
isEditMode = false,
name = "My AirPods Pro",
nameError = null,
selectedModel = PodModel.AIRPODS_PRO2,
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
identityKey = null,
encryptionKey = null,
selectedDevice = pairedAirPods,
bondedDeviceItems = bondedItems,
minimumSignalQuality = 0.15f,
canSave = true,
),
onBack = {},
onSave = {},
onDelete = {},
onNameChange = {},
onModelChange = {},
onDeviceChange = {},
onIdentityKeyChange = {},
onEncryptionKeyChange = {},
onSignalQualityChange = {},
onKeyGuide = {},
)
}
@Composable
internal fun DeviceSettingsReactionsContent() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = MockPodDataProvider.dualPodMonitoredWithReactions(),
now = MOCK_NOW,
isPro = true,
isNudgeAvailable = true,
isClassicallyConnected = true,
),
onNavigateUp = {},
)
}
@Composable
internal fun WidgetConfigurationContent() = PreviewWrapper {
val profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
)
WidgetConfigurationScreen(
state = WidgetConfigurationViewModel.State(
profiles = profiles,
selectedProfile = profiles.first().id,
isPro = true,
theme = WidgetTheme.DEFAULT,
activePreset = WidgetTheme.Preset.MATERIAL_YOU,
isCustomMode = false,
),
onSelectProfile = {},
onSelectPreset = {},
onEnterCustomMode = { _, _ -> },
onSetBackgroundColor = {},
onSetForegroundColor = {},
onSetBackgroundAlpha = {},
onSetShowDeviceLabel = {},
onReset = {},
onConfirm = {},
onCancel = {},
)
}
@Composable
internal fun CasePopUpContent() {
CapodTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF1A237E),
Color(0xFF0D47A1),
Color(0xFF006064),
)
)
),
contentAlignment = Alignment.Center,
) {
PopUpCard(
device = MockPodDataProvider.dualPodMonitoredMixed(),
onClose = {},
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
}
@Composable
internal fun HomescreenWidgetContent() {
CapodTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF1A237E),
Color(0xFF0D47A1),
Color(0xFF006064),
)
)
),
contentAlignment = Alignment.Center,
) {
ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
).withLocalizedPreviewEstimates(LocalContext.current),
)
}
}
}
@Preview(name = "1 - Dashboard Light", locale = "en", device = DS, showSystemUi = true)
@Composable
private fun PreviewDashboardLight() = DashboardContent()
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
@Composable
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
@Composable
private fun PreviewCasePopUp() = CasePopUpContent()
@Preview(name = "4 - Widget Configuration", locale = "en", device = DS, showSystemUi = true)
@Composable
private fun PreviewWidgetConfiguration() = WidgetConfigurationContent()
@Preview(name = "5 - Device Profiles", locale = "en", device = DS, showSystemUi = true)
@Composable
private fun PreviewDeviceProfiles() = DeviceProfilesContent()
@Preview(name = "6 - Add Profile", locale = "en", device = DS, showSystemUi = true)
@Composable
private fun PreviewAddProfile() = AddProfileContent()
@Preview(name = "7 - Device Settings Reactions", locale = "en", device = DS, showSystemUi = true)
@Composable
private fun PreviewDeviceSettingsReactions() = DeviceSettingsReactionsContent()
@Preview(name = "8 - Homescreen Widget", device = DS, showSystemUi = true)
@Composable
private fun PreviewHomescreenWidget() = HomescreenWidgetContent()
@@ -1,24 +1,35 @@
package eu.darken.capod.common.upgrade.core
import android.content.Context
import com.squareup.moshi.Moshi
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.preferences.createFlowPreference
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.serialization.SerializationCapod
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FossCache @Inject constructor(
@ApplicationContext context: Context,
moshi: Moshi
@SerializationCapod json: Json,
) {
private val preferences = context.getSharedPreferences("settings_foss", Context.MODE_PRIVATE)
val upgrade = preferences.createFlowPreference<FossUpgrade?>(
key = "foss.upgrade",
moshi = moshi,
defaultValue = null,
private val Context.dataStore by preferencesDataStore(
name = "settings_foss",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_foss")) }
)
}
private val dataStore: DataStore<Preferences> = context.dataStore
val upgrade = dataStore.createValue<FossUpgrade?>(
key = "foss.upgrade",
defaultValue = null,
json = json,
onErrorFallbackToDefault = true,
)
}
@@ -1,18 +1,19 @@
package eu.darken.capod.common.upgrade.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@JsonClass(generateAdapter = true)
@Serializable
data class FossUpgrade(
val upgradedAt: Instant,
val reason: Reason
@SerialName("upgradedAt") @Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
@SerialName("reason") val reason: Reason
) {
@JsonClass(generateAdapter = false)
@Serializable
enum class Reason {
@Json(name = "foss.upgrade.reason.donated") DONATED,
@Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@Json(name = "foss.upgrade.reason.nomoney") NO_MONEY;
@SerialName("foss.upgrade.reason.donated") DONATED,
@SerialName("foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@SerialName("foss.upgrade.reason.nomoney") NO_MONEY;
}
}
@@ -1,21 +1,16 @@
package eu.darken.capod.common.upgrade.core
import android.app.Activity
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.darken.capod.R
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
import eu.darken.capod.common.datastore.valueBlocking
@Singleton
class UpgradeControlFoss @Inject constructor(
private val fossCache: FossCache,
private val webpageTool: WebpageTool,
) : UpgradeRepo {
override val upgradeInfo: Flow<UpgradeRepo.Info> = fossCache.upgrade.flow.map { data ->
@@ -30,34 +25,11 @@ class UpgradeControlFoss @Inject constructor(
}
}
override fun launchBillingFlow(activity: Activity) {
MaterialAlertDialogBuilder(activity).apply {
setIcon(R.drawable.ic_heart)
setTitle(R.string.upgrade_capod_label)
setMessage(R.string.upgrade_capod_description)
setPositiveButton(R.string.foss_upgrade_donate_label) { _, _ ->
fossCache.upgrade.value = FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.DONATED
)
webpageTool.open("https://github.com/sponsors/d4rken")
Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show()
}
setNegativeButton(R.string.foss_upgrade_alreadydonated_label) { _, _ ->
fossCache.upgrade.value = FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.ALREADY_DONATED
)
Toast.makeText(activity, R.string.general_thank_you_label, Toast.LENGTH_SHORT).show()
}
setNeutralButton(R.string.foss_upgrade_no_money_label) { _, _ ->
fossCache.upgrade.value = FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.NO_MONEY
)
Toast.makeText(activity, "¯\\_(ツ)_/¯", Toast.LENGTH_SHORT).show()
}
}.show()
fun upgrade(reason: FossUpgrade.Reason) {
fossCache.upgrade.valueBlocking = FossUpgrade(
upgradedAt = Instant.now(),
reason = reason
)
}
data class Info(
@@ -0,0 +1,26 @@
package eu.darken.capod.upgrade.ui
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class UpgradeNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.Upgrade> { UpgradeScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: UpgradeNavigation): NavigationEntry
}
}
@@ -0,0 +1,271 @@
package eu.darken.capod.upgrade.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
@Composable
fun UpgradeScreenHost(vm: UpgradeViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val snackbarHostState = remember { SnackbarHostState() }
val returnedEarlyMessage = stringResource(R.string.upgrade_foss_sponsor_returned_early)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
vm.onResume()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(Unit) {
vm.sponsorEvents.collect { event ->
when (event) {
UpgradeViewModel.SponsorEvent.ReturnedTooEarly -> {
snackbarHostState.showSnackbar(returnedEarlyMessage)
}
}
}
}
UpgradeScreen(
snackbarHostState = snackbarHostState,
onNavigateUp = { vm.navUp() },
onSponsor = { vm.sponsor() },
)
}
private data class Benefit(val icon: ImageVector, val textRes: Int)
@Composable
fun UpgradeScreen(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onNavigateUp: () -> Unit,
onSponsor: () -> Unit,
) {
val benefits = listOf(
Benefit(Icons.TwoTone.Palette, R.string.upgrade_benefit_themes),
Benefit(Icons.TwoTone.PlayCircle, R.string.upgrade_benefit_autoplay),
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
)
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface,
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
Box(contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.size(120.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
) {}
Image(
painter = painterResource(R.drawable.splash_graphic2),
contentDescription = null,
modifier = Modifier.size(80.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = buildAnnotatedString {
append("CAPod ")
withStyle(SpanStyle(color = colorResource(R.color.brand_secondary), fontWeight = FontWeight.Bold)) {
append("FOSS")
}
},
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(24.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.upgrade_foss_preamble),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
benefits.forEach { benefit ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = benefit.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(benefit.textRes),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
Text(
text = stringResource(R.string.upgrade_benefit_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSponsor,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(
imageVector = Icons.TwoTone.Favorite,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.upgrade_foss_sponsor_action),
style = MaterialTheme.typography.titleMedium,
)
}
Text(
text = stringResource(R.string.upgrade_foss_sponsor_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(24.dp))
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier
.align(Alignment.TopStart)
.padding(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
}
}
}
@Preview2
@Composable
private fun UpgradeScreenPreview() = PreviewWrapper {
UpgradeScreen(
onNavigateUp = {},
onSponsor = {},
)
}
@@ -0,0 +1,48 @@
package eu.darken.capod.upgrade.ui
import android.os.SystemClock
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.FossUpgrade
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val upgradeControlFoss: UpgradeControlFoss,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
sealed interface SponsorEvent {
data object ReturnedTooEarly : SponsorEvent
}
val sponsorEvents = SingleEventFlow<SponsorEvent>()
fun sponsor() {
savedStateHandle[KEY_SPONSOR_OPENED_AT] = SystemClock.elapsedRealtime()
webpageTool.open("https://github.com/sponsors/d4rken")
}
fun onResume() {
val openedAt = savedStateHandle.get<Long>(KEY_SPONSOR_OPENED_AT) ?: return
savedStateHandle.remove<Long>(KEY_SPONSOR_OPENED_AT)
if (SystemClock.elapsedRealtime() - openedAt >= 5_000L) {
upgradeControlFoss.upgrade(FossUpgrade.Reason.DONATED)
navUp()
} else {
sponsorEvents.tryEmit(SponsorEvent.ReturnedTooEarly)
}
}
companion object {
private const val KEY_SPONSOR_OPENED_AT = "sponsor_opened_at"
}
}
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Skenk</string>
<string name="foss_upgrade_alreadydonated_label">Ek het reeds geskenk</string>
<string name="foss_upgrade_no_money_label">Ek spandeer al my geld op AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS is gratis en oopbron. As u dit nuttig vind, oorweeg dit om ontwikkeling te befonds om die projek aan die gang te hou.</string>
<string name="upgrade_foss_sponsor_action">Borg ontwikkeling</string>
<string name="upgrade_foss_sponsor_subtitle">Geen advertensies. Geen opsporing. Geen Google Play-binding.</string>
<string name="upgrade_foss_sponsor_returned_early">Alreeds terug? U ondersteuning hou CAPod aan die lewe.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">ልገሳ</string>
<string name="foss_upgrade_alreadydonated_label">አስቀድሜ ልገሳ ፈጽሜአለሁ</string>
<string name="foss_upgrade_no_money_label">ገንዘቤን በሙሉ በAirPods ላይ አውጥቻለሁ</string>
<string name="upgrade_foss_preamble">CAPod FOSS ነፃ እና ክፍት ምንጭ ነው። ግኾሚ ሆኖ ካግኝት፣ ፕሮጀክቱ ቀጥሎ እንደይይድ ለመርዳት ላማቱን ማስደገፏ ያስቡ።</string>
<string name="upgrade_foss_sponsor_action">ልማትን ይደግፉ</string>
<string name="upgrade_foss_sponsor_subtitle">ምንም ማስታወቂያ የሉም። ምንም ክትትል የሉም። ምንም Google Play ቆለፋ የሉም።</string>
<string name="upgrade_foss_sponsor_returned_early">ቀድሞ ተመለሱ፡ ድጋፏዎ CAPodን ህያው ያቆይላል።</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+7 -2
View File
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">تبرُّع</string>
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
<string name="foss_upgrade_alreadydonated_label">لقد تبرّعتُ بالفعل</string>
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على سمّاعات AirPods</string>
<string name="upgrade_foss_preamble">برنامج CAPod FOSS مجاني ومفتوح المصدر. إذا وجدتهُ مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يُبقي CAPod حيًّا.</string>
<string name="upgrade_badge_label">البرمجيات الحرة والمفتوحة المصدر</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">İanə ver</string>
<string name="foss_upgrade_alreadydonated_label">Artıq ianə vermişəm</string>
<string name="foss_upgrade_no_money_label">Bütün pulumu AirPods-lara xərcləyirəm</string>
<string name="upgrade_foss_preamble">CAPod FOSS pulsuzdur və açıq mənbəlidir. Faydalı tapırsınızsa, layihənin davamına kömək etmək üçün inkişafı maliyyələşdirməyi düşünün.</string>
<string name="upgrade_foss_sponsor_action">İnkişafı sponsor edin</string>
<string name="upgrade_foss_sponsor_subtitle">Reklam yoxdur. İzləmə yoxdur. Google Play bağlılığı yoxdur.</string>
<string name="upgrade_foss_sponsor_returned_early">Artıq geri qayıtdınız? Dəstəyiniz CAPod-u yaşadır.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Ахвяраваць</string>
<string name="foss_upgrade_alreadydonated_label">Я ўжо ахвяраваў</string>
<string name="foss_upgrade_no_money_label">Я патраціў усе грошы на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS бясплатны і з адкрытым зыходным кодам. Калі вы лічыце яго карысным, разгледзьце магчымасць спансіравання распрацоўкі, каб дапамагчы трымаць праект ў жывых.</string>
<string name="upgrade_foss_sponsor_action">Падтрымаць распрацоўку</string>
<string name="upgrade_foss_sponsor_subtitle">Ніякай рэкламы. Ніякай адсочкі. Ніякай залежнасці ад Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Ужо вяртаецеся? Ваша падтрымка трымае CAPod ў жывых.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Дарение</string>
<string name="foss_upgrade_alreadydonated_label">Вече съм дарил</string>
<string name="foss_upgrade_no_money_label">Харча всичките си пари за AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS е безплатен и с отворен код. Ако ви е полезен, размислете да подкрепите разработката, за да помогнете проектът да продължи.</string>
<string name="upgrade_foss_sponsor_action">Спонсорирайте разработката</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклами. Без следене. Без заключване в Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Вече ли? Вашата подкрепа поддържа CAPod жив.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">দান করুন</string>
<string name="foss_upgrade_alreadydonated_label">আমি ইতিমধ্যে দান করেছি</string>
<string name="foss_upgrade_no_money_label">আমি আমার সব টাকা AirPods এর জন্য খরচ করি</string>
<string name="upgrade_foss_preamble">CAPod FOSS বিনামূল্যে এবং মুক্ত উৎস। আপনি যদি এটি উপযোগী মনে করেন, প্রকল্পটি চালু রাখতে সাহায্য করতে ডেভেলপমেন্ট স্পনসর করার কথা বিবেচনা করুন।</string>
<string name="upgrade_foss_sponsor_action">স্পনসর ডেভলপমেন্ট</string>
<string name="upgrade_foss_sponsor_subtitle">কোনো বিজ্ঞাপন নেই। কোনো ট্র্যাকিং নেই। Google Play-এর সাথে বাঁধানো নেই।</string>
<string name="upgrade_foss_sponsor_returned_early">ইতোমধ্যে ফিরে গেলেন? আপনার সমর্থন CAPodকে বাঁচিয়ে রাখে।</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Donacions</string>
<string name="foss_upgrade_alreadydonated_label">Ja he donat</string>
<string name="foss_upgrade_no_money_label">Gasto tots els meus diners en AirPods</string>
<string name="upgrade_foss_preamble">El CAPod FOSS és gratuït i de codi obert. Si el trobeu útil, considereu patrocinar el desenvolupament per ajudar a mantenir el projecte en marxa.</string>
<string name="upgrade_foss_sponsor_action">Patrocina el desenvolupament</string>
<string name="upgrade_foss_sponsor_subtitle">Sense anuncis. Sense seguiment. Sense dependència del Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Ja heu tornat? El vostre suport manté viu el CAPod.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Přispět</string>
<string name="foss_upgrade_alreadydonated_label">Již jsem přispěl/a</string>
<string name="foss_upgrade_no_money_label">Všechny peníze jsem utratil/a za AirPods</string>
<string name="upgrade_foss_preamble">Aplikace CAPod FOSS je zdarma a open source. Pokud vám přijde užitečná, zvažte sponzorování vývoje, abyste pomohli udržet projekt v chodu.</string>
<string name="upgrade_foss_sponsor_action">Sponzorovat vývoj</string>
<string name="upgrade_foss_sponsor_subtitle">Žádné reklamy. Žádné sledování. Žádná závislost na Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Už jste zpět? Vaše podpora udržuje CAPod naživu.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Doner</string>
<string name="foss_upgrade_alreadydonated_label">Jeg har allerede doneret</string>
<string name="foss_upgrade_no_money_label">Jeg bruger alle mine penge på AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS er gratis og open source. Hvis du finder det nüttigt, kan du overveje at sponsorere udviklingen for at hjælpe projektet med at fortsætte.</string>
<string name="upgrade_foss_sponsor_action">Sponsorudvikling</string>
<string name="upgrade_foss_sponsor_subtitle">Ingen annoncer. Ingen sporing. Ingen Google Play-lås.</string>
<string name="upgrade_foss_sponsor_returned_early">Allerede tilbage? Din støtte holder CAPod i live.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Spenden</string>
<string name="foss_upgrade_alreadydonated_label">Ich habe schon gespendet</string>
<string name="foss_upgrade_no_money_label">Hab alles für AirPods ausgegeben</string>
<string name="upgrade_foss_preamble">CAPod FOSS ist kostenlos und Open Source. Wenn du es nützlich findest, erwäge, die Entwicklung zu sponsern, um das Projekt am Laufen zu halten.</string>
<string name="upgrade_foss_sponsor_action">Fördere Entwicklung</string>
<string name="upgrade_foss_sponsor_subtitle">Keine Werbung. Kein Tracking. Keine Google-Play-Bindung.</string>
<string name="upgrade_foss_sponsor_returned_early">Schon zurück? Deine Unterstützung hält CAPod am Leben.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Δωρεά</string>
<string name="foss_upgrade_alreadydonated_label">Ήδη δώρισα</string>
<string name="foss_upgrade_no_money_label">Ξόδεψα όλα τα χρήματά μου στα AirPods</string>
<string name="upgrade_foss_preamble">Το CAPod FOSS είναι δωρεάν και ανοιχτού κώδικα. Αν το βρίσκετε χρήσιμο, εξετάστε το ενδεχόμενο να χρηματοδοτήσετε την ανάπτυξη για να συνεχίσει το έργο.</string>
<string name="upgrade_foss_sponsor_action">Χορηγήστε την ανάπτυξη</string>
<string name="upgrade_foss_sponsor_subtitle">Χωρίς διαφημίσεις. Χωρίς παρακολούθηση. Χωρίς δέσμευση στο Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Επιστρέψατε ήδη; Η υποστήριξή σας κρατά το CAPod ζωντανό.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Ya doné</string>
<string name="foss_upgrade_no_money_label">Gasto todo mi dinero en AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS es gratuito y de código abierto. Si lo encontrás útil, considerá patrocinar el desarrollo para ayudar a mantener el proyecto en marcha.</string>
<string name="upgrade_foss_sponsor_action">Sponsorear el desarrollo</string>
<string name="upgrade_foss_sponsor_subtitle">Sin publicidad. Sin rastreo. Sin dependencia de Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">¿Ya te vas? Tu apoyo mantiene CAPod vivo.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Ya doné</string>
<string name="foss_upgrade_no_money_label">Gasto todo mi dinero en AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS es gratuito y de código abierto. Si lo encuentras útil, considera patrocinar el desarrollo para ayudar a mantener el proyecto.</string>
<string name="upgrade_foss_sponsor_action">Patrocinar el desarrollo</string>
<string name="upgrade_foss_sponsor_subtitle">Sin anuncios. Sin rastreo. Sin dependencia de Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">¿Ya de vuelta? Tu apoyo mantiene CAPod con vida.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Ya he donado</string>
<string name="foss_upgrade_no_money_label">Gasto todo mi dinero en AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS es gratuito y de código abierto. Si lo encuentras útil, considera patrocinar el desarrollo para ayudar a mantener el proyecto en marcha.</string>
<string name="upgrade_foss_sponsor_action">Patrocinadores</string>
<string name="upgrade_foss_sponsor_subtitle">Sin anuncios. Sin rastreo. Sin dependencia de Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">¿Ya de vuelta? Tu apoyo mantiene CAPod vivo.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Anneta</string>
<string name="foss_upgrade_alreadydonated_label">Juba annetasin</string>
<string name="foss_upgrade_no_money_label">Kulutasin kogu raha AirPodsidele</string>
<string name="upgrade_foss_preamble">CAPod FOSS on tasuta ja avatud lähtekoodiga. Kui sellest on sulle kasu, kaalu arenduse rahastamist, et aidata projekti arendada.</string>
<string name="upgrade_foss_sponsor_action">Rahasta arendamist</string>
<string name="upgrade_foss_sponsor_subtitle">Reklaami pole. Jälgimist pole. Google Play lukustust pole.</string>
<string name="upgrade_foss_sponsor_returned_early">Juba tagasi? Sinu toetus hoiab CAPodi elus.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Eman dohaintza</string>
<string name="foss_upgrade_alreadydonated_label">Dagoeneko eman dut dohaintza</string>
<string name="foss_upgrade_no_money_label">Nire diru guztia AirPods-etan gastatzen dut</string>
<string name="upgrade_foss_preamble">CAPod FOSS doakoa eta kode irekikoa da. Erabilgarria iruditzen bazaizu, kontuan hartu garapena babestu proiektua aurrera jarraitzeko.</string>
<string name="upgrade_foss_sponsor_action">Garapena babestu</string>
<string name="upgrade_foss_sponsor_subtitle">Iragarkirik ez. Jarraipenik ez. Google Play-ren lotura ez.</string>
<string name="upgrade_foss_sponsor_returned_early">Dagoeneko itzuli? Zure laguntzak CAPod bizirik mantentzen du.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">کمک مالی</string>
<string name="foss_upgrade_alreadydonated_label">کمک مالی کرده‌ام</string>
<string name="foss_upgrade_no_money_label">من تمام پولم را خرج ایرپاد می‌کنم</string>
<string name="upgrade_foss_preamble">CAPod FOSS رایگان و متن-باز است. اگر آن را مفید یافتید، حمایت مالی از توسعه را در نظر بگیرید تا پروژه ادامه یابد.</string>
<string name="upgrade_foss_sponsor_action">حامی توسعه دهنده</string>
<string name="upgrade_foss_sponsor_subtitle">بدون تبلیغ. بدون ردیابی. بدون وابستگی به Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">زود برگشتیدی؟ حمایت شما CAPod را زنده نگه می‌دارد.</string>
<string name="upgrade_badge_label">منبع‌آزاد</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Lahjoita</string>
<string name="foss_upgrade_alreadydonated_label">Olen jo lahjoittanut</string>
<string name="foss_upgrade_no_money_label">Käytän kaikki rahani AirPodeihin</string>
<string name="upgrade_foss_preamble">CAPod FOSS on ilmainen ja avoimen lähdekoodin ohjelma. Jos löydät sen hyödylliseksi, harkitse kehityksen tukemista projektin elässä pitämiseksi.</string>
<string name="upgrade_foss_sponsor_action">Sponsoroi kehitystä</string>
<string name="upgrade_foss_sponsor_subtitle">Ei mainoksia. Ei seurantaa. Ei Google Play -riippuvuutta.</string>
<string name="upgrade_foss_sponsor_returned_early">Jo takaisin? Tukesi pitää CAPod-sovelluksen hengissä.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Mag-donate</string>
<string name="foss_upgrade_alreadydonated_label">Nakapag-donate na ako</string>
<string name="foss_upgrade_no_money_label">Ginagastos ko lahat ng pera ko sa AirPods</string>
<string name="upgrade_foss_preamble">Ang CAPod FOSS ay libre at open source. Kung nakita mong kapaki-pakinabang ito, isaalang-alang ang pag-sponsor ng development para mapanatiling aktibo ang proyekto.</string>
<string name="upgrade_foss_sponsor_action">Mag-sponsor ng development</string>
<string name="upgrade_foss_sponsor_subtitle">Walang ads. Walang tracking. Walang Google Play lock-in.</string>
<string name="upgrade_foss_sponsor_returned_early">Bumalik na? Ang iyong suporta ay nagpapanatiling buhay ng CAPod.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+6 -1
View File
@@ -2,5 +2,10 @@
<resources>
<string name="foss_upgrade_donate_label">Faire un don</string>
<string name="foss_upgrade_alreadydonated_label">Jai déjà fait un don</string>
<string name="foss_upgrade_no_money_label">Jai dépensé tout mon argent sur les AirPods</string>
<string name="foss_upgrade_no_money_label">Jai dépensé tout mon argent sur des AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS est gratuite et à code source ouvert. Si vous la trouvez utile, pensez à soutenir le développement pour contribuer à la pérennité du projet.</string>
<string name="upgrade_foss_sponsor_action">Soutenir le développement</string>
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi à 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_badge_label">Logiciel libre</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Xa doei</string>
<string name="foss_upgrade_no_money_label">Gasto todos os meus cartos en AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS é gratuito e de código aberto. Se o atopas útil, considera patrocinar o desenvolvemento para axudar a manter o proxecto en marcha.</string>
<string name="upgrade_foss_sponsor_action">Patrocinar desenvolvemento</string>
<string name="upgrade_foss_sponsor_subtitle">Sen anuncios. Sen rastrexo. Sen dependencia de Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">És rápido/a! O teu apoio mantén CAPod vivo.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">दान करें</string>
<string name="foss_upgrade_alreadydonated_label">मैंने पहले ही दान कर दिया है</string>
<string name="foss_upgrade_no_money_label">मैं अपना सारा पैसा AirPods पर खर्च करता हूं</string>
<string name="upgrade_foss_preamble">CAPod FOSS मुफ्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो प्रोजेक्ट को जारी रखने में मदद के लिए विकास को प्रायोजित करने पर विचार करें।</string>
<string name="upgrade_foss_sponsor_action">विकास को प्रायोजक करें</string>
<string name="upgrade_foss_sponsor_subtitle">कोई विज्ञापन नहीं। कोई ट्रैकिंग नहीं। Google Play पर निर्भरता नहीं।</string>
<string name="upgrade_foss_sponsor_returned_early">अभी वापस? आपका समर्थन CAPod को जीवित रखता है।</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Donirajte</string>
<string name="foss_upgrade_alreadydonated_label">Već sam donirao</string>
<string name="foss_upgrade_no_money_label">Sav svoj novac trošim na AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS je besplatan i otvorenog koda. Ako vam je koristan, razmislite o sponzoriranju razvoja kako bi projekt ostao živ.</string>
<string name="upgrade_foss_sponsor_action">Podržite razvoj</string>
<string name="upgrade_foss_sponsor_subtitle">Bez oglasa. Bez praćenja. Bez zaključanosti na Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Već se vraćate? Vaša podrška čuva CAPod na životu.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Adományoz</string>
<string name="foss_upgrade_alreadydonated_label">Már adományoztam</string>
<string name="foss_upgrade_no_money_label">Minden pénzemet az AirPods-ra költöttem</string>
<string name="upgrade_foss_preamble">A CAPod FOSS ingyenes és nyílt forráskódú. Ha hasznosnak találod, fontold meg a fejlesztés támogatását, hogy a projekt tovább élhessen.</string>
<string name="upgrade_foss_sponsor_action">Szponzori fejlesztés</string>
<string name="upgrade_foss_sponsor_subtitle">Nincs hirdetés. Nincs nyomkövetés. Nincs Google Play-függőség.</string>
<string name="upgrade_foss_sponsor_returned_early">Már visszamész? A támogatásodnak köszönhetően él tovább a CAPod.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Նվիրաբերել</string>
<string name="foss_upgrade_alreadydonated_label">Ես արդեն նվիրաբերել եմ</string>
<string name="foss_upgrade_no_money_label">Ես իմ ամբողջ գումարը ծախսում եմ AirPods-ի վրա</string>
<string name="upgrade_foss_preamble">CAPod FOSS անվճար և բաց կոդով: Այն ոգտակար էք, խնդրում զարգացությունը կենդանի պահելու համար:</string>
<string name="upgrade_foss_sponsor_action">Հովանավորել զարգացումը</string>
<string name="upgrade_foss_sponsor_subtitle">Լրատնություն չկա: հետքխկություն չկա: Google Play-ի կապվածություն չկա:</string>
<string name="upgrade_foss_sponsor_returned_early">Արդևն հետ? ձևր աիկտակցությունը CAPod-ին կենդանի պահում:</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Menyumbang</string>
<string name="foss_upgrade_alreadydonated_label">Saya telah berdonasi</string>
<string name="foss_upgrade_no_money_label">Saya menghabiskan semua uang saya untuk AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS gratis dan sumber terbuka. Jika Anda merasa bermanfaat, pertimbangkan untuk mensponsori pengembangan guna menjaga proyek ini berjalan.</string>
<string name="upgrade_foss_sponsor_action">Pengembangan sponsor</string>
<string name="upgrade_foss_sponsor_subtitle">Tanpa iklan. Tanpa pelacakan. Tanpa ketergantungan Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Sudah kembali? Dukungan Anda menjaga CAPod tetap hidup.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Gefa</string>
<string name="foss_upgrade_alreadydonated_label">Ég hef þegar gefið</string>
<string name="foss_upgrade_no_money_label">Ég eyði öllum peningunum mínum í AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS er ókeypis og opinn kóði. Ef þú finnur það gagnlegt, þakkaðu þér fyrir að stuðla að þróun til að hjálpa til við að halda verkefninu gangandi.</string>
<string name="upgrade_foss_sponsor_action">Styrkja þróun</string>
<string name="upgrade_foss_sponsor_subtitle">Engar auglysíngar. Engin rakning. Ekkert Google Play-lás.</string>
<string name="upgrade_foss_sponsor_returned_early">Aftur þegar? Styðjað þít heldur CAPod lifandi.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Dona</string>
<string name="foss_upgrade_alreadydonated_label">Ho già donato</string>
<string name="foss_upgrade_no_money_label">Spendo tutti i miei soldi per gli AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS è gratuito e open source. Se lo trovi utile, considera di sponsorizzare lo sviluppo per contribuire a mantenere il progetto in vita.</string>
<string name="upgrade_foss_sponsor_action">Finanzia lo sviluppo</string>
<string name="upgrade_foss_sponsor_subtitle">Nessuna pubblicità. Nessun tracciamento. Nessun vincolo con Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Già via? Il tuo supporto mantiene CAPod in vita.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">תרומה</string>
<string name="foss_upgrade_alreadydonated_label">כבר תרמתי</string>
<string name="foss_upgrade_no_money_label">מיטב כספי מושקע ב-AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS הוא חינם וקוד פתוח. אם הוא שימושי לך, שקול לשקול על חסוי הפיתוח כדי לעזור להמשיך את הפרויקט.</string>
<string name="upgrade_foss_sponsor_action">פיתוח חסות</string>
<string name="upgrade_foss_sponsor_subtitle">אין פרסומות. אין מעקב. אין תלות ב-Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">כבר חוזר? התמיכה שלך שומרת את CAPod בחיים.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">寄付</string>
<string name="foss_upgrade_alreadydonated_label">すでに寄付しました</string>
<string name="foss_upgrade_no_money_label">AirPodsに全財産をつぎ込んでいます</string>
<string name="upgrade_foss_preamble">CAPod FOSSは無料かつオープンソースです。便利だと思ったら、プロジェクトの継続に向けて開発をスポンサーすることをご検討ください。</string>
<string name="upgrade_foss_sponsor_action">開発支援者</string>
<string name="upgrade_foss_sponsor_subtitle">広告なし。追跡なし。Google Play依存なし。</string>
<string name="upgrade_foss_sponsor_returned_early">もう戻りますか?あなたのサポートがCAPodを支えています。</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">შემოწირულობა</string>
<string name="foss_upgrade_alreadydonated_label">მე უკვე შემოწირული მაქვს</string>
<string name="foss_upgrade_no_money_label">მთელ ჩემს ფულს AirPods-ებზე ვხარჯავ</string>
<string name="upgrade_foss_preamble">CAPod FOSS უფასოა და ღია წყაროა. თუ გსარგებლია, გაანაბრეთ განვითარება პროექტის გადასაგრელად.</string>
<string name="upgrade_foss_sponsor_action">სპონსორის განვითარება</string>
<string name="upgrade_foss_sponsor_subtitle">რეკლამა არა. თვალთვალება არა. Google Play-ზე დამოკიდება არა.</string>
<string name="upgrade_foss_sponsor_returned_early">უკვე დაბრუნდით? თქვენი მხარდაჯერა CAPod-ს გადარჩენებას ეხმარება.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">បរិច្ចាគ</string>
<string name="foss_upgrade_alreadydonated_label">ខ្ញុំបានបរិច្ចាគរួចហើយ</string>
<string name="foss_upgrade_no_money_label">ខ្ញុំចំណាយលុយទាំងអស់របស់ខ្ញុំលើ AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ជាកម្មវិធីឥតគិតថ្លៃ និងប្រភពបើកចំហ។ ប្រសិនបើអ្នករកហើញថ្លៃវាមានប្រយោជន៍ សូមពិចារណាឧបត្ថម្ភការអភិវឌ្ឍន៍ ដើម្បីជួយឥ្សរិយគម្រោងបន្តដំណើរការ។</string>
<string name="upgrade_foss_sponsor_action">ឧបត្ថម្ភការអភិវឌ្ឍន៍</string>
<string name="upgrade_foss_sponsor_subtitle">គ្មានការផ្សាយពាណិជ្ជកម្ម។ គ្មានការតាមដាន។ គ្មានការចាក់សោ Google Play។</string>
<string name="upgrade_foss_sponsor_returned_early">ត្រលប់មកវិញហើយ។? ការគាំទ្ររបស់អ្នកចិញ្ចឹម CAPod ឥ្សរិយនៅរស់។</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Beşdar bike</string>
<string name="foss_upgrade_alreadydonated_label">Min berê bexş kir</string>
<string name="foss_upgrade_no_money_label">Ez hemû pereyê xwe li AirPods xerc dikim</string>
<string name="upgrade_foss_preamble">CAPod FOSS belaş û çavkaniya vekirî ye. Ger hûn wê kêrhastî dibînin, fikir bikin ku perkirezan bike da ku alîkariya berdewamî projeya bide.</string>
<string name="upgrade_foss_sponsor_action">Pêşdebiriyê teref bike</string>
<string name="upgrade_foss_sponsor_subtitle">Reklam tune. Śopandin tune. Girêdana Google Play tune.</string>
<string name="upgrade_foss_sponsor_returned_early">Zirû vegeriya? Piştgiriya we CAPod sax dihêle.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">ದಾನ ಮಾಡಿ</string>
<string name="foss_upgrade_alreadydonated_label">ನಾನು ಈಗಾಗಲೇ ದಾನ ಮಾಡಿದ್ದೇನೆ</string>
<string name="foss_upgrade_no_money_label">ನಾನು ನನ್ನ ಎಲ್ಲಾ ಹಣವನ್ನು AirPods ಗಾಗಿ ಖರ್ಚು ಮಾಡುತ್ತೇನೆ</string>
<string name="upgrade_foss_preamble">CAPod FOSS ಉಚಿತ ಮತ್ತು ತೆರೆದ ಮೂಲದ್ದಾಗಿದೆ. ನಿಮಗೆ ಉಪಯುಕ್ತವಾಗಿ ತೋರಿದರೆ, ಯೋಜನೆಯನ್ನು ಮುಂದುವರಿಸಲು ಅಭಿವೃದ್ಧಿಗೆ ಪ್ರಾಯೋಜಕತ್ವ ನೀಡಲು ಪರಿಗಣಿಸಿ.</string>
<string name="upgrade_foss_sponsor_action">ಅಭಿವೃದ್ಧಿಗೆ ಪ್ರಾಯೋಜಕತ್ವ ನೀಡಿ</string>
<string name="upgrade_foss_sponsor_subtitle">ಜಾಹೀರಾತುಗಳಿಲ್ಲ. ಭಕ್ಷಣೆ ಇಲ್ಲ. Google Play ಲಾಕ್-ಇನ್ ಇಲ್ಲ.</string>
<string name="upgrade_foss_sponsor_returned_early">ಇಷ್ಟೇ ಹಿಂದಿರುಗಿದಿರಿ? ನಿಮ್ಮ ಬೆಂಬಲ CAPod ಅನ್ನು ಜೀವಂತವಾಗಿಡುತ್ತದೆ.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">후원하기</string>
<string name="foss_upgrade_alreadydonated_label">이미 후원했어요</string>
<string name="foss_upgrade_no_money_label">AirPods에 돈을 다 썼어요</string>
<string name="upgrade_foss_preamble">CAPod FOSS는 무료 오픈소스입니다. 유용하게 사용하고 계신다면 프로젝트 유지에 도움이 되도록 후원을 고려해 주세요.</string>
<string name="upgrade_foss_sponsor_action">개발 후원하기</string>
<string name="upgrade_foss_sponsor_subtitle">광고 없음. 추적 없음. Google Play 의존 없음.</string>
<string name="upgrade_foss_sponsor_returned_early">벌써 돌아가세요? 여러분의 지원이 CAPod를 유지합니다.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Демөөрчүлүк кылуу</string>
<string name="foss_upgrade_alreadydonated_label">Мен буга чейин демөөрчүлүк кылгам</string>
<string name="foss_upgrade_no_money_label">Мен бүт акчамды AirPods үчүн короттум</string>
<string name="upgrade_foss_preamble">CAPod FOSS беплатан жана ачык каттулууда. Егер пайдалуу таапсаңыз, жобаны улантырууга колдоо көрсөтүңүз.</string>
<string name="upgrade_foss_sponsor_action">Иштелеп чыгууну колдоо</string>
<string name="upgrade_foss_sponsor_subtitle">Жарнама. Кадалоолоо жок. Google Playга байланма.</string>
<string name="upgrade_foss_sponsor_returned_early">Артка кайттыңызбы? Сиздин колдооңуз CAPodты тирүү сактайт.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">ບໍລິຈາກ</string>
<string name="foss_upgrade_alreadydonated_label">ຂ້ອຍໄດ້ບໍລິຈາກແລ້ວ</string>
<string name="foss_upgrade_no_money_label">ຂ້ອຍໃຊ້ເງິນທັງໝົດຂອງຂ້ອຍໃສ່ AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ແມ່ນຟຣີ ແລະ ໂອເພນຊອດ. ຖ້າທ່ານເຫັນວ່າມີປະໂຫຍດ, ພິຈາລະນາສະໜັບສະໜູນການພັດທະນາເພື່ອຊ່ວຍໃຫ້ໂຄງການດຳເນີນຕໍ່ໄປ.</string>
<string name="upgrade_foss_sponsor_action">ສະໜັບສະໜູນການພັດທະນາ</string>
<string name="upgrade_foss_sponsor_subtitle">ບໍ່ມີໂຄສະນາ. ບໍ່ມີການຕິດຕາມ. ບໍ່ຕິດ Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">ກັບມາແລ້ວ? ການສະໜັບສະໜູນຂອງທ່ານຮັກສາ CAPod ໃຫ້ດຳເນີນຕໍ່ໄປ.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Aukoti</string>
<string name="foss_upgrade_alreadydonated_label">Aš jau paaukojau</string>
<string name="foss_upgrade_no_money_label">Visus savo pinigus išleidžiu „AirPods“ ausinėms</string>
<string name="upgrade_foss_preamble">CAPod FOSS yra nemokama ir atviro kodo programa. Jei ji jums naudinga, pagalvokite apie rėmimą, kad projektą būtų galima tęsti.</string>
<string name="upgrade_foss_sponsor_action">Remti plėtrą</string>
<string name="upgrade_foss_sponsor_subtitle">Jokių reklamų. Jokio sekimo. Jokio „Google Play“ priklausomybės.</string>
<string name="upgrade_foss_sponsor_returned_early">Jau grįžte? Jūsų parama palaiko CAPod gyvybę.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Ziedot</string>
<string name="foss_upgrade_alreadydonated_label">Es jau ziedoju</string>
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
<string name="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez izsekošanas. Bez Google Play saistībām.</string>
<string name="upgrade_foss_sponsor_returned_early">Jau atpakaļ? Tavs atbalsts uztur CAPod esamību.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Донирај</string>
<string name="foss_upgrade_alreadydonated_label">Веќе донирав</string>
<string name="foss_upgrade_no_money_label">Ги трошам сите пари на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS е бесплатен и отворен код. Ако ви е корисен, размислете да го спонзорирате развојот за да помогнете проектот да продолжи.</string>
<string name="upgrade_foss_sponsor_action">Спонзорирај развој</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклами. Без пратење. Без зависност од Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Веќе се вративте? Вашата поддршка го одржува CAPod жив.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">സംഭാവന ചെയ്യുക</string>
<string name="foss_upgrade_alreadydonated_label">ഞാൻ ഇതിനകം സംഭാവന ചെയ്തു</string>
<string name="foss_upgrade_no_money_label">ഞാൻ എൻ്റെ എല്ലാ പണവും AirPods-നായി ചെലവഴിക്കുന്നു</string>
<string name="upgrade_foss_preamble">CAPod FOSS സൗജന്യവും ഓപ്പൻ സോഴ്സുമാണ്. നിങ്ങൾക്ക് ഇത് ഉപകാരപ്രദമാണെങ്കിൽ, പ്രോജെക്ട് തുടരാൻ സഹായിക്കുന്നതിന് ഡെവലപ്പ്മെന്റ് സ്പോൺസർ ചെയ്യുന്നത് പരിഗണിക്കുക.</string>
<string name="upgrade_foss_sponsor_action">വികസനം സ്പോൺസർ ചെയ്യുക</string>
<string name="upgrade_foss_sponsor_subtitle">പരസ്യങ്ങൾ ഇല്ല. ട്രാക്കിംഗ് ഇല്ല. Google Play ലോക്ക്-ഇൻ ഇല്ല.</string>
<string name="upgrade_foss_sponsor_returned_early">ഇത്ര പെട്ടെന്ന് തിരിച്ചു പോകുന്നോ? നിങ്ങളുടെ പിന്തുണ CAPod നിലനിർത്തുന്നു.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Хандивлах</string>
<string name="foss_upgrade_alreadydonated_label">Би аль хэдийн хандивласан</string>
<string name="foss_upgrade_no_money_label">Би бүх мөнгөө AirPods-д зарцуулдаг</string>
<string name="upgrade_foss_preamble">CAPod FOSS нь үнэгүй, нээлтэй эхийн програм. Хэрэв танд хэрэгтэй бол төслийг үргэлжүүлэхэд туслахын тулд хөгжүүлэлтийг дэмжих талаар бодоорой.</string>
<string name="upgrade_foss_sponsor_action">Хөгжлийг дэмжих</string>
<string name="upgrade_foss_sponsor_subtitle">Реклам байхгүй. Хяналт байхгүй. Google Play-д хамааралгүй.</string>
<string name="upgrade_foss_sponsor_returned_early">Аль хэдэйн буцаж ирлээ? Таны дэмжлэг CAPod-ийг амьд байлгадаг.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">दान करा</string>
<string name="foss_upgrade_alreadydonated_label">मी आधीच दान केले आहे</string>
<string name="foss_upgrade_no_money_label">मी माझे सर्व पैसे AirPods वर खर्च करतो</string>
<string name="upgrade_foss_preamble">CAPod FOSS विनामूल्य आणि ओपन सोर्स आहे. जर तुम्हाला ते उपयुक्त वाटत असेल, तर प्रकल्प सुरू ठेवण्यासाठी विकासास प्रायोजित करण्याचा विचार करा.</string>
<string name="upgrade_foss_sponsor_action">विकासास प्रायोजित करा</string>
<string name="upgrade_foss_sponsor_subtitle">कोणत्याही जाहिराती नाहीत. कोणतेही ट्रॅकिंग नाही. Google Play चे बंधन नाही.</string>
<string name="upgrade_foss_sponsor_returned_early">आधीच परत? तुमचा पाठिंबा CAPod जिवंत ठेवतो.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Sumbang</string>
<string name="foss_upgrade_alreadydonated_label">Saya telah menyumbang</string>
<string name="foss_upgrade_no_money_label">Saya belanjakan semua wang saya untuk AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS adalah percuma dan sumber terbuka. Jika anda mendapatinya berguna, pertimbangkan untuk menaja pembangunannya bagi membantu projek ini terus berjalan.</string>
<string name="upgrade_foss_sponsor_action">Pembangunan penaja</string>
<string name="upgrade_foss_sponsor_subtitle">Tiada iklan. Tiada penjejakan. Tiada ikatan Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Sudah kembali? Sokongan anda mengekalkan CAPod.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">လှူဒါန်းပါ</string>
<string name="foss_upgrade_alreadydonated_label">ကျွန်တော် လှူပြီးပါပြီ</string>
<string name="foss_upgrade_no_money_label">ကျွန်တော် ပိုက်ဆံအားလုံးကို AirPods မှာ သုံးတယ်</string>
<string name="upgrade_foss_preamble">CAPod FOSS သည် အခမဲ့နှင့် open source ဖြစ်သည်။ အသုံးပြုခုပ်ဆည်းနိုင် အသားဖြစ်ပါက ပရာဗက်ဆက်လက်ရန် ဖွဲ့ဖြီးရေးကို ပံ့ပိုးပေးသည်။</string>
<string name="upgrade_foss_sponsor_action">ဖွံ့ဖြိုးတိုးတက်မှုကို ကမကထပံ့ပိုးရန်</string>
<string name="upgrade_foss_sponsor_subtitle">ကြောင်ညာမရှိ။ ခြေရာမခံ။ Google Play ချုပ်ချယ်မြုမရှိ။</string>
<string name="upgrade_foss_sponsor_returned_early">ပြန်သွားပြီးလါ? သင်၏ ပံ့ပိုးမှု CAPod ကို ရှင်သန်ဆက်လက်ကေစေသည်။</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Doner</string>
<string name="foss_upgrade_alreadydonated_label">Jeg har allerede donert</string>
<string name="foss_upgrade_no_money_label">Jeg bruker alle pengene mine på AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS er gratis og åpen kildekode. Hvis du synes den er nyttig, kan du vurdere å sponse utviklingen for å bidra til at prosjektet går videre.</string>
<string name="upgrade_foss_sponsor_action">Støtt utviklingen</string>
<string name="upgrade_foss_sponsor_subtitle">Ingen reklame. Ingen sporing. Ingen Google Play-avhengighet.</string>
<string name="upgrade_foss_sponsor_returned_early">Allerede tilbake? Din støtte holder CAPod i live.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">दान गर्नुहोस्</string>
<string name="foss_upgrade_alreadydonated_label">मैले पहिले नै दान गरिसकेको छु</string>
<string name="foss_upgrade_no_money_label">म आफ्नो सबै पैसा AirPods मा खर्च गर्छु</string>
<string name="upgrade_foss_preamble">CAPod FOSS निःशुल्क र खुला स्रोत छ। यदि तपाईंले उपयोगी पाउनुभएको छ भने, प्राजेक्ट चलाउन राख्न विकासमा प्रायोजन गर्न विचार गर्नुहोस्।</string>
<string name="upgrade_foss_sponsor_action">विकासलाई प्रायोजन गर्नुहोस्</string>
<string name="upgrade_foss_sponsor_subtitle">कुनै विज्ञापन छैन। कुनै ट्रैकिङ छैन। Google Playमा बाँधिएको छैन।</string>
<string name="upgrade_foss_sponsor_returned_early">पहिले नै फिर्नुभयो? तपाईंको सहयोगले CAPod जीवित राख्छ।</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Doneren</string>
<string name="foss_upgrade_alreadydonated_label">Ik heb al gedoneerd</string>
<string name="foss_upgrade_no_money_label">Ik besteed al mijn geld aan AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS is gratis en open source. Als je het nuttig vindt, overweeg dan om de ontwikkeling te sponsoren om het project gaande te houden.</string>
<string name="upgrade_foss_sponsor_action">Sponsor ontwikkeling</string>
<string name="upgrade_foss_sponsor_subtitle">Geen advertenties. Geen tracking. Geen Google Play-vergrendeling.</string>
<string name="upgrade_foss_sponsor_returned_early">Alweer terug? Jouw steun houdt CAPod in leven.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+7 -2
View File
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Wsparcie</string>
<string name="foss_upgrade_alreadydonated_label">Już się zrzuciłem</string>
<string name="foss_upgrade_donate_label">Wesprzyj</string>
<string name="foss_upgrade_alreadydonated_label">Już wsparłem</string>
<string name="foss_upgrade_no_money_label">Wydałem wszystkie pieniądze na AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS jest bezpłatny i otwartoźródłowy. Jeśli uważasz go za użyteczny, rozważ sponsorowanie rozwoju, aby pomóc kontynuować projekt.</string>
<string name="upgrade_foss_sponsor_action">Wesprzyj rozwój</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklam. Bez śledzenia. Bez uzależnienia od Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Już wróciłeś? Twoje wsparcie utrzymuje CAPod przy życiu.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Eu já doei</string>
<string name="foss_upgrade_no_money_label">Eu gasto todo o meu dinheiro em AirPods</string>
<string name="upgrade_foss_preamble">O CAPod FOSS é gratuito e de código aberto. Se você o achar útil, considere patrocinar o desenvolvimento para ajudar a manter o projeto ativo.</string>
<string name="upgrade_foss_sponsor_action">Patrocinador do desenvolvimento</string>
<string name="upgrade_foss_sponsor_subtitle">Sem anúncios. Sem rastreamento. Sem dependência do Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Já voltou? Seu apoio mantém o CAPod vivo.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Já doei</string>
<string name="foss_upgrade_no_money_label">Gastei todo o meu dinheiro em AirPods</string>
<string name="upgrade_foss_preamble">O CAPod FOSS é gratuito e de código aberto. Se o achar útil, considere patrocinar o desenvolvimento para ajudar a manter o projeto ativo.</string>
<string name="upgrade_foss_sponsor_action">Patrocine o desenvolvimento</string>
<string name="upgrade_foss_sponsor_subtitle">Sem anúncios. Sem rastreamento. Sem dependência do Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Já está a sair? O seu apoio mantém o CAPod ativo.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+10 -1
View File
@@ -1,2 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Hai gia donat</string>
<string name="foss_upgrade_no_money_label">Mett tut mes daners en AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS è gratuit ed open source. Sche ti chattast ch\'el è util, considerescha da sponsurisar il sviluppament per agidà a mantegnair il project.</string>
<string name="upgrade_foss_sponsor_action">Sponsurisar il sviluppament</string>
<string name="upgrade_foss_sponsor_subtitle">Nagins annunzis. Nagin tracking. Nagin blocadi da Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Gia enavos? Tes sustegn mantegn CAPod viv.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Donează</string>
<string name="foss_upgrade_alreadydonated_label">Am donat deja</string>
<string name="foss_upgrade_no_money_label">Mi-am cheltuit toți banii pe AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS este gratuit si open source. Daca il gasiti util, luati in considerare sponsorizarea dezvoltarii pentru a ajuta la continuarea proiectului.</string>
<string name="upgrade_foss_sponsor_action">Sponsorizează dezvoltarea</string>
<string name="upgrade_foss_sponsor_subtitle">Fara reclame. Fara urmarire. Fara dependenta de Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Ati revenit deja? Sprijinul dvs. mentine CAPod in viata.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Пожертвовать</string>
<string name="foss_upgrade_alreadydonated_label">Я уже пожертвовал</string>
<string name="foss_upgrade_no_money_label">Я потратил все свои деньги на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если Вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
<string name="upgrade_foss_sponsor_action">Поддержать разработку</string>
<string name="upgrade_foss_sponsor_subtitle">Без рекламы. Без слежки. Без привязки к Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Вернулись? Ваша поддержка помогает CAPod жить.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Dona</string>
<string name="foss_upgrade_alreadydonated_label">Apo giai donadu</string>
<string name="foss_upgrade_no_money_label">Apo spèndidu totu is soddos meos pro is AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS est gratis e fonte aberta. Si test de agiudu, considera de patrotzinare su isviluppu pro agiudare a mantennere su progetu.</string>
<string name="upgrade_foss_sponsor_action">Sponsoriza s\'isvilupu</string>
<string name="upgrade_foss_sponsor_subtitle">Peroe publicidade. Peroe trachiamentu. Peroe dipendentzia de Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Zas torradas giai? Su sostegnu tuyu mantenet CAPod in vida.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">පරිත්‍යාග</string>
<string name="foss_upgrade_alreadydonated_label">මම දැනටමත් පරිත්‍යාග කර ඇත</string>
<string name="foss_upgrade_no_money_label">මම මගේ මුදල් සියල්ල AirPods සඳහා වියදම් කරමි</string>
<string name="upgrade_foss_preamble">CAPod FOSS නියමිත සහ විවෘත මූලාශ්‍රයි. ඔබට ඉත උපකාරී නම්, ආයෝජනය ගිණුම්‍ සහය ගෙවිපීම කිරීමට අර්වමයක් ගෙවිපීම් ප්‍රස්තා කරන්නට සංවර්ධන කරන්න.</string>
<string name="upgrade_foss_sponsor_action">සංවර්ධනයට අනුග්‍රහය දෙන්න</string>
<string name="upgrade_foss_sponsor_subtitle">අළු නැත. ට්‍රැකිං නැත. Google Play සලක඾ත්වය නැත.</string>
<string name="upgrade_foss_sponsor_returned_early">එක්බේන්මට පළ්ට? ඔබ෪් සහය් CAPod ක්‍රියාත්මකව තබා ගෙනයි.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Prispieť</string>
<string name="foss_upgrade_alreadydonated_label">Už som prispel/a</string>
<string name="foss_upgrade_no_money_label">Všetky peniaze som minul na AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS je zadarmo a open source. Ak ho nájdete užitočným, zvažte sponzorovanie vývoja, aby ste pomohli udržiavať projekt v chode.</string>
<string name="upgrade_foss_sponsor_action">Sponzorovaný vývoj</string>
<string name="upgrade_foss_sponsor_subtitle">Žiadne reklamy. Žiadne sledovanie. Žiadna závislosť od Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Už späť? Vaša podpora udržiava CAPod pri živote.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Doniraj</string>
<string name="foss_upgrade_alreadydonated_label">Sem že doniral</string>
<string name="foss_upgrade_no_money_label">Ves svoj denar porabim za AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS je brezplačen in odprtokoden. Če se vam zdi koristen, razmislite o sponzoriranju razvoja, da pomagate ohranjati projekt.</string>
<string name="upgrade_foss_sponsor_action">Podpri razvoj</string>
<string name="upgrade_foss_sponsor_subtitle">Brez oglasov. Brez sledenja. Brez vezave na Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Že nazaj? Vaša podpora ohranja CAPod pri življenju.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Dhuro</string>
<string name="foss_upgrade_alreadydonated_label">Unë tashmë kam dhuruar</string>
<string name="foss_upgrade_no_money_label">Unë i shpenzoj të gjitha paratë e mia për AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS është falas dhe me burim të hapur. Nëse e gjeni të dobishmëm, konsideroni sponsorizimin e zhvillimit për ta mbajtur projektin gjallë.</string>
<string name="upgrade_foss_sponsor_action">Zhvillimi i sponsorëve</string>
<string name="upgrade_foss_sponsor_subtitle">Pa reklama. Pa gjurmim. Pa varësi nga Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Ktheheni tashmjë? Mbështjetja juaj e mban CAPod gjallë.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Донације</string>
<string name="foss_upgrade_alreadydonated_label">Већ сам донирао/ла</string>
<string name="foss_upgrade_no_money_label">Сав новац трошим на АирПодс</string>
<string name="upgrade_foss_preamble">CAPod FOSS је бесплатан и отвореног кода. Ако вам је користан, размотрите спонзорисање развоја да би пројекат остао жив.</string>
<string name="upgrade_foss_sponsor_action">Спонзориши развој</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклама. Без праћења. Без зависности од Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Већ одлазите? Ваша подршка чува CAPod живим.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Donera</string>
<string name="foss_upgrade_alreadydonated_label">Jag har redan donerat</string>
<string name="foss_upgrade_no_money_label">Jag spenderar alla mina pengar på AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS är gratis och öppen källkod. Om du tycker att det är användbart kan du överväga att sponsra utvecklingen för att hjälpa till att hålla projektet vid liv.</string>
<string name="upgrade_foss_sponsor_action">Sponsra utvecklingen</string>
<string name="upgrade_foss_sponsor_subtitle">Inga annonser. Ingen spårning. Ingen bindning till Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Redan tillbaka? Ditt stöd håller CAPod vid liv.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
+5
View File
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">Changia</string>
<string name="foss_upgrade_alreadydonated_label">Nimeshachangia tayari</string>
<string name="foss_upgrade_no_money_label">Natumia pesa zangu zote kwenye AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ni bure na chanzo huria. Ikiwa unaikuta ni muhimu, fikiria kuchangia maendeleo ili kusaidia mradi kuendelea.</string>
<string name="upgrade_foss_sponsor_action">Faidhi maendeleo</string>
<string name="upgrade_foss_sponsor_subtitle">Hakuna matangazo. Hakuna ufuatiliaji. Hakuna kufungwa kwa Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Unarudi tayari? Msaada wako unaweka CAPod iwe hai.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>
@@ -3,4 +3,9 @@
<string name="foss_upgrade_donate_label">நன்கொடை</string>
<string name="foss_upgrade_alreadydonated_label">நான் ஏற்கனவே நன்கொடை அளித்துவிட்டேன்</string>
<string name="foss_upgrade_no_money_label">எனது பணத்தை எல்லாம் ஏர்பாட்களில் செலவிடுகிறேன்</string>
<string name="upgrade_foss_preamble">CAPod FOSS இலவசமாகவும் திறழ் மூலக்கோடும் உள்ளது. இது பயனுள்ளதாக கணித்தால், தொடர்பு பொறுத்துக்க அம்சத்தை நிச்சயிக்கவும்.</string>
<string name="upgrade_foss_sponsor_action">மேம்பாட்டை ஸ்பான்சர் செய்யுங்கள்</string>
<string name="upgrade_foss_sponsor_subtitle">ஐவிளம்பரம் இல்லை. கண்காணிப்பு இல்லை. Google Play கட்டுப்பாட்டு இல்லை.</string>
<string name="upgrade_foss_sponsor_returned_early">இத்தனை இருக்கிறீரகளா? உங்கள் ஆதரவு CAPodஐ உயிருடன் வைத்திருக்கிறது.</string>
<string name="upgrade_badge_label">FOSS</string>
</resources>

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