Compare commits

..
1173 Commits
Author SHA1 Message Date
d4rken-org-releaser[bot] abf28f335c Release: 5.2.4-rc0 2026-08-22 04:42:29 +00:00
Matthias Urhahn 540e6d1dbc Merge pull request #690 from d4rken-org/crowdin-update-20260821
General: Update translations from Crowdin
2026-08-21 20:54:10 +02:00
darken 76e1128e93 General: Update app translations from Crowdin 2026-08-21 19:14:22 +02:00
darken cbf681ccfc General: Update fastlane translations from Crowdin 2026-08-21 18:56:35 +02:00
darken 30f17b4791 fix(ui): Drop the removed monitor mode setting from the auto connect hint
The auto connect description told users the toggle would set the monitor mode
setting to 'Always'. That setting was removed in 5.1.6 and the mode is derived
from profile state now, so the sentence pointed at a settings entry that no
longer exists. The behaviour it described is unchanged: enabling auto connect
still resolves to continuous monitoring.

Base string only, so Crowdin flags the existing translations as outdated.
2026-08-21 10:52:55 +02:00
Matthias Urhahn cd6aaaf165 Merge pull request #687 from d4rken-org/worktree-settings-plain-language
General: Explain the compatibility settings in plain language
2026-08-21 01:20:03 +02:00
darken 289dcd660e ui(settings): Explain compatibility options in plain language
The three compatibility toggles described their own mechanism ("Don't
delegate data filtering to the system", "broadcast instead of callback")
without saying which symptom they address, so a non-technical user had no
way to tell whether a row applied to them. Each summary now leads with the
symptom and keeps a plain description of what changes. Titles are
unchanged: they name the mechanism and are referenced verbatim in issue
replies.

The category description "Don't touch if everything works ;)" stopped
rendering in 63692595 when the res/xml preference screens were replaced by
Compose, since SettingsCategoryHeader only took a title. The string and its
~75 translations survived unused. Added an optional subtitle to the header
and wired it back up.

Also rewrote the extra-notification summary, which named the "Device
status" channel but never said where to find it.

Reported via a Play Store review (vi, 4 stars, 5.2.3-rc0).
2026-08-21 01:13:13 +02:00
Matthias Urhahn b07428f894 Merge pull request #686 from d4rken-org/worktree-anc-adaptive-misreport
Device: Fix duplicate listening mode commands and silent failures
2026-08-20 18:16:20 +02:00
darken dbd1d422c6 refactor(aap): Split the echo classifier out of this change
Everything that reinterprets what the device reported moves to its own branch
(anc-echo-classifier), leaving only changes that stand on their own.

The classifier addresses a fault that has never been observed being handled: it
reproduced on two of four sessions and none since it was written. It also adds a
failure mode that did not exist before, where a report misattributed to our own
write makes CAPod show a mode the device is not in. That is a poor trade to carry
into main on the strength of tests alone, so it waits until it can be seen working
against a live fault.

What remains does not depend on the misreport:

- the verification deadline was 1000ms while the device answers in 833-1008ms, so
  a healthy reply could land just after the timer and trigger a bogus divergence
  plus a redundant re-send; this was captured live
- a listening mode request the device did not confirm produced no feedback at all
  for any mode except Off, which was a gap in the event plumbing rather than a
  timing artifact
- a mode outside the device's listening mode cycle was rendered as an ordinary
  selectable button whenever it happened to be the current mode
- an Off report arriving while a different mode was requested could teach the Allow
  Off inference, persisting "Off is permitted" into the device profile

Also drops effectiveAncMode, which only had an effect while the classifier was
present.
2026-08-20 12:38:30 +02:00
darken 0b83e86c5e fix(aap): Correct an overstated safety claim and a test that proved nothing
Review follow-ups on the previous commit.

Its claim that "nothing is learned or persisted" was wrong. BatteryEstimator
buckets drain samples by the current listening mode and force-persists the
accumulated window whenever that mode changes, so a misattributed mode can write
a drain rate to disk under the wrong bucket, and the corrective report does not
remove it. This is not new: the misreport being fixed here already mis-buckets in
the same way, and more often, since the device claims OFF while the pods play
Adaptive. Classifying corrects the common case and only gets it wrong on the rarer
misattribution. The design stands, the claim does not.

Two other claims were also too strong. The recorded echo is the first report after
the verification was installed, which is not exactly the wire write, so write
contention above the latency boundary can still inflate a fast refusal. And a
superseded write is not left alone entirely: classification is skipped, but it
still falls through to the ordinary retry path.

The supersession regression test asserted nothing: it fed a fresh ADAPTIVE report
in before its only assertion, overwriting either outcome, so it passed whether or
not the guard existed. It now asserts on the state left by the delayed echo, and
fails with the guard removed.

Also drops an unused import and restores an indent lost when the remap argument
was removed.
2026-08-20 00:06:03 +02:00
darken b396ce457d fix(aap): Attribute a listening mode echo before drawing conclusions from it
Extends the previous commit's classifier so it only judges evidence it can
actually attribute to our own write.

The classifier judged whatever mode happened to be current when the deadline
fired, and never looked at timing, despite the refusal-versus-misreport
distinction resting on it. A delayed answer to an earlier write, or a mode change
made on the pods themselves mid-request, could be taken for the answer to the
outstanding write.

Now the first report after a write is recorded with its latency, and that
recorded frame is what gets classified. A write is left alone entirely when it
was superseded by another listening mode write or by a stem press, since its
echoes can no longer be attributed. An answer arriving faster than 500ms is a
refusal, never a change: captures put refusals at 25-267ms and real changes at
815-1010ms. A re-send restamps its own send time and drops the previous attempt's
echo, so a retry is never judged on stale evidence. Latency is measured with the
monotonic clock, so a wall clock correction cannot turn a fast refusal into an
apparent change.

A misattribution is still possible, because AAP reports carry no correlation id
and a change made from iOS or another paired phone is invisible here. That is why
nothing is learned or persisted from this: the worst case is one wrong reading
that the device's next report corrects.

An earlier version of this work also carried a session-scoped remap, so that a
stem-initiated switch could be read correctly after our own write had proven the
device mislabels a value (issue #594). It is not included. The same
unattributability that bounds the classifier to a single wrong reading would have
let one misattribution rewrite every later report in the session, and a wrongly
resolved Off could persist AllowOffOption into the device profile, outliving the
session that produced it. Stem-initiated switches on an affected session are
therefore still not shown correctly.

Refs #594
2026-08-19 23:52:52 +02:00
darken 734e15c94d fix(aap): Tell a listening mode refusal apart from an unusable echo
Follow-up to the previous commit, which left the wrong half of this in place.
Distrusting the report only while the request was outstanding meant that four
seconds later the rejection cleared the pending mode, the bogus value came back,
and the user was shown an error for a mode change that had actually worked.

The two cases have different signatures, both readable from state the engine
already holds:

- A refusal echoes the mode the device is staying in, quickly. Captured Off
  refusals answer in 25-267ms with the previous mode.
- The Pro 3 misreport answers with a third mode, neither the one requested nor
  the one it was in, at normal change latency (815-1010ms).

So an echo that is neither the requested nor the previous mode is treated as an
unusable report rather than a refusal: no re-send of a write that already took
effect, no rejection, no error, and the requested mode is recorded as current.
Refusals still work, which is what the Off rejection message and the Allow Off
learning depend on.

This is deliberately engine-local. Seeding the cycle mask and Allow Off belief
from the device profile into the session would have encoded the rule directly,
but it inverts the current engine-to-profile data flow and creates a belief that
no device report can ever correct, since AirPods never report 0x1A or 0x34.
librepods keeps the same knowledge in its service layer and preferences, not in
its protocol manager.

The fault is per-session rather than per-request: across four sessions today the
pods either misreported every Adaptive write or none of them. The heuristic is
covered by unit tests but has not yet been observed handling a live bad session.
2026-08-19 20:33:32 +02:00
darken edf84248ab fix(anc): Don't show a phantom Off mode when AirPods misreport the listening mode
AirPods Pro 3 can answer a listening mode write with 0x0D 0x01 (Off) while
audibly switching to the requested mode. Seen on firmware
81.2675000075000000.6503, intermittently, and not reproducible on demand.

CAPod took that report at face value: it surfaced an Off button that isn't even
in the device's listening mode cycle, selected it, and said nothing about the
request not having been confirmed.

- visibleAncModes no longer re-admits a mode purely because it is the current
  one. That escape clause was what conjured the extra button.
- effectiveAncMode keeps showing the requested mode while our own request is
  outstanding and the device reports a mode it should not be able to reach.
- A rejected listening mode request now surfaces a message for every mode, not
  only Off. Other modes were dropped silently.
- The Allow Off inference ignores an Off report that arrived while a different
  mode was pending, so a single glitch cannot permanently persist "Off is
  allowed" into the device profile. An unsolicited Off still trains it, which
  is what keeps the option discoverable after it is enabled elsewhere.
- Verification deadline moved from a hardcoded 1000ms to 2000ms, and a matching
  device report now settles the verification when it arrives. Measured reply
  latency is 833-1008ms, so the old deadline sat inside the device's normal
  spread and could fire a bogus divergence plus a redundant re-send.

Settling verification on arrival is limited to SetAncMode deliberately. Every
other verified command is optimistically written into state when it is queued,
so its predicate is satisfied immediately and only the device's contradicting
echo makes it fail. Settling those early would swallow the rejection.

Note that AirPods never report AllowOffOption (0x34) or ListeningModeCycle
(0x1A), so which modes are permitted is always inferred, never device truth.
2026-08-19 19:09:20 +02:00
Matthias Urhahn 83dfdb9219 Merge pull request #682 from d4rken-org/feat/focus-duck-fallback
Reaction: Lower conversation volume even on phones that block it
2026-08-19 09:05:16 +02:00
darken 70b9c2981e fix(upgrade): Align the androidx.hilt declarations with the resolved 1.2.0
addWorkerManager() needed androidx.hilt 1.2.0 for its KSP compiler, which left
addDagger() asking for 1.0.0 while conflict resolution silently handed it 1.2.0,
and hilt-compiler declared twice at two different versions.

The version moves to Versions.AndroidX.Hilt.core, addDagger() stays the single
place that registers hilt-common and the hilt-compiler, and addWorkerManager()
declares only hilt-work. Declared and resolved versions now match, so the
dependency report no longer shows a 1.0.0 -> 1.2.0 substitution.

No dependency graph change: 1.2.0 is what already resolved.
2026-08-18 17:55:19 +02:00
darken 971fcbd34c fix(upgrade): Add a persistent acknowledgement safety net for Play purchases
Play auto-refunds (and revokes) purchases not acknowledged within 3 days.
The in-process ack machinery covers every case where the process lives
long enough; what it cannot cover is a process death around the Play
sheet (aggressive OEM task killers) followed by the user not reopening
the app before the deadline.

Add a gplay-only WorkManager safety net:
- PurchaseAckWorker: self-completing sweep via a new bounded
  BillingManager.ensureAllAcknowledged() that refreshes and acknowledges
  in the same coroutine (the reactive ack collector is async, so a worker
  cannot prove its acks happened through it). Retries with exponential
  backoff until the purchase's refund deadline, then gives up visibly.
- PurchaseAckScheduler: two unique work identities. A launch watch
  (REPLACE, armed and awaited before launchBillingFlow with a 30min delay
  so it cannot complete while the user is still in the sheet) and a
  discovered-purchase rescue (KEEP, 1min delay, armed directly from an
  ack pass that finds unacknowledged purchases, pre-attempt). Separate
  identities so a new purchase flow can never displace a pending rescue.
  Both triggers are fail-open: a broken WorkManager never blocks a
  purchase or an ack. WorkManager resolves via Provider at first arm so
  eager Application-time construction of the billing stack cannot
  trigger WorkManager's on-demand initialization prematurely.
- Nothing cancels the work from the foreground path: an ack pass can see
  zero unacked purchases while the sheet is still open, so the worker
  completes itself after its own reconciliation instead.

The ack pass now runs under a mutex (the worker sweep and the reactive
collector would otherwise race the token bookkeeping) and reports
per-outcome counts for the sweep result mapping.

This is a port of d4rken-org/sdmaid-se#2685; the ported sources are
byte-identical to the donor apart from the package rename.

CAPod had no explicit WorkManager wiring at all (work-runtime only
arrived transitively through Glance), so this also adds it:
- addWorkerManager() pinning androidx.work 2.7.1, the version already
  resolved via Glance, plus androidx.hilt:hilt-work and its KSP
  compiler. work-runtime-ktx is required at 2.7.1: CoroutineWorker,
  Operation.await, OneTimeWorkRequestBuilder and workDataOf all still
  live in the ktx artifact at that version. androidx.hilt moves 1.0.0 ->
  1.2.0 (by conflict resolution) because 1.0.0's hilt-compiler ships no
  KSP SymbolProcessorProvider, so @HiltWorker would generate nothing.
- WorkManagerModule providing the singleton WorkManager.
- App implements Configuration.Provider with the injected
  HiltWorkerFactory. WorkManager 2.7.1 still declares that interface as
  getWorkManagerConfiguration(), not the later property form.
- The manifest removes androidx.work's startup initializer so the
  on-demand configuration is the one that takes effect.

FOSS stays untouched behaviour-wise: all new billing types live in
src/gplay, workers need no manifest entry, and the worker factory
resolves the worker only in gplay variants.
2026-08-18 17:55:19 +02:00
Matthias Urhahn cebed0a60d Merge pull request #683 from d4rken-org/worktree-anc-selector-redesign
General: Redesign the noise control mode selector
2026-08-18 15:18:38 +02:00
darken 8817da309f ui(anc): Redesign the listening mode selector
Replaces the outlined segmented row with a filled track carrying a single
sliding thumb. The old control stacked three signals for one state (container
fill, bold, underline) in an outline treatment nothing else in the card uses,
which is what made it read as bolted on.

The track now sits at the same tonal step as the battery panel above it and
reuses the card's 16dp/12dp radii, so selection is carried by thumb position
and fill alone. Only the active mode is named and its slot expands to fit,
which keeps long translations off the width budget entirely.

Slot widths, label reveal and content tint are all derived from one clamped
fractional position, so geometry and content stay in lockstep. The thumb's
leading edge is that position in collapsed-slot units rather than a sum of
the animating widths, which would overshoot the target and crawl back.

Also fixes an invisible pending state: the in-flight branch set
activeContainerColor to secondaryContainer, which is exactly what
SegmentedButtonDefaults already used for the selected segment, so a requested
mode was indistinguishable from a confirmed one. An unconfirmed request now
draws a hollow pulsing thumb and tints the mode the pods are still in.

Transparency moves off Icons.TwoTone.Hearing, which is already the "In ear"
status chip and rendered identically two rows below it in the same card.
2026-08-18 15:04:50 +02:00
darken 27cc0e8d00 test(reaction): Cover the audio-focus duck fallback
Pins the outcome mapping of duckMusicVolume (a higher read-back stays a skip,
an unmoved one is a refusal), the focus request lifecycle (idempotent grant,
retry after denial, abandon only what is held, permanent loss clears the state
but a transient one does not), and the reaction side: focus is requested only
for an ignored write, released at teardown, re-requested by a keep-alive after
a permanent loss, and a late-landing volume write is restored.

The negative assertions are load-bearing: the MediaControl mock is relaxed, so
an accidental focus request on a healthy duck would pass silently.
2026-08-18 12:37:33 +02:00
darken cecd3088be feat(reaction): Duck via audio focus when the volume write is ignored
ColorOS 16 accepts setStreamVolume from a backgrounded app and leaves the
level where it was, so the conversation reaction ducked nothing. duckMusicVolume
now classifies the outcome (Ducked / Unchanged / Skipped) instead of collapsing
everything into a nullable duck, and a level that came back *higher* stays a
skip: that is the user raising the volume between the two reads, not a refusal.

On Unchanged the reaction requests AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK and lets
the framework attenuate the other player. Teardown abandons the focus and, if
the volume ended up below the pre-duck level anyway, restores it (guard for a
device that applies the write asynchronously). The focus request is built by an
injected factory because AudioFocusRequest.Builder is an unmocked stub in plain
JVM unit tests.
2026-08-18 12:36:30 +02:00
Matthias Urhahn 412ea369f1 Merge pull request #681 from d4rken-org/worktree-ca-duck-noop-diag
Reaction: Retry conversation volume lowering when the system ignores it
2026-08-17 18:40:57 +02:00
darken 22ca3a2a46 fix(bluetooth): Fix bogus scan-gap delays in debug logs
lastScanAt was read and written inside the log lambdas, which only run while
a logger is attached (log() checks Logging.hasReceivers first). In a release
build with recording off, the bookkeeping therefore never happened, so the
first delay of every debug recording reported the time since the *previous*
recording ended. Two logs from a support case opened with delay=878453ms and
delay=359983ms, which read as 14 and 6 minutes of suppressed scanning but
were just the gap between recordings.

The bookkeeping moves out of the lambdas, and the clock changes from
currentTimeMillis to elapsedRealtime so a wall-clock correction cannot
fabricate a gap either. That also puts the delay in the same boot-clock
domain as ScanResult.timestampNanos.
2026-08-17 13:00:02 +02:00
darken 92feea8a10 fix(reaction): Don't report a volume duck the system ignored
On ColorOS 16, setStreamVolume from a backgrounded app is accepted without
an exception and leaves the volume untouched: a debug log from an OPPO
PME110 shows "duckMusicVolume(100%): 40 -> 40 (requested 0)" while the same
call succeeded in the foreground.

duckMusicVolume returned a VolumeDuck for that, so ConversationReaction
recorded an Active session, armed the stale backstop, and later "restored" a
level that was never left. It now returns null when the read-back shows no
decrease, which routes the caller into its existing duck-no-op path: nothing
armed, nothing to restore, and a repeat START retries the duck instead of
treating the dead session as a keep-alive.

The predicate is applied >= prior rather than == prior. A route that
quantizes the target back up to the starting index attenuated nothing
either, and a volume that came back higher (user raised it between the two
reads) must not produce a duck whose restore would undo their change. The
WARN says "volume did not decrease" rather than blaming the ROM, since the
read-back alone cannot distinguish those three causes.
2026-08-17 13:00:02 +02:00
d4rken-org-releaser[bot] 3af1af872e Release: 5.2.3-rc0 2026-08-16 19:51:18 +00:00
Matthias Urhahn 1f00925cd8 Merge pull request #680 from d4rken-org/crowdin-update-20260816
General: Update translations from Crowdin
2026-08-16 20:47:28 +02:00
darken 50079e2b7a fix(upgrade): Remove orphaned translation strings failing lint vitals
upgrade_screen_sub_check_failed_title/_message no longer exist in the
English source (removed in 3e6541de). Seven locales still carried
Crowdin TM-poisoned translations for them, tripping ExtraTranslation
in lintVitalGplayBeta/Release.
2026-08-16 20:17:47 +02:00
darken 02ec76980c General: Update app translations from Crowdin 2026-08-16 19:52:33 +02:00
darken 52ad90f77e General: Update fastlane translations from Crowdin 2026-08-16 19:39:06 +02:00
darken 563c6e71f7 fix(upgrade): Invalidate a dead billing connection from refreshStrict too
refreshStrict() throws its incomplete-result error AFTER useConnection
already returned: refreshPurchases hands back a partial result instead of
throwing, so useConnection's dead-binder detection never sees it. A gate
that ran against a connection whose binder died mid-query (partialError
cause chain carrying SERVICE_DISCONNECTED / SERVICE_TIMEOUT) left that
connection installed, and every later purchase check kept talking to the
corpse until something else tore it down.

processReconciliation() already compensated for the refresh()/connect-loop
paths; the strict path had no such call. Extract that dead-connection block
into invalidateOnDeadConnection() and call it from both. The strict path
deliberately does NOT feed the episode clock — a gate the user aborted
mid-purchase is not a reconciliation outcome.
2026-08-16 17:44:02 +02:00
darken a357d77b00 test(upgrade): Cover pending purchases across the billing stack and screen
Ports the pending-purchase coverage alongside the production change.

- BillingConnectionTest / BillingManagerTest: PENDING ingestion, the
  PURCHASED-only entitlement exits, provesAbsence ignoring a surviving
  pending overlay, the reconciliation pass and the ack pass skipping
  pending purchases.
- UpgradeRepoGplayTest: pendingSkus never feeding isPro, the strict
  verify path and PendingPurchaseBillingException on an already-owned
  recovery.
- GplayUpgradeViewModelTest: the shared pre-purchase gate on both paths
  (pending, timeout, error, owned upgrade, renewing subscription with an
  unknown product), the pending-payment launch failure, and the pending
  card rendering while prices are still loading or have failed.
- GplayUpgradeScreenTest / GplayUpgradeOwnershipTest /
  GplayUpgradeScreenHostTest: the card for all three audiences, the
  locked offers and switch button, restore staying enabled, and the
  informational dialog reaching the composition.
2026-08-16 17:44:02 +02:00
darken abc82b0f09 fix(upgrade): Show supporter status on every upgrade screen route
The FOSS upgrade screen's view mapping only surfaced STATUS_UPGRADED on
the manage route. Forced routes (the Pro-locked settings entry)
deliberately don't auto-close, so a supporter completing the sponsor
flow from there stayed on the sales pitch with a live sponsor button --
reading as if sponsoring didn't work, with only the transient thanks
toast saying otherwise. The gplay flavour already renders ownership
state route-independently on this path.

The isPro branch now wins on every route, matching the adjacent
comment's stated intent and gplay's behaviour. Forced routes keep their
don't-auto-close semantics; the durable status view is what
acknowledges the upgrade.
2026-08-16 17:44:02 +02:00
darken 3e6541deec feat(upgrade): Explain a pending Google Play payment on the upgrade screen
A user whose payment Play is still processing now gets an explanation
instead of a screen that keeps selling them the upgrade they already
bought.

- New PendingPurchaseCard, rendered above the ownership/acquisition split
  so it reaches every audience: the acquisition buyer, the owner switching
  products, and the grace user (whose offers box is hidden entirely during
  a young episode) — all three have their purchase actions locked and need
  the same explanation.
- Loaded.hasPendingPurchase is SKU-agnostic: the subscription and the
  one-time purchase are alternatives, so a pending payment for either one
  disables both offers and the ownership switch button. Restore stays
  enabled — re-checking with Play is the useful action here.
- Both purchase paths now run the same pre-purchase gate
  (verifyPurchaseStateNow, bounded): a pending payment answers with the
  informational PurchasePending dialog instead of launching a flow Play
  would reject. The subscription path previously launched unverified, and
  its gate also blocks on an owned upgrade (RestoreSucceeded) and on a
  still-renewing subscription (SubscriptionStillRenewing), so a stale
  screen can't sell Pro to someone who already owns it.
- restorePurchase() reports a found-but-unpaid purchase as PurchasePending
  rather than RestoreFailed, whose copy asserts a completed check and
  steers toward multi-account troubleshooting and support.
- A PendingPurchaseBillingException from a launch maps to the same dialog.
- SubscriptionCheckFailed becomes PurchaseCheckFailed (both paths use it
  now); its string is replaced by
  upgrade_screen_purchase_check_failed_message and the translated entries
  of the old key are removed, along with the already-orphaned
  upgrade_screen_sub_check_failed_title, so ExtraTranslation lint stays
  quiet.
2026-08-16 17:44:02 +02:00
darken 33a972001e feat(upgrade): Carry pending Google Play purchases through the billing stack
Play reports a purchase as PENDING while a slow payment method (cash,
carrier billing, bank transfer) is still being processed. Until now
BillingConnection dropped those at ingestion, so the app had no idea the
user had bought anything: the upgrade screen kept selling, and a second
purchase attempt was rejected by Play with ITEM_ALREADY_OWNED.

Pending purchases now enter the reducer state and travel to the UI, while
every entitlement exit stays PURCHASED-only:

- BillingConnection ingests PURCHASED + PENDING (UNSPECIFIED_STATE is
  still dropped everywhere). The freshUpdates stream keeps receiving only
  PURCHASED, and provesAbsence now ignores a surviving PENDING overlay
  entry, so a payment in progress can't freeze the unconfirmed-episode
  clock.
- combinePurchaseResults gets the sku-type resolver: a PENDING result only
  suppresses the couldn't-verify error when it maps to a known Pro SKU. An
  unknown pending product proves nothing about the type whose query failed.
- PurchaseRefresh now carries provenance (confirmed set,
  hasConfirmedProPurchase, commit-time occurredAt, partialError) instead of
  just the merged view plus isComplete.
- BillingData splits into purchases (entitlement carrier) and
  pendingPurchases via a single from() classifier used at every exit.
- BillingManager gains processReconciliation(), run after the connect
  loop's initial refresh and by refresh(): it re-signals dead-binder
  invalidation and feeds the grace episode clock with the refresh's COMMIT
  time. The ack pass skips pending purchases, which Play rejects
  permanently and would report as a bug every pass.
- BillingConnection.querySubscriptions / BillingManager.querySubscriptions
  are replaced by refreshStrict(): the pre-purchase gate needs both product
  types and the pending state, and still fails closed on anything short of
  a complete round-trip.
- UpgradeRepoGplay exposes Info.pendingSkus (never part of isPro),
  Info.hasAutoRenewingSubscription, verifyPurchaseStateNow() for the gates,
  and reports PendingPurchaseBillingException when an already-owned
  recovery finds a pending payment. The grace branch now carries
  billingData through so pending stays visible while Pro runs on grace.
2026-08-16 17:44:02 +02:00
Matthias Urhahn fe67ce0365 Shrink issue screenshots uploaded as HTML img tags 2026-08-11 14:43:10 +02:00
darken 511944c21c fix(upgrade): Share the settings row title with the upgrade screen
settings_upgrade_status_label lived twice per flavor: once for the
Settings row, once as a raw locale-translated string standing in for
what the upgrade screen otherwise composes from the app name and the
tier qualifier. On gplay the two could drift apart -- 15 of 75
locales showed the wrong word order or an untranslated English
fallback where the composed title used the correct language. The row
now composes through the same brandTitle template gplay's own
upgrade screen title already used, so the two can no longer disagree.

FOSS's value was a support ask ("Sponsor CAPod"), not a composed
brand title, so its wording stays untouched -- the key is renamed to
upgrade_foss_sponsor_label (byte-identical text, all 75 locales) and
both FOSS call sites read that one resource instead.
2026-08-07 22:16:00 +02:00
d4rken-org-releaser[bot] 007282d3f5 Release: 5.2.2-rc1 2026-08-07 18:28:44 +00:00
darken 5ffdf66b9d Fix release builds failing on a missing Play Core annotation
R8 reported com.google.android.gms.common.annotation.NoNullnessRewrite as missing; it is a compile-time-only annotation referenced by review-ktx and absent from the runtime classpath. Adds the same targeted -dontwarn SD Maid SE already uses.
2026-08-07 19:30:15 +02:00
d4rken-org-releaser[bot] 7e90d115e0 Release: 5.2.2-rc0 2026-08-07 16:58:23 +00:00
darken ffc3ffb718 test(upgrade): Sweep locales in both flavours now the template is shared
The template moved to main, so FOSS resolves per-language arrangements
too and composes reordered titles for the first time. Running the sweep
only for gplay would leave that path unguarded.
2026-08-07 18:27:33 +02:00
darken 6b358cb101 refactor(upgrade): Move the title template out of the flavour source sets
Arrangement is a property of the language, not the flavour, so the key
now lives once in main alongside app_name. The qualifier stays
flavour-specific: only the word order and punctuation move.

FOSS consequently inherits each language's arrangement instead of being
frozen at the default order.
2026-08-07 18:27:33 +02:00
darken 7cbcde50d3 General: Update app translations from Crowdin 2026-08-07 18:27:33 +02:00
darken 5365a5da29 chore(upgrade): Add the title template to main ahead of the flavour move
Phase 1 of the Crowdin migration: the flavour copies still override this
for their own builds, so behaviour is unchanged while translations are
gathered against the new source string.
2026-08-07 18:27:33 +02:00
darken 908acc4ea0 test(upgrade): Assert the splice honoured the template, not just its output
The locale sweep checked format specifiers with a regex that does not know
%<s, which reuses the previous argument and so emits the qualifier twice.
That damages the template, triggers the fallback, and still satisfies every
output assertion because a fallback title also carries one correctly styled
qualifier. Both checks now run against the formatter's own output.

Also pins the highlight colour: it is a parameter because the toolbar tints
FOSS and Pro differently, so hardcoding it back would have stayed green.
2026-08-07 16:51:44 +02:00
darken 5a635b424b fix(upgrade): Retire the composed app_name_pro and app_name_foss keys
Both are now derived: the title is composed from app_name and the
flavour's upgrade_badge_label through app_name_upgraded_template, so the
pre-composed strings have no remaining reader.
2026-08-07 16:51:44 +02:00
darken f1e4ffb099 General: Update app translations from Crowdin 2026-08-07 16:51:44 +02:00
darken d012799d49 fix(upgrade): Compose the Pro title from a translatable template
The composed app_name_pro was split on spaces and only styled when it
produced exactly two tokens. Arabic has four (kabud en-dash two-word
qualifier) and lost its branding entirely; Estonian puts the qualifier
first and so passed the guard while highlighting the brand instead.

Titles are now built from a per-flavour app_name_upgraded_template with
the app name and the tier qualifier as placeholders, so translators own
word order and punctuation.
2026-08-07 16:51:44 +02:00
darken 06fd256889 fix(upgrade): Show a failed error-dialog fix action inline instead of a toast
The Google Play launch-failure message was shown via Toast, which Android caps
at 2 lines: English lost the trailing "device.", French was cut mid-word and
lost an entire condition. The strings are fine, the container was wrong.

The fix action now rethrows after logging, the failure reaches the dialog, and
the dialog renders the message inline while staying open. The dismiss button
stays available, so the dialog is never latched.

The message is passed per dispatch rather than read from the LocalizedError, so
no future action button can surface the fix action's failure copy. The inline
state is keyed on the throwable, not the LocalizedError, which is rebuilt with
fresh action lambdas on every recomposition.
2026-08-07 09:49:57 +02:00
darken 2f715ccac9 fix(upgrade): Report empty Play product results as a merchandising error
When both the IAP and SUB queries came back with nothing, the screen always
reported a connectivity failure, telling users to clear Play's cache and reboot.
Play can answer OK and simply have no sellable offer (region, account
eligibility, pulled product), where that advice is futile.

Both causes are now inspected: only when BOTH are OfferUnavailableBillingException
does the merchandising copy surface. A single non-merchandising failure can't
rule out a real Play problem, so the conservative copy stays.
2026-08-07 09:49:57 +02:00
darken 4dba593c13 General: Update app translations from Crowdin 2026-08-06 13:07:28 +02:00
darken ed5ec8152b General: Update app translations from Crowdin 2026-08-06 13:07:28 +02:00
darken 11232e9169 fix(overview): Reset the review card latch when it leaves the list
The card is a keyed item in the overview's lazy list, which restores the
saveable state of removed items when they come back. A review tap left
the dismiss action permanently disabled once a higher priority card took
the slot and gave it back, and a dismissed card returned fully dead.

Fixes review finding F1.
2026-08-06 09:55:09 +02:00
darken 19976c89f6 test(review): Cover the probe quirks and the card latch
Adds timing, caching and boundary coverage for the review tool plus the
card's latch matrix. The existing both-actions card test is superseded:
its dismiss-then-review sequence is exactly what the latch blocks.
2026-08-06 09:55:09 +02:00
darken 323b10224c fix(overview): Latch the review card's actions after a tap
The card stays up until the next state emission, so a dismiss after a
review would overwrite the review bookkeeping with a snooze and a review
after a dismiss would re-open what was just closed. Repeated review taps
stay allowed so a failed Play request can still be retried.
2026-08-06 09:55:09 +02:00
darken 39b8fcb8f8 fix(review): Harden the Play review probe and tap path
Timeouts on all three Play calls, a dismiss generation backstop for the
tap race, and a 3-state probe verdict that caches Play's definitive
answers for the process, retries transient failures on a bounded budget,
and re-evaluates eligibility at the snooze and pro-grace boundaries.
2026-08-06 09:55:09 +02:00
darken 5ef3965a0d General: Update app translations from Crowdin 2026-08-05 16:42:36 +02:00
darken 1e1f0b13c6 fix(overview): Keep the review prompt from crashing or stacking
Corrupt review settings terminated the shared state flow on AppScope,
so the exception crashed the process instead of reaching the ViewModel's
catch. Absorb it upstream of both replayingShare calls.

The review card also no longer stacks on top of the enable-Bluetooth
prompt.

Fixes review findings F1, F2
2026-08-05 16:27:13 +02:00
darken 7716221053 feat(overview): Cover the review prompt with unit tests
Pins the Play review tool's eligibility gate, probe retries, single-flight
guard and cancellation handling, the DataStore round trip of the review
timestamps, the overview's card priority gate and the card itself.
2026-08-05 16:27:13 +02:00
darken 26707dc0e5 feat(overview): Ask happy users for a Play review
Adds a review prompt card to the overview. On Google Play it uses the
in-app review flow, gated on the user having been Pro for a while, not
having dismissed it recently and not having reviewed yet. FOSS gets a
no-op implementation.

The card is the lowest priority item on the overview and stays hidden
while a permission, troubleshooter, background-monitoring-off or
no-profiles card is on screen.
2026-08-05 16:27:13 +02:00
Matthias Urhahn e8b7f73c6b Add shared screenshot thumbnail workflow (#666)
Shrinks oversized screenshots posted in issues and comments into clickable
thumbnails. Implementation shared from d4rken-org/.github; this stub only
supplies the triggers, because workflow_call cannot be driven by issue_comment
directly. Pinned by commit SHA rather than a moving tag.
2026-08-05 12:59:40 +02:00
darken 442f6bcc88 fix: Correct the Google Play launch-failure toast
The toast claimed Google Play was not installed for every failed launch,
but the SecurityException path means Play is installed and merely disabled
or profile-restricted - telling those users to install an app they already
have. Neutral wording covers both cases; the resource key is unchanged (no
translations exist yet).
2026-08-05 11:26:02 +02:00
darken e7cb53f0b8 fix: Harden the error dialog's Google Play fix action
The dialog's fix dispatch ran unguarded: a throwing action crashed the UI
thread from inside a click handler and skipped onDismiss(), leaving the
dialog latched on the current error. The dispatch is now wrapped in
try/catch with onDismiss() in a finally block.

Google Play fix action:
- Drop FLAG_ACTIVITY_NEW_TASK. The action runs on an activity context, so
  the flag only detached Play's app info from the caller's task and back
  stack.
- Catch SecurityException next to ActivityNotFoundException: Play can be
  installed but blocked (disabled app, restricted profile, guarding ROM),
  which denies the launch instead of failing to resolve it.
- The fallback toast is now a translatable string resource instead of a
  hardcoded literal.

New coverage: ComposeErrorDialogGuardTest pins that a throwing fix action
still dismisses the dialog (shared source set, so both flavors run it),
GplayFixActionTest pins the denied and unresolvable launches showing a
toast instead of crashing, and ComposeErrorDialogTest now asserts the
launch intent carries no NEW_TASK flag.
2026-08-04 22:01:26 +02:00
darken ed940b3b3d fix(debug): Stop a failed start from erasing a resumed recording
Two rollback defects found reviewing the failed-start handling, both cases
where cleaning up after a start that could not finish damaged something it
did not own:

- FileLogger.start() deletes the log file only when that same call created
  it, and reports the failure instead of swallowing it. A resumed session
  appends to the previous recording's core.log, and a failed append used to
  erase it while telling the recorder the start had succeeded. The writer is
  published only once it is usable, so a failed attempt leaves nothing behind
  that would make a later start() a no-op (F3).
- The module's rollback skips self-suppression: a recorder broken in one way
  throws the same instance on the start line and again when the rollback
  stops it, and addSuppressed(self) raises IllegalArgumentException — which
  aborted the rollback before the failure state was committed and took the
  shared state collector with it (F4).

Recorder.start() is the only production caller of FileLogger.start(), and it
runs inside the module's whole-branch guard, so the new throw lands in the
rollback rather than escaping.

Fixes review findings F3, F4
2026-08-04 18:26:47 +02:00
darken c696e523a9 fix(debug): Hold off orphan zipping while a start is in flight
A start commits its session dir into the recorder state only once the
recorder is live. For the whole window before that, shouldRecord is set
but isRecording is not, so a scan sees a directory with a non-empty
core.log and no sibling zip - an orphan - and the manager's auto-zip
compresses the directory the recorder is writing into. When the start
then fails, the rollback deletion races that zipper, and the archive
left behind defeats the collision check in createSessionDir: the retry
reuses the session ID of the attempt that just died.

The scan and the recorder state it was taken against now travel as one
value, so the reconciliation can tell a pending start apart from a
settled one and defer new zips until the state is terminal. Running
zips are untouched. A sibling '.zip' or '.zip.tmp' now counts as a name
collision as well, since the session ID is derived from that name.

Fixes review finding F1.
2026-08-04 18:26:47 +02:00
darken f9327930b5 fix(debug): Keep the recorder usable when a recording fails to start
Starting a recording spans several steps — create the session directory,
start the recorder, persist the trigger file, write the header — and only
the last of them commits the recorder into the module's state. Anything
throwing inside that window escaped the reactive collector, which then
died for the rest of the process: the started recorder kept writing where
nothing could stop it, the trigger file survived to re-attempt the dead
session on every launch, and startRecorder() waited forever for a state
nobody would publish. The debug log toggle stayed dead until reinstall.

The whole start branch is now guarded. A failure rolls back first — stop
the recorder, clear the log dir mirror, remove the trigger file, delete a
session dir this attempt created — and only then decides what the failure
means: our own scope dying still takes the collector with it, anything
else (a cancellation from inside the start work included) is committed as
a start failure and surfaced to the caller. shouldRecord is reset with it,
so the every-state collector lands in the idle branch instead of retrying.

The stop branch gets the same treatment: a recorder that cannot stop is
logged and the cleared state committed anyway, so an awaiting stop
completes. Recorder.stop() itself now guarantees logger removal, writer
closure and reference clearing. Session directory names get a collision
suffix, since a same-second retry would otherwise share a directory with
the attempt it replaces, and the public start/stop entry points are
serialized so two callers cannot race the same transition.
2026-08-04 18:26:47 +02:00
darken e7ee126f7e refactor(upgrade): Drop the superseded upgrade preamble card
The hero card took over both call sites (FOSS pitch, GPLAY acquisition), so
the standalone preamble card had no callers left.

Fixes review finding F1.
2026-08-03 20:28:01 +02:00
darken ed5e5ba7e1 fix(upgrade): Record the last known entitlement upstream of the flatMapLatest buffer
The tracking onEach sat downstream of flatMapLatest, so its channel buffer
could hold the Pro emission while the inner flow already threw: the catch
then read a null last-known state and emitted a non-Pro error Info, kicking
a supporter back to the pitch. Tracking now runs in the same coroutine as
the throw.
2026-08-03 20:28:01 +02:00
darken 2dbe86ad5e test(error): Emit through the error flow in the dialog test
The fake source was asked to emit directly; SingleEventFlow's emitters live
on the errorEvents flow itself, the way the ViewModels use them.
2026-08-03 20:28:01 +02:00
darken 6fcc2c4348 fix(upgrade): Theme the upgrade retry button for its error card
The retry sits inside the errorContainer card but drew itself with the
default primary-on-surface outlined colors, which clashes with the card and
loses contrast once the tap latch disables it. Content and border now follow
onErrorContainer, with a dimmed disabled pair, and both states get a
preview.
2026-08-03 20:28:01 +02:00
darken 375af943ae fix(error): Let the error dialog dismiss instead of only acknowledging
LocalizedError can now carry a fix action, and the shared error dialog shows
it next to a Dismiss button when one is present; errors without a fix keep
today's OK-only shape. The Google Play billing-unavailable error uses it to
open Play's app info as a generic troubleshooting affordance.
2026-08-03 20:28:01 +02:00
darken 65f7cbe8b7 fix(upgrade): Color the brand inside the gplay upgrade pitch title
The acquisition top bar reused the widgets' flat upgrade label, so the Pro
postfix stayed uncolored while the owned/grace title highlighted it. The
title is now a gplay-only template that takes the composed brand as a
placeholder, spliced back in as the same styled AnnotatedString the status
title uses — word order stays the translation's business. The widget label
keeps its own key and its own wording.
2026-08-03 20:28:01 +02:00
darken 2833639892 feat(upgrade): Merge upgrade screen mascot and preamble into one hero card
Adds UpgradeHeroCard, which pairs the mascot with the preamble copy inside a
single ElevatedCard and stacks them once the copy runs out of room. Used by
the FOSS pitch view and the GPLAY acquisition view; grace episodes and the
FOSS status views keep their standalone header, which has no preamble to
pair with. Screen tests pin the hero's presence and absence per state.
2026-08-03 20:28:01 +02:00
darken 495f58c23b fix(upgrade): Align the round-4 restore, recorder sentinel and contention test
Restore the consumed sponsor marker only when no newer launch was armed
meanwhile. Make the recorder's monotonic base a nullable sentinel so a
boot-adjacent elapsedRealtime of 0 is not mistaken for a resumed session,
and clear it on stop. Pin the contention test's collector subscription and
assert it actually echoed.
2026-08-03 20:28:01 +02:00
darken 9b60ed945f fix(upgrade): Settle FOSS entitlement errors instead of hanging
A thrown cache read used to die inside shareIn's sharing coroutine, leaving
every collector waiting forever. Catch inside flatMapLatest, keep the last
known entitlement on late failures, and let a successful persist revive an
error-stuck inner flow.
2026-08-03 20:28:01 +02:00
darken b77f4a9581 fix(flow): Subscribe before emitting in DynamicStateFlow.updateBlocking
updateBlocking emitted its update into updateActions FIRST and only then
subscribed internalFlow.first { it.updatedBy == update } against a
shareIn(replay = 1). That is a lost wakeup: if the producer processes our
update plus a successor before the first{} collector is registered, our
identity-matched State is displaced from the replay-1 cache and the await
never completes. A reactive collector makes the successor guaranteed rather
than unlikely - RecorderModule reacts to every state with an update of its
own, so each caller's update immediately breeds another.

The trigger is scheduling pressure: on a 2-core machine the producer and the
awaiting caller no longer run in parallel, so the emit-to-subscribe window is
wide. The CI test jobs wedged at GitHub's 6h job timeout; reproduced locally
under taskset -c 0,1 and confirmed by jstack - the caller parked forever in
updateBlocking's first{} while the producer sat idle with the update long
since processed.

The awaiter is now started UNDISPATCHED before the emit, so it runs
synchronously up to its first suspension inside first's collect and the
collector exists on the shared flow before the update can be processed.

Covered by a contention test that mirrors the module interaction (two
concurrent updateBlocking workers plus a value-neutral reactive echo
collector), and the two recorder test harnesses now wrap their block and
their cleanup stop in timeouts - a regressed await has to fail in seconds
instead of wedging a runner for six hours.
2026-08-03 08:30:33 +02:00
darken 91dafe8a04 test(debug): Stop leaked recorders in the recorder-module test harness
The realtime harness cancelled its module scope but never stopped the
recorder, and cancelling a scope does not uninstall a running recorder's
globally installed FileLogger. A test that started a recording therefore left
one writing into every test that followed, and an assertion failing before the
explicit stop did the same.

The harness now stops the module in a nested finally and fails its own test if
a file logger survived, removing the straggler afterwards so a single leak
cannot cascade. The tracked-recording test gets the same finally treatment.
2026-08-03 08:30:33 +02:00
darken 1dfed1c21c fix(debug): Make the short-recording warning clock-change safe and raise it to 10s
The "that recording looks very short" prompt measured duration against the
wall clock, so any adjustment mid-recording decided it: an NTP sync or a
manual clock change moving forward made a three-second recording look like an
hour and skipped the prompt, moving backward trapped a long recording in it
with no way past but "stop anyway".

A live session now measures from a monotonic base taken at the start. Only a
session resumed from the trigger file still uses the persisted wall time -
that file has to survive reboots, which monotonic time does not - and a
negative duration there fails open instead of warning.

The threshold moves from 5s to 10s: a recording stopped that quickly holds
nothing but the recorder starting and stopping, which costs a support
round-trip to re-request. It stays a prompt, not a block, because a crash is
logged and flushed immediately.
2026-08-03 08:30:33 +02:00
darken 26633b940d fix(upgrade): Make the FOSS supporter persist create-only-if-absent
persistUpgrade() wrote an unconditional record, so any sponsor return that
got past the ViewModel's isPro guard replaced an existing supporter's
upgradedAt - the "supporter since" date the status screen shows - and, for
the legacy records every existing supporter has, their stored reason too.
That guard reads a shareIn replay and can be stale, so it is not a race-free
answer on its own.

The write now happens inside the store transaction: an existing record is
kept and reported back, only an absent one is created. The ViewModel thanks
the user only for an actual unlock and stays quiet otherwise. It also no
longer eats the sponsor visit when something fails - a failed entitlement
read or a failed write restores the pending-launch marker so the next return
can retry, while the error still travels the normal path.

FossCache gains the same constructor test seam BillingCache has, so the
transaction can be exercised against a real DataStore on a temp file.
2026-08-03 08:30:33 +02:00
darken d98c118f37 test(overview): Cover the background-monitoring-off signal
Adds monitoringStatus decision tests to OverviewViewModelTest plus Compose
tests for the new dashboard card and the missing-paired-device banner.
2026-08-02 14:51:43 +02:00
darken 8f0ec5a374 fix(overview): Signal when background monitoring is off
An auto-created profile without a paired Bluetooth device resolves to MANUAL
mode, so nothing runs in the background while the dashboard claimed to be
monitoring. The dashboard now states that background monitoring is off and
offers to pick a paired device, and the per-card banner names what a missing
paired device costs.

Closes #658
2026-08-02 14:51:43 +02:00
darken 53b4150ff6 fix(ui): Stop translating the flavor brand labels
"FOSS" is the flavor's name, not prose. Locale copies of the FOSS badge
and of the composed "CAPod FOSS" title had drifted - some translated the
word, one Thai entry into an unrelated word entirely - all of it shown as
the app's own brand.

Both base entries are now translatable="false" and every locale copy is
gone; with the flag set, any remaining locale entry would trip a fatal
ExtraTranslation in the release lint. The GPlay "Pro" badge is prose and
stays translated.
2026-08-02 12:40:32 +02:00
darken 6b2536c74f fix(debug): Keep debug recording available when diagnostics hang
The debug log header read the flavor's upgrade diagnostics unbounded. A
wedged source (a stuck DataStore file lock, a billing store that never
answers) left the recorder started but never committed, so the user asking
for a log got nothing at exactly the moment the app was misbehaving.

The read now runs under a deadline: a source that hangs or fails degrades
to "unavailable" and the recording starts. Completion is tracked
separately from the value, so a flavor that legitimately has nothing to
report (FOSS) still logs no line at all instead of claiming a failure.
Cancellation is unchanged: an outer scope death still rolls the
uncommitted recorder back.

The GPlay diagnostics' pro-history read gets the same bound its billing
cache read already had.
2026-08-02 12:40:32 +02:00
darken 72fb1d3b17 fix(upgrade): Never let a failed cache stamp abort entitlement bookkeeping
stampLastProState() was bounded against a wedged file lock, but a write
that failed outright (corrupt preferences file, no disk space) still threw
straight through into the entitlement path it only decorates.

Non-cancellation exceptions from the edit now log a warning and skip the
stamp, same as the timeout does. Cancellation keeps propagating - caught
first on purpose, swallowing it would break the caller's structured
concurrency. Reads stay loud: a snapshot that couldn't be read must not
be mistaken for a never-bought install.
2026-08-02 12:40:32 +02:00
darken 31c3b47bcd fix(upgrade): Only count a sponsor visit when the page actually opened
The sponsor unlock heuristic armed itself on every tap, regardless of
whether a browser ever opened the page. An unrelated later background
round-trip could then hand out supporter status with no page ever shown.

WebpageTool.open() now reports whether an activity was actually started
and the FOSS repo passes that through synchronously, so the ViewModel can
only arm after a successful launch. A second tap while a launch is still
pending is ignored, and the upgraded status view's donate button gets its
own unarmed entry point - an existing supporter has nothing left to
unlock, and re-persisting would rewrite their "supporter since" date.
2026-08-02 12:40:32 +02:00
Matthias Urhahn 7cb31ce783 Merge pull request #656 from d4rken-org/worktree-fix-unknown-notification
Fix: Ongoing notification stuck showing "Unknown"
2026-07-30 14:30:33 +02:00
darken 2f69090bc2 fix(monitor): Invalidate the notification cache at session launch 2026-07-30 13:23:49 +02:00
darken 0c39ca89c8 fix(upgrade): Show the supporter-since date on the FOSS status screen
The upgraded status now renders the date the supporter unlocked, derived in the
same emission as the view so the screen never shows the status without the date
it is supposed to carry.

Guards that date: a return from the recurring-donation button no longer runs
persistUpgrade() when the install is already Pro, which would have rewritten
upgradedAt and visibly reset the displayed date. The sponsor-return tracker is
seeded from the handle-backed pending launch so a process death while the
sponsor page is in front does not swallow the first return.

The status views are titled "CAPod FOSS" instead of "CAPod Pro" -- on FOSS the
flavor name is the brand.
2026-07-30 12:55:31 +02:00
darken 10317b373f fix(upgrade): Polish the GPlay offers-unavailable card
The card reports that PRICES could not be loaded, so it now says so instead
of borrowing the generic "Google Play services are unavailable" title, which
contradicted its own body.

The retry latches after the first tap: the guard sits inside onClick because
`enabled` only takes effect after recomposition, so two taps in the same frame
would both fire. It resets naturally when the card leaves composition.

Returning to the screen re-runs the SKU query when it is in the unavailable
state. MainActivity's per-resume refresh only covers the entitlement, so a
transient Play outage left the retry card up until it was tapped by hand.
2026-07-30 12:55:31 +02:00
darken 7fb1f7aabd fix(debug): Bound the billing cache and fold pro history into diagnostics
BillingCache reads and writes are now bounded by a timeout seam: a wedged
DataStore file lock made the debug-log header hang, and a silent fallback to
the default snapshot would have reported "never bought" for an install whose
evidence merely could not be read. Reads now fail loudly, writes fail soft.

UpgradeDiagnosticsGplay absorbs the pro-state history that the recorder header
used to read directly, with a separate failure boundary per source so one
broken DataStore cannot suppress the other's evidence.

RecorderModule's start-failure guard now covers ordinary exceptions, not just
cancellation, stops the uncommitted recorder under NonCancellable and appears
once instead of per resume branch.
2026-07-30 12:55:31 +02:00
darken 54d3c9d824 fix(monitor): Stop re-promoting a stale notification into a new session 2026-07-30 12:47:23 +02:00
darken b616696a41 fix(monitor): Retract the stale ongoing notification on teardown 2026-07-29 20:09:12 +02:00
darken e364a5b02c fix(upgrade): Cover widget entry refresh and recorder edge cases
- WidgetConfigurationActivity refreshes the entitlement on resume: it is a
  second launcher entry point and can't rely on MainActivity reconciling.
- The upgrade-return callback re-asks decideConfirm() instead of trusting the
  upgrade activity's result code, so RESULT_OK stays entitlement-gated.
- RecorderModule stops the freshly started recorder when the header's
  diagnostics reads are cancelled, instead of leaking an untracked recording.
- FOSS beta channel points at the GitHub releases page; the Play testing URL
  is signature-incompatible for FOSS builds.
- Billing bug reports carry the contextual wrapper again, so the report is
  grouped by call site instead of the raw billing exception.

Fixes review findings F1, F2, F3, F4, F5.
2026-07-29 14:05:26 +02:00
darken 3651bb3d55 refactor(upgrade): Converge GPlay billing on the canonical stack
Replaces capod's older billing core, upgrade UI and their tests with the
canonical sdmaid-se stack at the pinned revision.

Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on
billing 8.3 with the centralized connect loop, merging purchases-listener
overlay and the canonical ack pipeline; the dying ack collector, the
ackedTokens gate and the in-billing foreground loop are gone. Full canonical
exception set (internal/network/offer-unavailable added), OurSku with capod's
product ids, BillingCache with snapshot()/episode-guarded stampLastProState.

FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API
surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter
records must keep decoding.

Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by
RecorderModule next to CurriculumVitae's Pro history as two independent,
isolated header reads.

UI: canonical upgrade screens for both flavors under common/upgrade/ui with
capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod
previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a
per-resume, unthrottled MainActivity call.

Strings reuse capod's existing translated ids wherever equivalent; only
referenced-but-missing ones are authored.

mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value
while recording, which the ported restore tests need.
2026-07-29 14:05:26 +02:00
darken 0192ae7081 feat(core): Add Pro-state history and safe state collection helpers
Additive infrastructure for the canonical billing port, no coupling to the
billing core yet.

- CurriculumVitae: Pro-state slice only (ProState, ProHistory,
  updateProState, proHistory, transition classification, tolerant enum
  decode). Raw preference keys so a transition updates state, counter and
  timestamp in one DataStore transaction.
- ViewModel4.safeStateIn: render-state flows forward recoverable failures
  to errorEvents and emit an explicit fallback state instead of throwing
  into collectAsStateWithLifecycle().
- testhelpers: TestApplication, BaseComposeRobolectricTest and the
  mockDataStoreValue helper.
2026-07-29 14:05:26 +02:00
darken 8c1b57a47c refactor(upgrade): Adopt canonical entitlement interface and gates
UpgradeRepo gains the canonical shape: settledness rides each Info
emission, plus storeSite/upgradeSite/betaSite and a suspend refresh().
getSponsorUrl() is replaced by upgradeSite (FOSS only, GPlay keeps the
heart icon hidden). UpgradeRepoExtensions is the canonical file with
isPro/isProSettled/isProForUi.

UpgradeRepoGplay folds its parallel isSettled flow into Info.isSettled
(behaviour preserving) and implements refresh() as a bounded, unthrottled
call to the existing billing refresh. UpgradeControlFoss is settled from
its first emission and no-ops refresh().

Interactive gates move to isProForUi so a paying user isn't bounced to
the upgrade screen during the GPlay cold-start race: the device-settings
and press-controls pro gates, the theme setters, and the widget confirm
action, which now goes through a sealed ConfirmOutcome so the activity
can only return RESULT_OK for an entitled, valid configuration.
Presentation paths that can't reach a suspending gate (general settings
theme items, overview device limit) render the upgrade branch only when
the entitlement is hard-locked: settled, error-free and not pro.
2026-07-29 14:05:26 +02:00
darken fb48ff9d43 chore(claude): Enable google-play and devtools plugins 2026-07-29 13:11:55 +02:00
darken 2d604c1e6a fix(reaction): Serialize pause arming behind queued snapshots 2026-07-28 23:52:04 +02:00
darken 5e28eeed38 fix(reaction): Serialize media key dispatch and read playback snapshots
Two pre-existing concurrency defects in MediaControl (#647).

Lost update: sendPlay() wrote capPaused after the suspending sendKey(),
whose delay(100) is a window in which a concurrent
sendPause(rememberForResume = true) could arm the flag only to have it
overwritten. Stem presses run on the app scope while ear, sleep and
conversation reactions run on the monitor scope, so the senders really
do race. The compound check/dispatch/flag sequence now runs under a
Mutex, the flag is cleared before the first suspension, and the key
pair completes under NonCancellable so cancellation cannot strand an
unpaired DOWN event.

Coalescing blind spot: the playback callback ignored its configs
argument and read live isMusicActive, so queued deliveries all observed
the newest state and an inactive to active edge in between was never
seen, leaving capPaused stale. The edge is now derived from the
delivered snapshot. A pause that passes the live active check records
that observation so an already-queued music-start snapshot cannot drain
later and read as a fresh edge.
2026-07-28 23:52:04 +02:00
darken edd3cf0c2b ui(monitor): Give the early service notification a content text 2026-07-28 23:51:46 +02:00
darken 9f322f0a3f test(monitor): Cover foreground re-promotion and start-rejection logging 2026-07-28 23:51:46 +02:00
darken de287fcb66 fix(monitor): Log FGS start rejections distinctly with full stack 2026-07-28 23:51:46 +02:00
darken 28ad09e96d fix: Stop suppressing foreground service timing exceptions
Suppressing ForegroundServiceDidNotStartInTimeException and re-entering
Looper.loop() left zombie processes behind that kept collecting ANRs.
Always delegate to the previous handler instead.
2026-07-28 23:51:46 +02:00
darken 8f71af8c5d fix(monitor): Re-satisfy foreground obligation on every start command
Every startForegroundService() re-arms the 10s startForeground() deadline,
even when the service is already foreground. The service only promoted in
onCreate(), so repeated start requests could time out and ANR.
2026-07-28 23:51:46 +02:00
darken 305a6d6c2a docs(aap): Warn that 0x0001 closes the stream mid-session 2026-07-28 20:18:54 +02:00
darken 26b04854c4 chore(claude): Document AAP protocol landmines and add scratch dir 2026-07-28 20:18:54 +02:00
darken 7fc13ac2c5 chore(claude): Correct modifier-position claim in code style rule 2026-07-28 20:18:54 +02:00
darken 7c75ec02bd chore(claude): Port code-style, test gotchas, and PR labels from sdmaid-se 2026-07-28 20:18:54 +02:00
darken f39ea20690 chore(claude): Restore always-on release guardrails 2026-07-28 20:18:54 +02:00
darken b573a79582 chore(claude): Path-scope rules and align with Opus 5 guidance 2026-07-28 20:18:54 +02:00
darken 82cc9764e5 fix(logging): Install logger before Hilt injection
super.onCreate() triggers Hilt's singleton construction, so any log emitted
during that graph build was discarded before the logger existed.

Fixes review finding F2
2026-07-28 19:07:54 +02:00
darken c9a5db1ac8 test(mediacontrol): Name handler registration test after what it asserts
The test claimed to guard against a main-looper binding, but the
assertion only checks that the injected handler instance is forwarded.
Rename it and document that the Looper identity is covered by the
AndroidModule provider and the runtime thread-name QA check instead.

Fixes review finding F1
2026-07-28 19:07:54 +02:00
darken a95377cf7b fix(media): Move audio playback callback off the main thread
MediaControl registered its AudioPlaybackCallback with a null Handler,
binding delivery to the main looper. Both the callback body and the
constructor's seed read call AudioManager.isMusicActive, a binder
transaction into AudioService, producing two ANR clusters: one in
onPlaybackConfigChanged and one in <init>, the latter on the cold-start
critical path since MediaControl is constructed during App.onCreate.

Registration and seeding now run on a dedicated, injected Handler backed
by a "CAPod-MediaControl" HandlerThread, and the callback is delivered on
that same looper. Registration happens before seeding so a transition
during registration is queued behind the seed instead of being lost.

The handler is constructor-injected via a new @AudioCallbackHandler
qualifier so unit tests can drive it without Robolectric.
2026-07-28 19:07:54 +02:00
darken 80ea6fbd68 General: Update app translations from Crowdin 2026-07-28 15:53:54 +02:00
darken c08c6f1129 refactor(strings): Move flavor-specific upgrade-status strings into flavor source sets 2026-07-24 17:53:19 +02:00
darken 73dc10aee9 fix(ui): Draw all screens edge-to-edge under system bars
Scrolling content now slides under the transparent status and
navigation bars instead of clipping at the inset boundary. Adds
PaddingValues.plus and systemBarsAndCutoutInsets helpers, moves inset
consumption from scroll viewports into content padding on every screen,
fixes reorder auto-scroll thresholds for content padding, adds IME
handling to form screens, and removes the unused EdgeToEdgeHelper.
2026-07-24 17:53:13 +02:00
Matthias Urhahn 37278af34e Merge pull request #644 from d4rken-org/fix/upgrade-offercard-flash
Fix: Prevent red flash when opening the Pro upgrade screen
2026-07-24 15:15:02 +02:00
darken c822569a63 fix(upgrade): Avoid red unavailable flash during billing warm-up
On entry upgradeInfo looks like a non-owner until Play reconciles, so if the SKU query resolves first the offers box briefly rendered the red "unavailable" card before the owner/grace status or prices appeared. Gate that card behind settled and !skuQueryInProgress and show a neutral spinner during warm-up.

Also label the restore-failed dialog's dismiss button "Close" instead of "Cancel" — it reports a result, it doesn't ask to abort an action.
2026-07-24 14:42:58 +02:00
Matthias Urhahn eb41d84a0f Merge pull request #643 from d4rken-org/worktree-upgrade-offercard-ui
General: Redesign the Pro upgrade screen and restore flow
2026-07-24 14:32:20 +02:00
darken c072b90876 feat(upgrade): Adopt SD Maid offercard layout and restore UX
Reshape the Google Play upgrade screen into SD Maid SE's offercard layout:
purchase options as titled offer rows (name · price, terms, action) with an
"or" divider inside one action card, extracted into gplay-local UpgradeContent
/ UpgradeOffers / UpgradeOwnership / UpgradeRestore primitives. Keeps capod's
icon benefits card, splash graphic, and floating back arrow.

Restore now mirrors SD Maid: a reusable restore section (emphasized for
returning buyers), verification-gated across all surfaces, and a restore-failed
dialog that leads with the live Play check and offers Contact support.

Billing logic is unchanged apart from onContactSupport() navigating to the
contact form. Offer rows render conditionally on offer availability; the offers
box AnimatedContent keys on an availability phase so same-state updates
recompose in place.
2026-07-24 14:15:12 +02:00
Matthias Urhahn 6175bd3bb1 Merge pull request #642 from d4rken-org/worktree-billing-sdmaid-backport
General: Make Pro purchases more reliable when Google Play is flaky
2026-07-23 13:25:59 +02:00
darken 36c54d5d19 fix(upgrade): Harden billing storage, restore races, and offer retry 2026-07-23 13:01:14 +02:00
Matthias Urhahn f629f05f23 Merge pull request #641 from d4rken-org/crowdin-update-20260722
General: Update translations from Crowdin
2026-07-22 22:21:35 +02:00
Matthias Urhahn 2e969d3439 Merge pull request #640 from d4rken-org/worktree-flavor-string-placement
General: Tidy up FOSS and Play Store specific upgrade texts
2026-07-22 22:21:23 +02:00
darken 809305c4bd General: Update app translations from Crowdin 2026-07-22 21:14:42 +02:00
darken 3398da68ba General: Update fastlane translations from Crowdin 2026-07-22 20:56:07 +02:00
darken 061aa21c85 refactor(l10n): Move flavor-specific upgrade strings to source sets 2026-07-22 17:09:46 +02:00
darken 28b5b85be2 ui(overview): Redesign unmatched devices card with icon and description 2026-07-22 16:32:27 +02:00
Matthias Urhahn 825892df74 General: See your Pro status and switch from subscription to one-time purchase (#638)
* feat(upgrade): Add Pro status view, grace UI and sub-to-IAP switch

* fix(upgrade): Pad restore purchase to a minimum visible duration

* ui(upgrade): Mention Play-website install fix in restore troubleshooting

* fix(upgrade): Stop re-acknowledging already-acked purchases

* ui(settings): Move upgrade status row into the Other category
2026-07-22 15:40:57 +02:00
Matthias Urhahn bfc0e1cfaa test(upgrade): Cover PENDING purchases granting no Pro and no ack 2026-07-14 17:27:36 +02:00
Matthias Urhahn f4facd3e86 fix(upgrade): Reconnect billing instantly on user actions 2026-07-14 16:43:58 +02:00
Matthias Urhahn c8179c5c19 fix(upgrade): Cut report noise, pace retries and fix trial wording 2026-07-11 21:32:06 +02:00
Matthias Urhahn bd36c5e5d6 fix(upgrade): Recover billing from stale purchase data and mid-flow errors 2026-07-11 12:15:17 +02:00
Matthias Urhahn 778d45170f feat(upgrade): Re-check purchases when the app comes to the foreground 2026-07-11 12:10:08 +02:00
Matthias Urhahn b52c2cbe6f feat(upgrade): Show restore banner and progress for returning Pro buyers 2026-07-11 12:05:06 +02:00
Matthias Urhahn 69febaac28 feat(upgrade): Keep one-time Pro buyers Pro through longer Play outages 2026-07-11 12:00:00 +02:00
Matthias Urhahn 2f1cbe34e5 fix(upgrade): Surface Google Play errors when a purchase can't start 2026-07-11 11:51:54 +02:00
Matthias Urhahn 8dcc4f8489 fix(upgrade): Harden purchase restore and billing error handling 2026-07-10 22:28:30 +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
darken ba554c8787 Release: 3.0.4-rc1 2026-02-09 16:51:10 +01:00
darken 968829072a chore: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-09 16:48:43 +01:00
darken 0d1f331551 Release: 3.0.4-rc0 2026-02-09 16:22:31 +01:00
darken a8bb42d40b fix(ci): Narrow APK glob to avoid uploading duplicates
The copyTo-based APK renaming (from AGP 9 upgrade) leaves both the
original and renamed APK in the output directory. Narrow the glob to
only match renamed APKs.
2026-02-09 16:16:18 +01:00
darken 9a3fb11ef7 fix: Use correct labels for upgrade and donate menu items
Both menu items were using settings_general_label ("Settings") instead
of their proper labels, affecting accessibility and long-press tooltips.
2026-02-09 16:07:25 +01:00
darken 12d2c4dd06 fix: Pre-release cleanup of manifest, resource leaks, and dependencies
- Fix BleScanResultReceiver package name in manifest (.bluetooth → .common.bluetooth)
- Fix HandlerThread leak in BluetoothManager2 when registerReceiver() throws
- Cache battery values in MonitorNotifications to match hardening pattern
- Remove duplicate fragment-ktx dependency and align fragment-testing version
- Remove deprecated lifecycle-extensions dependency
2026-02-09 15:50:39 +01:00
darken cfed733fa7 fix: Guard MonitorService start against missing Bluetooth permissions 2026-02-09 14:24:45 +01:00
darken 1af8743533 chore: Remove redundant gradle.properties after AGP 9 upgrade 2026-02-09 07:50:36 +01:00
darken 2f3f1f3f70 fix(ci): Remove non-debug unit test variants for AGP 9 compatibility 2026-02-08 17:31:19 +01:00
darken 6223524a1e chore: Upgrade to AGP 9.0.0 and Gradle 9.3.1 2026-02-08 17:31:19 +01:00
darken ebfffb1536 fix: Replace WorkManager with ForegroundService to eliminate excessive wakelocks
Replace MonitorWorker (CoroutineWorker) with MonitorService (ForegroundService)
to eliminate persistent ProcessorForegroundLck wakelock reported in Android Vitals.
Add BootCompletedReceiver for post-reboot auto-start.
Remove WorkManager dependency entirely.
2026-02-08 14:19:11 +01:00
darken 5aa36b148b fix: Harden battery display against native crashes
Remove thread-unsafe shared RelativeDateTimeFormatter instance and cache
battery percentage values to local variables at all call sites.
2026-02-08 04:25:30 +01:00
darken 29391feaf8 chore: Remove stale CLAUDE.md exclusion from _config.yml 2026-02-07 20:17:51 +01:00
darken 4e279ca44d chore: Restructure Claude Code config into modular rules
Migrate monolithic root CLAUDE.md into .claude/rules/ structure
with focused topic files and glob-based contextual loading.
Clean up leftover helper scripts in .claude/tmp/.
2026-02-07 19:31:28 +01:00
darken 9ff8d9e04e fix: Handle display cutouts in landscape mode
EdgeToEdgeHelper now considers both systemBars() and displayCutout()
insets when applying padding. This prevents camera cutouts from
obscuring UI content when the device is in landscape orientation.
2026-01-17 14:20:21 +01:00
darken 10d5a823fc fix: Make widget clicks unique
Use the widget ID as the request code for `PendingIntent`. This ensures that each widget instance has a distinct `PendingIntent`, preventing them from overwriting each other and allowing clicks on multiple widgets to work correctly.
2025-11-11 08:37:37 +01:00
darken 54afadb136 fix: Prevent memory leak in WidgetConfigurationActivity
Uses the application context instead of the activity context when updating the widget. This avoids leaking the activity instance if the update operation outlives the configuration screen.
2025-11-11 08:37:37 +01:00
darken e4db9387ca Update translations 2025-11-10 08:38:12 +01:00
darken 4834eb276a Widget: Fix layout for devices with single charge detection
The instance check was being done on the wrong value, so it was never `true`.
2025-11-09 15:03:04 +01:00
darken 5860bbffb6 Release: 3.0.3-rc0 2025-10-29 15:52:54 +01:00
darken da53d68190 Update translations 2025-10-29 15:52:04 +01:00
darken b397bf190c Update translations 2025-10-29 15:52:04 +01:00
darken 15d664c361 feat: Make connectedDevices flow more robust
Adds retry and catch logic to handle failures gracefully.
2025-10-29 15:51:48 +01:00
darken d2f24d8b95 fix: Improve BluetoothManager stability
Enhances robustness by adding extensive error handling around broadcast receiver registration, profile event processing, and service disconnections to prevent crashes. Also ensures device flow is cleared when Bluetooth is disabled.
2025-10-29 15:51:48 +01:00
darken b5436659b6 refactor: Make connectedDevices a hot StateFlow
Convert `connectedDevices()` from a function returning a cold Flow to a property that is a hot `StateFlow`. This simplifies call sites and improves efficiency by sharing the underlying subscription.
2025-10-29 15:51:48 +01:00
darken 1cfc4611cb Improve Bluetooth device connection monitoring
Switched from monitoring generic ACL events to specific Headset profile state changes for more reliable device connection and disconnection detection.

This fixes a race condition where CAPod thinks no device is connected because we triggered too early, before "connectedDevices" on the HEADSET profile contains our target device.

This fixes #313
2025-10-29 15:14:45 +01:00
darken bda0a20743 Release: 3.0.2-rc0 2025-10-11 08:02:51 +02:00
darken 09bad90c0c feat: Add and update translations for Dutch and Mexican Spanish 2025-10-11 07:50:32 +02:00
darken 34856607f8 Fix crash on start due to R8
Closes #341
2025-10-11 07:45:42 +02:00
darken 5c2b66fe27 Release: 3.0.1-rc0 2025-10-10 08:35:19 +02:00
darken c9fe9a43e2 Update and extend translations
This commit updates translations for German, Spanish, and Catalan. It also adds a comprehensive set of new strings for the Chinese (Simplified) localization.
2025-10-10 08:34:29 +02:00
darken 395dbbb624 Feat: Update translations
Updates for Russian, Polish, Catalan, Spanish, and Czech.
2025-10-09 12:24:14 +02:00
darken fdc6486102 Feat: Update translations
Updates for Russian, Polish, Catalan, Spanish, and Czech.
2025-10-09 12:24:14 +02:00
darken 6c379451c0 Add ear detection limitation notice to reactions settings
Explains that single-pod detection is an Apple limitation, not an app bug.
Users experiencing this issue will now understand it only affects the
"primary pod" (microphone pod) and can be configured in iOS settings.

Closes #38, Closes #329
2025-10-08 15:37:14 +02:00
darken 221f3ccbed Refactor: Convert build configuration to Gradle plugin pattern
- Migrate ProjectConfig from static object to proper Gradle plugin
- Add version type support (beta/rc) to version.properties
- Clean up legacy fastlane changelogs
- Update release script to support version types
- Remove automatic fastlane changelog generation from release script
- Add BuildConfig field injection for version info
2025-10-07 13:54:24 +02:00
darken 12f0662146 Release: 3.0.0-beta1 2025-10-07 12:44:45 +02:00
darken 7cc81bd554 Chore: Update release tooling
Update Fastlane and simplify GitHub release workflow.
2025-10-07 12:43:50 +02:00
darken 618fddb698 Update translations 2025-10-07 12:43:33 +02:00
darken 0f74cb9b39 Refactor: Use plurals for unmatched devices count. 2025-10-07 12:43:33 +02:00
darken 743f5aeb89 Release: 3.0.0-beta0 2025-10-07 10:59:23 +02:00
darken 9260a24e0d Feat: Update translations
- Add new strings for device profiles and settings
- Improve existing translations for clarity
- Update Google Play billing error messages
2025-10-07 10:58:27 +02:00
darken 49fc0f018a Implemented widget configuration for multiple profiles 2025-09-29 19:29:43 +02:00
darken ad7089ea7c Allow key inputs with spaces instead of dashes
The regex for validating the identity key in the device profile creation fragment has been updated to also allow spaces as separators.
2025-09-29 15:50:53 +02:00
Matthias Urhahn e74b83581a Merge pull request #330 from d4rken-org/multi-device
Support for multiple device profiles
2025-09-29 15:10:18 +02:00
darken a3fb26f967 Update translations 2025-09-29 14:57:41 +02:00
darken 2b4161b9e7 Fix DeviceManagerFragment navigation
The navigation graph had a duplicate entry for DeviceManagerFragment.
This commit removes the duplicate and ensures the correct navigation action to DeviceProfileCreationFragment is present.
2025-09-29 14:31:58 +02:00
darken d38f2ea3e9 Fix: Add missing % to signal quality string.
Refactor: Simplify signal quality filter.
2025-09-29 14:30:38 +02:00
darken ba4445f19d Fix lid state for AirPods Gen 4
The lid state for AirPods Gen 4 was incorrectly reported as UNKNOWN when it should have been NOT_IN_CASE. This commit fixes the issue in both the ANC and non-ANC tests.
2025-09-29 14:23:56 +02:00
darken 33cab939f0 Refactor: Use DeviceProfilesRepo for keys
This commit replaces the direct usage of GeneralSettings for fetching identity and encryption keys with the DeviceProfilesRepo. BaseAirPodsTest is updated to reflect this change, mocking DeviceProfilesRepo instead of GeneralSettings.
2025-09-29 14:23:52 +02:00
darken 69ce7e18f5 Add "Monitoring for devices" card to Overview
This commit introduces a new card to the Overview screen that informs the user that the app is actively monitoring for devices when there are profiles configured but no devices are currently connected.
2025-09-29 14:14:09 +02:00
darken e2014a26fc Gradle update to 8.13.0 2025-09-29 14:06:34 +02:00
darken 1e806ea416 Lower default minimum signal quality 2025-09-29 14:04:14 +02:00
darken 6cf85935f9 TS: Auto create profile from closest device 2025-09-29 14:04:07 +02:00
darken 25b838da5c Rebase regression 2025-09-29 14:02:16 +02:00
darken 03676346e9 Move connected notification setting to its own category 2025-09-29 14:00:12 +02:00
darken f2e6d8978c Fix false unsaved changes detection in profile editor
- Replace multiple StateFlows with single ProfileEditorState data class
- Store ByteArray keys as hex strings to enable proper equality comparison
- Fix race conditions in state initialization by setting initial and current state atomically
- Simplify change detection logic to simple data class comparison

The issue was caused by:
1. ByteArray types not implementing proper equals() (compared by reference)
2. Race conditions between multiple StateFlow updates
3. Complex change detection logic with timing issues

Now uses atomic state management with proper value-based equality.
2025-09-29 13:37:01 +02:00
darken fbbbf751c5 Add priority hint to device profiles list
- Shows hint when 2+ profiles exist explaining order determines priority
- Appears as flat list item at bottom, less intrusive than card
- Prevents dragging hint item, keeps it at bottom
- Uses book icon for informational guidance
2025-09-29 13:37:01 +02:00
darken 132b3996fc Update signal quality slider range
The signal quality slider in `device_profile_creation_fragment.xml` now ranges from 0 to 100 with a step size of 1, previously 10 to 100 with a step size of 5.
2025-09-29 13:37:01 +02:00
darken 7b41f83a53 Refactor device profiles and settings migration
This commit introduces a new device profile system, moving away from a single "main device" configuration in `GeneralSettings`.

Key changes:
- `DeviceProfilesRepo` now manages a list of `DeviceProfile` objects, allowing for multiple device configurations.
- On first launch with this update, existing "main device" settings from `GeneralSettings` (address, model, keys, signal quality) are migrated into a new default `AppleDeviceProfile`.
- The `MonitorWorker`, `PopUpReaction`, `AutoConnect`, and `PodHistoryRepo` have been updated to use the new `DeviceProfilesRepo` instead of the old `GeneralSettings` for device-specific information.
- `AppleFactory` now uses `DeviceProfilesRepo` to find matching profiles based on IRK.
- `TroubleShooterFragmentVM` now interacts with `DeviceProfilesRepo` for profile management during troubleshooting.
- The `create` method in `ApplePodsFactory` and its implementations are now `suspend` functions to allow for asynchronous operations like fetching profiles from the repository.
- Old "main device" preference keys in `GeneralSettings` have been renamed with an "old" prefix and will be removed in a future update.
- A new `currentProfiles()` extension function provides a convenient way to get the current list of profiles.
2025-09-29 13:37:01 +02:00
darken b6027186c0 Distinguish between UNKNOWN and missing profile
This commit modifies the `AppleFactory` to correctly handle `PodDevice.Model.UNKNOWN`.

Previously, if a device's model was `UNKNOWN`, it wouldn't match any profile and `isIRKMatch` would be false. This change ensures that `isIRKMatch` reflects the actual IRK match status, regardless of whether a specific profile is found.

Devices with an `UNKNOWN` model can now match profiles that are also marked as `UNKNOWN`, allowing for generic profile matching when the specific model isn't identified.
2025-09-29 13:37:01 +02:00
darken d2d126e9d9 Enable per-app language preferences
This commit enables the automatic generation of per-app language preferences by configuring the Android Gradle Plugin.

A `resources.properties` file is added to specify `en` as the `unqualifiedResLocale`, which serves as the default/fallback locale.

Additionally, `.kotlin` is added to `.gitignore` to exclude Kotlin build-related files.
2025-09-29 13:37:01 +02:00
darken 89632d864a Remove deviceModel property from ApplePodsFactory interface
Removed the deviceModel property from ApplePodsFactory interface and all 25 implementing classes across AirPods, Beats, and misc device factories. This simplifies the factory pattern as the model information can be derived from the concrete implementation type itself.
2025-09-29 13:37:01 +02:00
darken 2cd209ee03 Add TODO for profile migration
This commit adds a TODO comment to `GeneralSettings.kt` to migrate settings related to signal quality and main device details to the new device profiles system.
2025-09-29 13:37:01 +02:00
darken b96d6ef4db Improve device profile creation UI and fix navigation bar overlaps
- Fix navigation bar overlap issues in profile creation and list screens
- Add proper edge-to-edge handling with dynamic padding for navigation bars
- Improve paired device selection with "None" option and clear functionality
- Add localized error messages for key validation with proper placeholders
- Centralize default signal quality value in DeviceProfile companion object
- Add unsaved changes detection and confirmation dialogs
- Enhance drag handle touch target size with FrameLayout wrapper
- Restore device address in profile editing and improve change tracking
- Localize all user-facing strings following project guidelines
2025-09-29 13:37:01 +02:00
darken d6af8e21cc Remove TitleLarge from device profile creation toolbar
The `titleTextAppearance` was removed from the toolbar in `device_profile_creation_fragment.xml` to use the default appearance.
2025-09-29 13:37:01 +02:00
darken ae2d1c6868 Refactor: Rename devices package to profiles
This commit renames the `devices` package and its contents to `profiles` for better clarity and consistency. This includes renaming classes, files, and updating import statements and navigation graph references.
2025-09-29 13:37:00 +02:00
darken 98cf4ff3e7 Refactor device profiles: Add creation UI and drag-to-reorder
This commit introduces a new UI for creating and editing device profiles. Users can now define profile names, select device models, set minimum signal quality, and optionally add identity and encryption keys.

The device manager screen now supports drag-and-drop reordering of profiles. The order of profiles in the list now determines their priority, with items at the top having higher priority. The internal data structure for device profiles has been updated, and the `PodMonitor` now sorts devices based on this new profile priority.
2025-09-29 13:37:00 +02:00
darken 679c9cd070 wip 2025-09-29 13:36:59 +02:00
darken 5b281303d9 Use Uri.toUri() extension
This commit updates the `OverviewFragment` to use the `androidx.core.net.toUri()` extension function when creating Uris for system settings intents. This replaces the direct usage of `Uri.parse()`.
2025-09-29 13:36:46 +02:00
darken cb28bbfaf3 Remove item count debug subtitle in OverviewFragment
The subtitle showing the number of items in the list was only visible in debug builds and has been removed.
2025-09-29 13:36:46 +02:00
darken cd190389cd Update all device implementations to support profiles
- Update all Apple device classes to use AppleMeta with profile support
- Modify device factories to inject profile information during creation
- Update UI components to handle profile-aware device metadata
- Refactor reaction systems to work with profile-based devices
- Update monitor worker and cache to support profile relationships
- Ensure consistent meta structure across all device types

All devices now properly reference their associated profiles through
the meta.profile field, enabling profile-based functionality.
2025-09-29 13:36:46 +02:00
darken 0af469af07 Add core device profile infrastructure
- Implement DeviceProfile interface with sealed interface pattern
- Add AppleDeviceProfile for Apple devices with IRK support
- Create DeviceProfilesRepo for profile persistence with Moshi
- Add NameBasedPolyJsonAdapterFactory for polymorphic JSON serialization
- Update PodDevice.Meta to include profile reference
- Extend ApplePods with AppleMeta containing profile information
- Add utility extensions for profile-based device filtering

This establishes the foundation for device profile management system.
2025-09-29 13:36:46 +02:00
darken 1689153070 Implement profile-based device separation in overview UI
- Add UnmatchedDevicesCard to show/hide devices without profiles
- Separate devices with profiles from unmatched devices in overview
- Add priority-based sorting (profile.priority with 0 = highest)
- Add compiler args for experimental unsigned types and annotation targets
- Add string resources for unmatched devices UI
- Refactor overview to handle both profiled and non-profiled devices

The UI now shows:
1. Devices with configured profiles first (sorted by priority)
2. Collapsible section for unmatched devices with toggle button
3. Session-persistent show/hide state for unmatched devices
2025-09-29 13:36:46 +02:00
darken 520458b107 Consolidate strings.xml files from app-common to app module
- Merged all 76 locale strings.xml files from app-common to app module
- Removed app-common module dependency from app/build.gradle.kts
- Removed app-common from settings.gradle
- Fixed all BuildConfig and R class import statements
- Updated fully qualified R references from eu.darken.capod.common.R to R
- Deleted entire app-common directory and all associated files
- Added temporary fallback for missing GITSHA BuildConfig field

The project now has a simplified structure with all string resources
consolidated in the app module, eliminating the unnecessary app-common
module while maintaining full functionality.

Builds successfully for both FOSS and Google Play variants.
2025-09-29 13:36:46 +02:00
darken be8f4919c8 Merge main and common module to simplify. 2025-09-29 13:36:43 +02:00
darken deb012600d Remove "Show all devices" setting and always show all devices.
This commit removes the "Show all devices" toggle from General Settings. The app will now always display all nearby Bluetooth devices in the overview, simplifying the UI and device discovery.

The corresponding preference `core.showall.enabled` and its usage have been removed. The debug setting `showUnfiltered` no longer affects this behavior.
2025-09-29 13:36:21 +02:00
darken 15371e01ef Refactor device configuration UI and move troubleshooter
- Change "No primary device" card to "No device configured" with clearer messaging
- Replace troubleshoot action with "Manage devices" button that navigates to device manager
- Move troubleshooter from overview to Settings → Support section
- Add concise troubleshooter description for settings preference
- Update navigation to support troubleshooter access from settings
- Improve user experience by providing more intuitive device management flow
2025-09-29 13:36:21 +02:00
darken 0e3ee655ef Remove WearOS variant (hasn't been supported for quite some time). 2025-09-29 13:36:21 +02:00
darken da5af8e4b1 Release: 2.19.0-rc1 2025-09-21 19:22:38 +02:00
Matthias Urhahn 6c848eaa55 Merge pull request #328 from d4rken-org/airpods-pro-3-merged
AirPods Pro 3 support
2025-09-21 19:13:57 +02:00
darken 15054d836d Merge branch 'airpods-pro-3' into airpods-pro-3-merged
# Conflicts:
#	app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt
#	app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt
2025-09-21 18:38:13 +02:00
darken 0ac9bba482 Merge branch 'airpods-pro-3' into airpods-pro-3-merged
# Conflicts:
#	app-common/src/main/java/eu/darken/capod/pods/core/PodDevice.kt
#	app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3.kt
2025-09-21 18:33:10 +02:00
Tharen Abela 247bc13e0a Initial support for AirPods Pro 3
This commit introduces the AirPods Pro 3 model to the application, including its representation in the README, the PodDevice interface, and the AppleFactoryModule.

A new class for AirPods Pro 3 is created, along with corresponding unit tests to validate its functionality.

Based on the AirPods Pro 2 USBC.
2025-09-21 17:56:03 +02:00
li-fstz 33126be817 Add support for AirPods Pro 3 2025-09-21 17:55:56 +02:00
darken b02b31043b Release: 2.18.0-rc1 2025-09-03 19:41:29 +02:00
darken 6786b5aa8a Remove Norwegian (Bokmål) from supported languages. 2025-09-03 19:41:12 +02:00
darken 8577e7bae8 Release: 2.18.0-rc0 2025-09-03 19:08:48 +02:00
darken df88399e7a Remove BUILDTIME BuildConfig field
This commit removes the `BUILDTIME` field from `BuildConfig` across all modules. This change improves build caching and reproducibility by eliminating a value that changed with every build.

The `BUILDTIME` was previously used in `VERSION_DESCRIPTION_LONG` but is no longer included.
2025-08-31 15:30:12 +02:00
darken 16392d5721 Refactor buildSrc and update dependencies
This commit refactors the `buildSrc` module by:
- Removing the `setupLibraryDefaults()` extension function from `ProjectConfig.kt` as its functionality is largely covered by standard Gradle plugin configurations.
- Inlining version numbers for Moshi and various testing libraries directly into `Dependencies.kt`, removing them from `Versions.kt`. This simplifies version management for these specific libraries.
- Removing unused version declarations from `Versions.kt`.

Additionally, this commit updates the following dependencies:
- Kotlin from 2.1.21 to 2.2.10
- Android Gradle Plugin from 8.11.0 to 8.12.2
- KSP from 2.1.21-2.0.1 to 2.2.10-2.0.2
- Dagger from 2.56.2 to 2.57.1
- AndroidX Navigation from 2.8.9 to 2.9.3

The `targetSdk` was also removed from the `app-common` module's `defaultConfig` as it's typically inherited or set at the app level.
2025-08-31 15:30:12 +02:00
darken 2edf5411ce Allow keeping "connected" notification after disconnect
This commit introduces a new setting that allows users to keep the "connected" notification visible even after the monitored device disconnects.

When this setting is enabled and the "Extra notification" is also active, the `MonitorWorker` will no longer cancel the connected notification upon finishing. This provides users with the option to see the last known battery levels after disconnection.

A new preference `keepConnectedNotificationAfterDisconnect` is added to `GeneralSettings` and the UI.
2025-08-31 10:11:45 +02:00
darken 89e46d3858 Update translations 2025-08-30 17:12:36 +02:00
darken da45eb5dda Update translations 2025-08-30 17:12:36 +02:00
darken a1868f495d Update translations 2025-08-30 17:12:36 +02:00
darken ceb5034c3b Update translations 2025-08-30 17:12:36 +02:00
darken 02ff7089c0 Refactor GPlay billing error handling and translations
This commit introduces localized error messages for Google Play billing issues and refactors the error handling in `UpgradeRepoGplay`.

- `BillingException` and `BillingResultException` now implement `HasLocalizedError` to provide user-friendly error messages.
- New string translations for billing error labels and descriptions have been added for various languages.
- `UpgradeRepoGplay` now includes an `error` field in its `Info` state to propagate billing errors.
- The `upgradeInfo` flow in `UpgradeRepoGplay` has been updated with a `retryWhen` operator to handle errors more gracefully. If an error occurs but the pro state was recent, it emits a grace period state. Otherwise, it emits the error.
- `OverviewFragmentVM` now observes `upgradeState` and posts any non-pro errors to `errorEvents` for display.
2025-08-30 17:12:36 +02:00
darken 2fa33b13a3 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.claude/settings.json
2025-08-30 16:48:41 +02:00
darken 96a654638a Claude settings 2025-08-30 16:48:22 +02:00
darken b4aa7b0815 Claude settings 2025-08-30 16:48:04 +02:00
darken df5389c27e Configure Jekyll include and exclude settings
This commit updates the Jekyll configuration file (`_config.yml`) to:
- Include `README.md` and `CHANGELOG.md` in the site build.
- Exclude `CONTRIBUTING.md` and `CLAUDE.md` from the site build.
2025-08-30 16:44:26 +02:00
darken 0611ec0e08 Add CLAUDE.md to guide AI code generation
This commit introduces a `CLAUDE.md` file.
This file contains instructions and context for the Claude AI to assist with development in this repository. It includes:
- Common build, test, and code quality commands.
- An overview of the project's multi-module architecture, core patterns (MVVM, DI with Hilt, Coroutines, Repository), and key components like `PodMonitor` and the reaction system.
- Details on build flavors (FOSS, Google Play) and build types (debug, beta, release).
- A description of the data flow architecture.
- Information on the testing strategy and key dependencies.
- Development notes regarding Bluetooth LE implementation and multi-platform considerations for phone and Wear OS.
2025-08-30 16:42:28 +02:00
darken c325112b44 Apply translation updates and add Norwegian Bokmål
This commit incorporates various translation improvements across multiple languages (Ukrainian, Greek, French, Turkish, Spanish (Mexico), Arabic, Korean, Urdu (India), Indonesian, Romanian, Zulu) for UI strings and Play Store metadata.

Additionally, this commit introduces new translations for Norwegian Bokmål (nb) for the Play Store listing (title, short description, full description) and adds initial string resources for Romansh (rm).
2025-08-30 16:42:28 +02:00
darken 65f9e1d9db Release: 2.17.6-rc0 2025-07-03 00:19:40 +02:00
darken c97c8f4852 Refactor: Introduce EdgeToEdgeHelper for consistent inset handling
This commit introduces an `EdgeToEdgeHelper` class to centralize and simplify the application of window insets for edge-to-edge display.

Key changes:
- Created `EdgeToEdgeHelper` to manage padding based on system bar insets.
- Migrated various Fragments (`OverviewFragment`, `OnboardingFragment`, `SettingsFragment`, `TroubleShooterFragment`) to use `EdgeToEdgeHelper` for consistent padding.
- Removed manual inset handling from `Activity2` and `Fragment2` as `EdgeToEdgeHelper` now manages this.
- Enabled edge-to-edge display in `MainActivity`.
- Minor updates to `BillingClientConnection` for product details fetching and `build.gradle.kts` for configuration.
2025-07-03 00:14:58 +02:00
darken 6b2073eff2 Update AGP, SDK, and BillingClient
This commit updates:
- Android Gradle Plugin to 8.11.0
- Compile/Target SDK to 36
- Google Play Billing Library to 8.0.0

The `BillingClient` is updated to use the new `PendingPurchasesParams` and `QueryPurchasesParams` APIs.
2025-07-03 00:14:58 +02:00
darken 2b08d9a4d0 Release: 2.17.5-rc0 2025-06-30 14:13:41 +02:00
darken 977f135c50 Allow empty keys to clear IRK/EncKey
This commit modifies the handling of Identity Resolving Key (IRK) and Encryption Key (EncKey) to allow users to clear these values by providing an empty input.

Specifically:
- In `GeneralSettingsFragment.kt`, the `onKey` callbacks for both IRK and EncKey dialogs now use `takeIf { it.isNotEmpty() }` after converting the input hex string to a byte array. This ensures that an empty input results in `null` being set for the respective key.
- In `PodMonitor.kt`, when determining the main device, `mainDeviceIdentityKey.value` is now checked with `takeIf { it.isNotEmpty() }` to ensure an empty IRK is treated as no IRK being configured.
- In `AppleFactory.kt`, when attempting to decrypt the private payload, `mainDeviceEncryptionKey.value` is now checked with `takeIf { it.isNotEmpty() }` to ensure an empty EncKey prevents decryption attempts.
2025-06-30 14:12:32 +02:00
darken 3c777d377e Only use IRK match for main device if IRK is configured
This commit modifies `PodMonitor.kt` to ensure that the `isIRKMatch` flag is only used to determine the main device if an Identity Resolving Key (IRK) is actually configured in the general settings.

If no IRK is set, the main device determination will fall back to other logic, preventing a device from being incorrectly selected as "main" solely based on an IRK match when no specific IRK is being looked for.
2025-06-30 14:12:32 +02:00
darken b98994f2f1 Revert "Fix IRK-only mode to prevent heuristic fallback + auto battery optimization"
This reverts commit ca5041b27d.
2025-06-30 14:12:32 +02:00
wnerhs 85860a53bf Fix IRK-only mode to prevent heuristic fallback + auto battery optimization
- Prevent heuristic device detection when IRK/ENC keys are configured
- Maintain last IRK-matched device state instead of fallback
- Auto-enable LOW_POWER scan mode when IRK device is found
- Fixes false positives in crowded places
2025-06-30 14:12:32 +02:00
darken 7a967ce648 Release: 2.17.4-rc0 2025-06-23 14:06:20 +02:00
darken 4c7eac870d Dependencies: Downgrade Navigation Core to 2.8.9
Navigation Core version 2.9.0 seems to have a bug causing an `ConcurrentModificationException`.

See:
https://issuetracker.google.com/issues/188860458
https://issuetracker.google.com/issues/417784831
2025-06-23 14:04:39 +02:00
darken ff58e01551 Shorten app title for Tamil, Romanian, and Swedish
This commit updates the app title in the Google Play Store metadata for Tamil (ta-IN), Romanian (ro), and Swedish (sv-SE) by removing or adjusting a word to meet length constraints.
2025-06-22 21:28:55 +02:00
darken 004ea6cbcb Add fastlane/README.md to .gitignore
This commit updates the `.gitignore` file to include `fastlane/README.md`, ensuring that this Fastlane-generated file is not tracked by Git.
2025-06-22 21:28:55 +02:00
darken a42b88aa78 Fix typo in Lao app title
This commit corrects a typographical error in the Lao translation of the app title.
Specifically, it changes "ຄູ່​ຮ່ວມ​ສຳ​ລັບ" to "ຄູ່ຮ່ວມສຳລັບ" in `fastlane/metadata/android/lo-LA/title.txt`.
2025-06-22 21:28:55 +02:00
darken 43088cf8b8 Update Fastlane and add Wear OS lanes
This commit updates various Ruby gems in `Gemfile.lock`, most notably upgrading `fastlane` from version 2.213.0 to 2.228.0.

It also introduces new Fastlane lanes for Wear OS builds:
- `beta_wearos`
- `production_wearos`

The `fastlane/README.md` has been updated to reflect these new lanes.
2025-06-22 21:28:55 +02:00
darken 9460584891 More language updates and mapping changes 2025-06-22 21:28:55 +02:00
darken 570118ca94 Refactor Crowdin language mapping
This commit refactors the `crowdin.yaml` configuration file to:
- Use YAML anchors (`&stringmapping`, `*stringmapping`) to define and reuse the `languages_mapping` for different source files. This reduces redundancy and improves maintainability.
- Update language codes for Sardinian ("sc") and Norwegian ("no") to "sc-rIT" and "nb" respectively in the `stringmapping`.
- Update language codes for Sardinian ("sc") and Albanian ("sq") to "sc-IT" and "sq-AL" respectively in the `playstoremapping`.
2025-06-22 19:20:47 +02:00
darken d9f0de2e05 Add translations for Filipino, Albanian, Sardinian, and Urdu
This commit introduces new translations and updates existing ones for the CAPod application in Filipino (fil), Albanian (sq-rAL), Sardinian (sc-rIT), and Urdu (ur-rIN).

Specifically, it:
- Adds comprehensive translations for various UI elements, settings, permissions, and status messages in Albanian (`app/src/main/res/values-sq-rAL/strings.xml` and `app-common/src/main/res/values-sq-rAL/strings.xml`).
- Adds translations for Google Play related strings in Sardinian (`app/src/gplay/res/values-sc-rIT/strings.xml`).
- Adds translations for FOSS-specific upgrade options in Sardinian (`app/src/foss/res/values-sc-rIT/strings.xml`).
- Adds comprehensive translations for various UI elements, settings, permissions, and status messages in Sardinian (`app/src/main/res/values-sc-rIT/strings.xml`).
- Updates and expands translations in Filipino (`app-common/src/main/res/values-fil/strings.xml`), including terms for app names, general UI elements, permissions, device states, and settings.
- Adds comprehensive translations for various UI elements, settings, permissions, and status messages in Urdu (`app-common/src/main/res/values-ur-rIN/strings.xml`).
2025-06-22 19:20:47 +02:00
darken 3d5dcfb21d Add Sardinian and Albanian to Crowdin config
This commit updates the `crowdin.yaml` file to include language mappings for Sardinian (sc-IT) and Albanian (sq-AL).

Specifically, it adds:
- `sc-IT: sc-rIT` and `sq-AL: sq-rAL` to the `android_code` language mappings for various files.
- `sc-IT: sc-IT` and `sq-AL: sq-AL` to the `locale` language mappings under `playstoremapping`.
2025-06-22 19:20:47 +02:00
darken e0dc9ea737 Fix escaped quotes in translations
This commit corrects the escaping of quotation marks in several translated strings. Specifically, it replaces `\\\"` with `\"` in:
- Galician (gl-rES) translations for various permission descriptions.
- Telugu (te-rIN) translations for settings descriptions and troubleshooter text.
- Romansh (rm) translations for settings descriptions and troubleshooter text.
2025-06-22 19:20:47 +02:00
darken b4d0904f55 Add translations for Zulu, Finnish, Galician, Kyrgyz, Romansh, Telugu, and Uzbek
This commit introduces new translations for the app in Zulu (zu), Finnish (fi), Galician (gl-rES), Kyrgyz (ky-rKG), Romansh (rm), Telugu (te-rIN), and Uzbek (uz).
2025-06-22 19:20:47 +02:00
darken 2caee59713 More translation updates 2025-06-22 19:20:47 +02:00
darken 6712ba78ba Update app translations 2025-06-22 19:20:47 +02:00
darken cba30aee47 Translate app store listings to various languages
This commit translates the app's title, short description, and full description into several new languages for the app store listing.

Specifically, it adds translations for:
- Tamil (ta-IN)
- Kurdish (kmr-TR)
- Armenian (hy-AM)
- Kyrgyz (ky-KG)
- Swedish (sv-SE)
- Uzbek (uz)
- Telugu (te-IN)
- Lao (lo-LA)
- Icelandic (is-IS)
- Slovenian (sl)
- Macedonian (mk-MK)
- Afrikaans (af)
- Serbian (sr)
- Zulu (zu)
- Croatian (hr)
- Basque (eu-ES)
- Amharic (am)
- Urdu (ur-IN)
- Mongolian (mn-MN)
- Latvian (lv)
- Malayalam (ml-IN)
- Filipino (fil)
- Marathi (mr-IN)
- Finnish (fi-FI)
- Swahili (sw)
- Georgian (ka-GE)
- Thai (th)
- Burmese (my-MM)
- Romansh (rm)
- Sardinian (sc-IT)
- Bengali (bn-BD)
- Nepali (ne-NP)
- Bulgarian (bg)
- Albanian (sq-AL)
- Sinhala (si-LK)
- Khmer (km-KH)
- Galician (gl-ES)
- Lithuanian (lt)
- Danish (da-DK)
- Kannada (kn-IN)
2025-06-22 19:20:47 +02:00
darken c6cd8d68f7 Upgrade Android Gradle Plugin and KSP
This commit updates the Android Gradle Plugin (AGP) from version 8.10.0 to 8.10.1 and the Kotlin Symbol Processing (KSP) plugin from version 2.1.20-2.0.0 to 2.1.21-2.0.1.

These changes are applied in the root `build.gradle.kts` file and the `buildSrc/build.gradle.kts` file.
2025-06-22 18:08:22 +02:00
darken d2fbf9d79f Add variant PowerBeatsPro test case 2025-06-21 14:01:16 +02:00
darken 738a35dbe9 Add support for PowerBeats Pro 2
This commit introduces support for the PowerBeats Pro 2 device.

Had to change the PowerBeats Pro 1 device code due to conflicting matches. Previously was using the half code variant. Might have broken that, but don't have any feedback from actual users, someone will have to speak up...

Closes #298
2025-06-21 14:01:16 +02:00
darken 1278ec55c2 Refine changelog translations 2025-06-20 11:00:23 +02:00
darken 95ff34b373 Add Fastlane changelogs for multiple languages
This commit adds default changelog files for various languages in the Fastlane metadata directory. Each file contains a generic message about bug fixes, performance improvements, and potential new features, along with a link to the full changelog and a note about the developer being a single person.
2025-06-20 10:21:15 +02:00
darken 1329429130 Refactor: Improve CHANGELOG.md formatting
This commit refactors the Liquid templating in `CHANGELOG.md` to:
- Indent Liquid logic for better readability.
- Improve spacing around headings (##, ###) and list items (-) for a cleaner rendered output.
2025-06-20 09:51:17 +02:00
darken 8c3c09dc03 feat: Enhance Jekyll config and add auto-generated changelog page
This commit updates the Jekyll configuration to include `jekyll-github-metadata` and `jemoji` plugins. It also introduces a `CHANGELOG.md` file that automatically generates a formatted changelog page from GitHub releases. The default changelog text for new app versions in Fastlane has also been updated to be more engaging and directly link to the new changelog page.
2025-06-20 08:58:39 +02:00
darken 10bf65bd09 Release: 2.17.3-rc0 2025-06-18 13:17:56 +02:00
darken b57b3458ab Update translations 2025-06-18 13:17:20 +02:00
darken 6aeee4816c Update translations 2025-06-18 13:17:20 +02:00
darken 3c8f24fa50 Release: 2.17.2-rc0 2025-06-13 10:04:07 +02:00
darken 5e60629b4c Update translations 2025-06-13 10:02:59 +02:00
darken 21d740ae51 Fix IRK match flag in AppleFactory
This commit corrects the `isIRKMatch` flag assignment in `AppleFactory.kt`. Previously, it was hardcoded to `true`. Now, it correctly uses the `isIrkMatch` variable determined by the Identity Resolving Key (IRK) comparison.
2025-06-13 09:27:43 +02:00
darken f4e8f08f4f Release: 2.17.1-rc1 2025-06-12 09:02:30 +02:00
darken 7bf472023f Update translations 2025-06-12 08:57:16 +02:00
darken 01fc9654f8 Refine default changelog text 2025-06-12 08:57:16 +02:00
darken c1b247c219 Release: 2.17.0-rc0 2025-06-06 07:47:56 +02:00
darken 101fb68205 Translate strings for new notification channel and update descriptions
This commit introduces translations for a new notification channel "Device connected" and updates some existing string descriptions in various languages.

Specifically, it:
- Adds translations for `settings_monitor_connected_notification_label`, `settings_monitor_connected_notification_description`, and `notification_channel_device_status_connected_label` in Spanish (es), French (fr), Estonian (et-rEE), Norwegian (no), Czech (cs), Polish (pl), Catalan (ca), Greek (el), and German (de).
- Updates the full app descriptions in `fastlane/metadata/android/` for Catalan (ca), German (de-DE), Spanish (es-ES), French (fr-FR), Estonian (et), Norwegian (no-NO), Czech (cs-CZ), Polish (pl-PL), and Russian (ru-RU) to replace "phone" with "device" in the context of connecting to AirPods.
- Adds missing Arabic (ar) translations for various strings related to debug logging, settings, troubleshooting, and onboarding.
- Fixes a typo in the German translation for `settings_general_description`.
- Corrects Hebrew (iw) translations for Bluetooth permission descriptions.
2025-06-06 06:57:29 +02:00
darken cda16ca06a Extend tests and fix payload parsing
This commit enhances the test coverage for `AirPodsProTest` by:
- Verifying the hex representation of the public payload data.
- Confirming that the private payload is null when no IRK/EncKey is set.
- Verifying the hex representation of the private payload data when IRK/EncKey are set.

Additionally, a fix is introduced in `AppleFactory.kt` to correctly parse the public payload by taking only the first 9 bytes instead of 16. This ensures that the private payload is only processed if the incoming message length matches the expected `PAIRING_MESSAGE_LENGTH`.
2025-06-06 06:03:41 +02:00
darken e9b29a2a43 Prioritize private payload for battery info in single headset devices
This commit modifies `SingleApplePods`, `AirPodsMaxUsbc`, and `AirPodsMax` to prioritize battery information from the decrypted private payload when available.

Specifically:
- `SingleApplePods.batteryHeadsetPercent`: Now checks `payload.private?.asBatteryState(1)?.level` first. If the private payload is not available or doesn't contain battery information at position 1, it falls back to the existing logic using `pubPodsBattery`.
- `AirPodsMaxUsbc.isHeadsetBeingCharged` and `AirPodsMax.isHeadsetBeingCharged`: Now check `payload.private?.asBatteryState(1)?.isCharging` first. If the private payload is not available or doesn't contain charging status at position 1, they fall back to the existing logic using `pubFlags.isBitSet(0)`.

This change allows for more accurate battery level and charging status reporting when the private payload can be decrypted.
2025-06-06 06:03:41 +02:00
darken 5df809a492 Improve main device determination logic
This commit refines the logic for selecting the main Pod device. Previously, the `RPAChecker` was used directly to verify the Identity Resolving Key (IRK).

The new approach leverages the `isIRKMatch` flag within the `ApplePods` class. This flag is set during the device parsing process, indicating whether the device's address matches the configured IRK.

This change:
- Simplifies the `PodMonitor` by removing the direct dependency on `RPAChecker`.
- Encapsulates the IRK matching logic within the `ApplePods` class, improving modularity.
- Ensures that the main device selection correctly prioritizes devices whose IRK has been matched.
2025-06-06 06:03:41 +02:00
darken ffe8a58746 Introduce ApplePods.Flags and UI for key status
This commit introduces a new `Flags` data class within `ApplePods` to store boolean flags related to the device, specifically `isIRKMatch`.

This flag is now passed during the creation of `ApplePods` instances in all `ApplePodsFactory` implementations and the central `AppleFactory`.

The UI has been updated to display a key icon in the overview cards (`DualPodsCardVH` and `SinglePodsCardVH`):
- The icon is only visible if `flags.isIRKMatch` is true.
- The icon changes to a filled key (`ic_key_24`) if `payload.private` is not null (indicating successful decryption), otherwise an outline key (`ic_key_outline_24`) is shown.
- Minor layout adjustments were made in `overview_pods_single_item.xml` and `overview_pods_dual_item.xml` to accommodate the new key icon.
2025-06-06 06:03:41 +02:00
darken eab0296125 Introduce helper for parsing battery state
This commit introduces a new `BatteryState` data class and an extension function `ProximityPayload.Private.asBatteryState` to simplify the parsing of battery level and charging status from the proximity payload.

The `asBatteryState` function takes the position of the battery data in the payload and returns a `BatteryState` object, or null if the data is invalid.

This change improves code readability and maintainability by encapsulating the battery state parsing logic.
2025-06-06 06:03:41 +02:00
darken 2ff143b858 Determine case charging state more accurately
The previous logic for determining if the case was charging relied on the `batteryCasePercent` being non-null. This commit refines the logic to check if the `batteryCasePercent` falls within the valid range of 0.0 to 1.0, providing a more accurate assessment of the case's charging status.
2025-06-06 06:03:41 +02:00
darken 3b2dcc1a5c Refactor Pods history to a central PodHistoryRepo
This moves the history tracking logic for discovered Apple Pods from individual `ApplePodsFactory` implementations to a new `PodHistoryRepo` class.

Benefits:
- Centralized logic for device history management.
- Simplifies individual `ApplePodsFactory` implementations.
- Improves code maintainability and testability.
2025-06-06 06:03:41 +02:00
darken d59dc8c225 Move RPA checker and key types
Relocate RPAChecker to the `pods.core.apple.protocol` package as it's specific to Apple's protocol.

Introduce type aliases `IdentityResolvingKey` and `ProximityEncryptionKey` for `ByteArray` to improve code clarity and type safety when dealing with these keys. Update relevant classes to use these new type aliases.
2025-06-06 06:03:41 +02:00
darken 877c5d9013 Introduce BluetoothAddress typealias
This commit introduces a typealias `BluetoothAddress` for `String` to improve code clarity and type safety when dealing with Bluetooth MAC addresses.

The following changes were made:
- Created `BluetoothAddress.kt` defining the typealias.
- Updated various classes to use `BluetoothAddress` instead of `String` for Bluetooth addresses:
    - `RPAChecker`
    - `PopUpReaction`
    - `DeviceSelectionDialogFactory`
    - `MonitorWorker`
    - `GeneralSettings`
    - `BluetoothDevice2`
2025-06-06 06:03:41 +02:00
darken 11a19da0b8 Add helper for HEX string to byte array conversion
This commit introduces a new helper function `fromHex()` that converts a HEX string (with optional separators like spaces or hyphens) into a byte array. It also adds a corresponding `toHex()` function for converting a byte array to a HEX string.

These helpers are now used in various parts of the codebase, including:
- Fake BLE data generation
- AirPod key input dialog
- RPA checker tests
- BaseAirPodsTest

Additionally, a unit test for these conversion functions has been added.
2025-06-06 06:03:41 +02:00
darken eda81822aa Add more tests for AirPods data decryption
This commit adds a test case to `AirPodsProTest` to verify the decryption of AirPods Pro data with and without encryption/IRK keys. It also introduces a new test class `RPACheckerTest` to validate the RPA checking mechanism.

Helper functions for converting hex strings to byte arrays and setting mock keys have been added to `BaseAirPodsTest` to simplify test setup.
2025-06-06 06:03:41 +02:00
darken 3b00613dde Refactor proximity message handling, introduce "Payload" that splits the data in public and private part. 2025-06-06 06:03:41 +02:00
darken a434a0bee0 Disable encryption key setting if no identity key
The encryption key setting for the main device is now disabled if the identity key is not set. This prevents users from trying to set an encryption key for a device that doesn't have an identity key.
2025-06-06 06:03:41 +02:00
darken 33ca43eaf1 Use decrypted proximity data if available
This change introduces the capability to decrypt the last 16 bytes of the proximity pairing message if an encryption key is available.

The decrypted payload provides more precise battery information (0-100% instead of 0-10%) and charging status for each pod and the case.

This information is now used by `DualApplePods` to enhance battery reporting accuracy.

The `RPAChecker` has also been updated with improved error logging.
2025-06-06 06:03:41 +02:00
darken df4ec3501b Add resolvable private address check
This allows verifying the identity of a device if its Identity Resolving Key (IRK) is known.
The IRK can be optionally configured.
2025-06-06 06:03:41 +02:00
darken fc54e540e8 Code fluff
Replace `values()` with `entries` for enum iteration, as recommended by Kotlin for improved performance and consistency.

Also:
- Suppress unused warning in `ByteArrayAdapter`
- Improve logging in `WebpageTool` by including the exception details.
2025-06-06 06:03:41 +02:00
darken e8481cc8cd Add preferences for AirPods Identity and Encryption Keys
This commit introduces settings for users to input their AirPods' Identity Resolving Key (IRK) and Encryption Key. These keys, obtainable via a MacBook, allow CAPod to reliably identify the AirPods and decrypt detailed status information.

Key changes:
- Added new preferences in General Settings for IRK and Encryption Key.
- Implemented a custom dialog for key input, including validation and a link to a guide.
- Added a Moshi adapter for serializing/deserializing `ByteArray` to/from Base64 for storing the keys.
- Included new string resources for labels, descriptions, and explanations related to the keys.
- Added new icons for the key preferences.
2025-06-06 06:03:41 +02:00
darken eb8a9b3b46 Add option for separate connected notification
Closes #28
Closes #18
2025-05-14 21:21:04 +02:00
darken 79ec3ba932 Remove obsolete configuration in manifest 2025-05-14 21:04:28 +02:00
darken 8f910b6dcc Group reaction preferences into categories 2025-05-14 19:35:54 +02:00
darken 92fa181925 Decrease default minimum signal quality 2025-05-14 19:35:47 +02:00
darken ca82f96b30 Update translations 2025-05-13 19:54:01 +02:00
darken 2ea2bb1765 Update Play Billing library to 7.1.1 2025-05-13 19:02:02 +02:00
darken fb22798469 Use correct log levels for workmanager 2025-05-13 19:02:02 +02:00
darken d48f475a34 Update desugar_jdk_libs to 2.1.5 2025-05-13 19:02:02 +02:00
darken c88c57e856 Update gradle dependencies and configurations 2025-05-13 19:02:02 +02:00
darken 17892fbd24 Fix test runner 2025-04-11 03:02:23 +02:00
darken 2352974d4d Update github actions (based on SDMSE) and use pinning 2025-04-11 03:02:23 +02:00
darken e216fbf66e Update privacy policy link 2025-03-21 19:36:03 +01:00
Matthias Urhahn f3a947a8a3 Update PRIVACY_POLICY.md 2025-03-21 19:36:03 +01:00
darken 00264c1d2b Remove link to Twitter 2025-03-21 19:24:07 +01:00
darken 54a02837fd Update description with feedback from Crowdin 2025-03-21 19:23:06 +01:00
Matthias Urhahn 7a5af76ebb Create CNAME 2025-03-19 22:30:14 +01:00
Matthias Urhahn 92bd754623 Create plain.html 2025-03-19 22:29:09 +01:00
Matthias Urhahn 85b9978e05 Create _config.yml 2025-03-19 22:28:41 +01:00
darken 1ebb2319d4 Add thanks note 2024-12-05 13:43:06 +01:00
darken f956625d2e Update translations 2024-12-05 13:43:06 +01:00
darken 9c2ebd9d04 Update CLI tool 2024-12-05 13:43:06 +01:00
darken fb0527b0bf Release: 2.16.0-rc0 2024-11-08 11:18:14 +01:00
darken 6b922e8845 Fix AirPods Max and AirPods Max (USB-C) support
Closes #236
2024-11-08 09:24:44 +01:00
b4shtirk1n 663e2d3b08 add support airpods max 2 & fix airpods max 2024-11-08 08:23:46 +01:00
darken 858b86af6a Fix onboarding not being completable with large fontsizes 2024-09-30 16:17:11 +02:00
darken 647300fd3a Release: 2.15.0-rc0 2024-09-23 15:05:48 +02:00
darken 3eaf99390e Record debug logs without foreground service
Some overhaul of log recording to get rid of the `FOREGROUND_SERVICE_SPECIAL_USE` which is a headache with Google
2024-09-23 15:05:11 +02:00
darken 0ee7915e31 Fix WorkManager init crash
```
Caused by java.lang.IllegalStateException: WorkManager is not initialized properly.  You have explicitly disabled WorkManagerInitializer in your manifest, have not manually called WorkManager#initialize at this point, and your Application does not implement Configuration.Provider.
  at androidx.work.impl.WorkManagerImpl.getInstance (WorkManagerImpl.java)
  at androidx.work.impl.foreground.SystemForegroundDispatcher.<init> (SystemForegroundDispatcher.java)
  at androidx.work.impl.foreground.SystemForegroundService.initializeDispatcher (SystemForegroundService.java)
  at androidx.work.impl.foreground.SystemForegroundService.onCreate (SystemForegroundService.java)
  at android.app.ActivityThread.handleCreateService (ActivityThread.java:4726)
```
2024-09-23 14:12:25 +02:00
darken 10c880a800 Do staged rollouts via fastlane 2024-09-23 13:53:18 +02:00
Matthias Urhahn b1bf658ff3 Update README.md 2024-09-23 13:51:09 +02:00
Matthias Urhahn 77dc13d9ba Merge pull request #228 from d4rken-org/airpods-gen4-ANC
Add support for AirPods (Gen 4 with ANC)
2024-09-23 13:41:21 +02:00
darken 21b6cfc013 Add support for AirPods (Gen 4 with ANC)
Closes #226
2024-09-23 13:33:12 +02:00
Matthias Urhahn 5d953ee23e Merge pull request #227 from d4rken-org/airpods-gen4
Add support for AirPods (Gen 4 - no ANC)
2024-09-23 13:32:30 +02:00
darken 42f2f5bfee Add support for AirPods (Gen 4)
Closes #225
2024-09-23 13:08:35 +02:00
darken 6708901394 Improve BLE scanning
The default "balanced" mode was reporting data too infrequently.
2024-07-26 08:48:21 +02:00
darken 788038beb7 Release: 2.14.1-rc0 2024-07-12 21:47:39 +02:00
darken 18ac05ed80 Fix crashes when entering settings
Likely from #209
2024-07-12 21:47:04 +02:00
darken 02f68821d2 Release: 2.14.0-rc0 2024-07-12 15:04:28 +02:00
darken 62886f00f1 Show battery values in notification content titles 2024-07-12 14:47:01 +02:00
darken f2eb45d00b Fix app crash on launch if permission are not granted
```java
12:49:29.433 AndroidRuntime                       E  FATAL EXCEPTION: main
                                                     Process: eu.darken.capod, PID: 15164
                                                     java.lang.SecurityException: Starting FGS with type connectedDevice callerApp=ProcessRecord{cb03698 15164:eu.darken.capod/u0a352} targetSDK=34 requires permissions: all of the permissions allOf=true [android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE] any of the permissions allOf=false [android.permission.BLUETOOTH_ADVERTISE, android.permission.BLUETOOTH_CONNECT, android.permission.BLUETOOTH_SCAN, android.permission.CHANGE_NETWORK_STATE, android.permission.CHANGE_WIFI_STATE, android.permission.CHANGE_WIFI_MULTICAST_STATE, android.permission.NFC, android.permission.TRANSMIT_IR, android.permission.UWB_RANGING, USB Device, USB Accessory]
                                                     	at android.os.Parcel.createExceptionOrNull(Parcel.java:3242)
                                                     	at android.os.Parcel.createException(Parcel.java:3226)
                                                     	at android.os.Parcel.readException(Parcel.java:3209)
                                                     	at android.os.Parcel.readException(Parcel.java:3151)
                                                     	at android.app.IActivityManager$Stub$Proxy.setServiceForeground(IActivityManager.java:7167)
                                                     	at android.app.Service.startForeground(Service.java:863)
                                                     	at androidx.work.impl.foreground.SystemForegroundService$Api31Impl.startForeground(SystemForegroundService.java:194)
                                                     	at androidx.work.impl.foreground.SystemForegroundService$1.run(SystemForegroundService.java:130)
                                                     	at android.os.Handler.handleCallback(Handler.java:959)
                                                     	at android.os.Handler.dispatchMessage(Handler.java:100)
                                                     	at android.os.Looper.loopOnce(Looper.java:232)
                                                     	at android.os.Looper.loop(Looper.java:317)
                                                     	at android.app.ActivityThread.main(ActivityThread.java:8699)
                                                     	at java.lang.reflect.Method.invoke(Native Method)
                                                     	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:580)
                                                     	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:886)
2024-07-12 13:44:57 +02:00
darken 2fca9097c2 Fix up-navigation behavior when doing onboarding
Back button shouldn't take you dashboard -> onboarding or onboarding -> dashboard
2024-07-12 13:44:44 +02:00
Ricky Cheung 051677e7dd Prevent return@apply duplicates
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung fbcd595b45 Add more spacing around the wide variant of dual pods widgets
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung aa38f58f62 setCustomContentView() -> setCustomBigContentView()
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung a3b01a02ee Update all checkbox preferences to SUW switches
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung 8544983984 Update com.google.android.material to 1.12.0
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-07-12 12:18:24 +02:00
Ricky Cheung 07a11799b1 Implement support for different states in notification text
Fixes #198

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-27 08:34:19 +02:00
Kavish Devar 65a7b1933a Add PUSH_NOTIFICATIONS permission to AndroidManifest.xml for wearos (#203)
* Add PUSH_NOTIFICATIONS permissions to AndroidManifest.xml for wearos

Fixes https://github.com/d4rken-org/capod/issues/154

* Fix typo in AndroidManifest.xml for wearos app
2024-06-26 12:41:47 +02:00
Ricky Cheung bbfb735124 Enlarge the icons on wide widgets
* Also move the case details to the centre and separate the text a little

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 7b474fb0e1 Fix up comment
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung c6524d7cee Log layout ID in createDualPodLayout
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 148cdf5109 Add more notes for the wide widgets
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 60a3039415 Annotate layout argument as LayoutRes
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 6f7b6e30c9 Fix status bar icons in light theme
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 08c095eae4 Switch all MaterialComponents for Material3 for Wear
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 9087be6282 Add wide variant of dual pod widget
* Wide variant activates when the widget is larger than 3 columns in length

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung d46fe35eab Make widgets consistent with each other
Going by 2f9185efa7, it appears the label is preferred to be at the bottom

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung 1afb77ee33 Make the status bar transparent
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
Ricky Cheung a9215fc11c Switch to everything Material3 components
This commit modifies all major elements of the UI for phones to use Material3 elements, including the toolbar.

Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
2024-06-26 12:41:24 +02:00
darken d80a110280 Thank the MagicPods project 2024-04-30 16:10:06 +02:00
darken fdfc7f44fb Update translations 2024-04-30 15:37:18 +02:00
darken 26fbfc08f7 Remove proprietary Google Play info block in FOSS builds 2024-04-05 18:42:11 +02:00
hwlnx 89049ee3ab Update preferences_support.xml 2024-03-11 13:30:22 +01:00
hwlnx 68f81f900b Update strings.xml 2024-03-11 13:29:19 +01:00
darken 513bd891af Release: 2.13.0-rc0 2024-02-25 13:30:10 +01:00
darken 37cd753ab8 Update README 2024-02-25 13:29:13 +01:00
darken 34bf2311b4 Add support for PowerBeats4
Thanks to @docgalaxyblock
2024-02-25 13:29:13 +01:00
darken a86093f617 Update translations 2024-02-25 13:16:19 +01:00
Matthias Urhahn 71f66e6004 Merge pull request #182 from JerretQueen/main
Onboarding typo fixed, dual pods layout realigned.
2024-02-25 13:12:31 +01:00
Matthias Urhahn 2d1ab85e01 Update README.md 2024-02-25 13:11:46 +01:00
Matthias Urhahn f029eb9102 Merge pull request #179 from hwlnx/patch-1
Update strings.xml
2024-02-25 13:06:15 +01:00
Matthias Urhahn 471837ab88 Update gradle-wrapper.properties 2024-02-25 13:05:05 +01:00
Matthias Urhahn 1e80d742cc Update build.gradle.kts 2024-02-25 13:04:38 +01:00
Matthias Urhahn 16b6273938 Update build.gradle.kts 2024-02-25 13:04:08 +01:00
JerretQueen 3407370563 1. Fixed a typo in @string/onboarding_body2. The message now correctly says "implementation" instead of "implemtation".
2. Changed layout of overview_pods_dual_item.xml so that the left and right pod status are properly lined up with each other.
2023-12-30 00:14:24 -06:00
hwlnx 3cda73a36c Update strings.xml 2023-11-19 20:05:42 +01:00
darken b604c423ad Release: 2.12.1-rc0 2023-10-28 19:51:26 +02:00
darken 0c8b121c2c Request FOREGROUND_SERVICE_SPECIAL_USE for debug log service 2023-10-28 19:50:56 +02:00
darken da11961611 Release: 2.12.0-rc0 2023-10-28 18:36:11 +02:00
darken 48b9952fc0 Update translations 2023-10-28 18:21:50 +02:00
darken 99fa99eba5 Update billing client 2023-10-28 18:03:28 +02:00
darken 06e67e04ac Move permission 2023-10-28 17:58:09 +02:00
darken 4a2f7c20a2 Update unit test 2023-10-28 17:58:09 +02:00
darken 82e8769407 Bump dependencies and API targets. 2023-10-28 17:58:09 +02:00
darken ad38702df1 Widget: Center device label 2023-10-28 16:59:52 +02:00
darken 3f98c62a8f Extend test case for AirPods Pro 2 (USB-C)
Closes #164
2023-09-24 12:01:15 +02:00
darken ec7447992f Release: 2.11.1-rc0 2023-09-23 21:44:04 +02:00
Matthias Urhahn 99958e13eb Update translations (#166) 2023-09-23 21:43:24 +02:00
Matthias Urhahn b9fe5d4a14 Update README.md 2023-09-23 21:38:44 +02:00
darken 1443fd5714 Support for Apple AirPods Pro2 with USB-C
See #164
2023-09-23 21:35:30 +02:00
darken 798083b235 Remove link from changelog notes (eggshells) 2023-09-11 23:35:20 +02:00
darken 18a7311089 Release: 2.11.0-rc0 2023-09-11 22:45:47 +02:00
darken 9f49d62f12 Clean up store description and remove WearOS sentences. 2023-09-11 21:09:02 +02:00
darken 0c3c4e7ea1 Update translations 2023-09-11 21:09:02 +02:00
darken 7cdf653842 Remove WearOS part from app description, it's no longer on Google Play. 2023-09-11 21:09:02 +02:00
darken 32d99eada8 Prevent app from crashing if no app to view urls is installed 2023-09-11 20:57:15 +02:00
darken 9e4adb16c6 Hide install, not useful without 3rd party bug tracker 2023-09-11 20:33:06 +02:00
darken 4b5638e4f0 Hide bug reporting setting (but lets keep the setting ID because if we re-add we might use the setting state again). 2023-09-11 20:32:53 +02:00
darken d8fb9cdd30 Remove bugsnag 2023-09-11 20:32:53 +02:00
Matthias Urhahn 3920489dcd Extra onboarding step (#158)
* wip

* Add extra onboarding step
2023-09-11 20:32:41 +02:00
Matthias Urhahn 09d013588d Update PRIVACY_POLICY.md 2023-09-11 01:33:38 +02:00
darken d8bd03c65d Remove WearOS build from Google Play pipeline. 2023-07-16 01:05:04 +02:00
darken 8b2c6b9498 Release: 2.10.0-rc2 2023-07-07 10:13:48 +02:00
darken 0f63d3a0d4 Revert "Don't bump WearOS, not needed due to new track system?"
This reverts commit a321bb733c.
2023-07-07 10:12:56 +02:00
darken 672eb333b0 Update fastlane 2023-07-07 10:12:24 +02:00
darken de609b4d05 Release: 2.10.0-rc1 2023-07-07 09:05:38 +02:00
darken 7e6e9eee36 Try WearOS first as it is more likely to fail. 2023-07-07 09:04:34 +02:00
darken a321bb733c Don't bump WearOS, not needed due to new track system? 2023-07-07 09:04:34 +02:00
darken 490369a3e1 Adjust CI to build and upload to WearOS beta/production tracks 2023-07-07 09:04:34 +02:00
darken a2cce044ef Release: 2.10.0-rc0 2023-07-05 09:16:53 +02:00
darken 8884bb88c7 Update translations 2023-07-05 09:15:44 +02:00
darken 313b0e9131 Include changelog in changelog. 2023-07-05 09:15:44 +02:00
darken 47048cf7e7 Update translations 2023-07-05 09:15:44 +02:00
darken ef1c4615aa Add reaction option: Popup on new connection 2023-07-05 08:21:37 +02:00
Matthias Urhahn c9587b2bf0 Update translations (#147)
* Update translations

* Update translations
2023-07-04 12:41:14 +02:00
Sean Singh 669fa2fd22 Support edge-to-edge rendering on Android Q+ (#146)
* Set nav bars transparent in themes.xml files

* App renders edge-to-edge; need to handle scroll overlaps

* Add fake padding

* Begin version separating

* Edge-to-edge working everywhere except main page

* Finalized

* Cleanup Finalized

* Cleanup Finalized

* Removed redundant values-v29 & optimize imports

* Consolidated insets to Fragment2

* Consolidated insets to Fragment2 (file wasn't staged)
2023-07-04 12:17:44 +02:00
darken fb8ec72825 Release: 2.9.3-rc0 2023-06-27 22:07:22 +02:00
Matthias Urhahn d1687f574f Merge pull request #145 from d4rken-org/bump_workmanager
Fix background execution errors.
2023-06-27 22:04:28 +02:00
darken 72f47bec1f Fix background execution errors.
Bump workmanager `2.7.1` -> `2.8.1` to fix

```java
android.app.ForegroundServiceStartNotAllowedException: startForegroundService() not allowed due to mAllowStartForeground false: service eu.darken.capod/androidx.work.impl.foreground.SystemForegroundService
```
2023-06-27 21:55:58 +02:00
Matthias Urhahn 20e8aecf83 Merge pull request #144 from d4rken-org/gplay_hates_splash
Fix "Missing app icon in splash screen"
2023-06-27 21:49:50 +02:00
Matthias Urhahn ee41474670 Merge pull request #143 from d4rken-org/gplay_wear_policies_fml
Fix "Missing show time"
2023-06-27 21:49:37 +02:00
darken ac29e28eda Fix "Missing app icon in splash screen"
> Your app does not show a 48x48dp app icon on a black background during app startup. For more information, see Branded launch.
2023-06-27 21:49:05 +02:00
darken 58b96cbdee Fix "Missing show time"
> Your app does not display the time of day clearly at the top of the app home screen and any ongoing activity screens. We recommend that you display the time of day at the top of all activities except dialog and confirmation screens. For more information, see Show the time.
2023-06-27 21:12:16 +02:00
darken 0c1fc15251 Update translations 2023-06-27 20:28:31 +02:00
darken 7b8e0cc952 Update translations 2023-06-27 20:28:31 +02:00
darken c8aa591feb Update AGP 2023-06-27 20:28:21 +02:00
darken afa4d6e807 Release: 2.9.2-rc0 2023-05-16 09:23:59 +02:00
darken e6b7eda9e7 Update store translations 2023-05-16 09:23:09 +02:00
darken bbc4e05c8f Update translations 2023-05-16 09:23:09 +02:00
darken 50bccbaa2e Update Crowdin tooling 2023-05-16 09:23:09 +02:00
Matthias Urhahn d71e83a2af Merge pull request #132 from d4rken-org/tweak_widget_layout
Tweak widget layout
2023-05-16 09:14:30 +02:00
Matthias Urhahn 92a67702b7 Merge pull request #131 from d4rken-org/gradle_bump
Gradle bump
2023-05-16 09:04:46 +02:00
darken 2f9185efa7 Tweak dual pod widget.
Less padding to reduce cutoff chances and put the label at the type bottom as it's the least important info.
2023-05-16 09:00:58 +02:00
darken a10216520b Fix resource reference 2023-05-16 08:59:54 +02:00
darken d5354e5156 Fix resource reference 2023-05-16 08:55:35 +02:00
darken a8f9b1f732 Use non final resource ids 2023-05-16 08:07:48 +02:00
darken 08a796e621 Fix wear namespace 2023-05-16 08:05:59 +02:00
darken 15f3a68f8e Use non transitive R classes to improve performance 2023-05-16 07:58:28 +02:00
darken e0bc6dafa0 Bump gradle 2023-05-16 07:46:13 +02:00
darken 430c1236d7 Fix typo 2023-05-07 20:15:41 +02:00
darken 3ac357161a Improve strings based on translator feedback 2023-05-07 20:15:41 +02:00
darken 3c5f1bf21f CI: Remove unused base flavor, all our modules are flavored :) 2023-04-07 11:00:44 +02:00
darken 5544cd3770 CI: Update action dependencies, improve naming and use more specific build commands 2023-04-07 11:00:44 +02:00
darken 71a060feb9 Release: 2.9.1-rc0 2023-04-05 16:19:46 +02:00
darken 25d643257c Update translations 2023-04-05 13:13:42 +02:00
darken 85900e5bff Improve final headphones checks and fix IllegalStateException if data changes between the final two checks. 2023-04-05 13:13:42 +02:00
darken d3257d6c5e Improve troubleshooting texts 2023-04-05 13:13:42 +02:00
darken afe1399f72 Release: 2.9.0-rc0 2023-04-02 22:25:17 +02:00
Matthias Urhahn 20ce2d3e22 A troubleshooter that automatically goes through different compatibility settings. (#125) 2023-04-02 22:22:15 +02:00
Matthias Urhahn cea8f2290d More IAP improvements (#121)
* More IAP improvements

* More refactoring

* More tolerance towards GPlay API issues.

* Bump billing dependency

* Migrate away from deprecated methods.
2023-03-20 08:21:54 +01:00
darken 1b5b59c41b Release: 2.8.4-rc0 2023-03-13 17:28:28 +01:00
Matthias Urhahn 8df7c19e84 Fix billing client disconnection handling (#117) 2023-03-13 17:28:01 +01:00
darken 88b0069b94 Release: 2.8.3-rc0 2023-03-13 13:48:11 +01:00
Matthias Urhahn 3b8ddc9f06 Fix reversed AirPods Max wear-detection (#116)
* Fix reversed AirPods Max wear-detection
Fixes #115

* Remove unnecessary test
2023-03-13 13:47:32 +01:00
darken 708c7f38ec Merge remote-tracking branch 'origin/main' into main 2023-03-12 14:05:25 +01:00
darken 139daa1cc2 Update full gplay text 2023-03-12 14:05:20 +01:00
Matthias Urhahn b85ac778c0 Address that gplay api can emit multiple times (#114) 2023-03-12 02:58:37 +01:00
Matthias Urhahn a3e2c9e61d Bump dependencies and gradle (#113) 2023-03-11 18:16:24 +01:00
Matthias Urhahn ad78a0570c For some reason we can finish the widget update broadcast twice. (#112)
I can't yet determine why this race-condition occurs, due to the forced timeout, it shouldn't be due to exceeding the receiver ANR window...
2023-03-11 13:01:15 +01:00
Matthias Urhahn 7e6ee274d8 Quick data updates can cause concurrent access and modifying of the notification builder. (#111)
Internally building the notification can then throw a ConcurrentModificationException.
2023-03-11 13:01:05 +01:00
Matthias Urhahn daf7411c36 Show worker foreground notification early. (#110) 2023-03-11 13:00:55 +01:00
darken d3dedbe6cd Release: 2.8.2-rc0 2023-03-10 12:13:30 +01:00
Matthias Urhahn d5411586d5 Update translations (#109)
* Update translations, add Portuguese

* Update translations
2023-03-10 12:12:31 +01:00
Matthias Urhahn 94e2d40554 Fix AirPods Max wear detection. (#108)
Closes #106
2023-03-10 11:30:40 +01:00
Matthias Urhahn db80fa1b46 Wear: Remove unused code (#104)
* Wear: Remove unused code

* Remove more code
2023-03-02 16:39:11 +01:00
Matthias Urhahn 9b569b8d07 Update Turkish translation (#100) 2023-02-20 09:39:27 +01:00
darken 90288880fb Add f-droid as source 2023-02-20 09:14:20 +01:00
darken 870e518ff6 Release: 2.8.1-rc0 2023-02-09 22:01:02 +01:00
darken 6d89169bae Update release.sh script to create default changelogs for f-droid 2023-02-09 21:59:44 +01:00
darken ac77ee2703 Update default changelog for GPlay 2023-02-09 21:53:33 +01:00
darken 18dcf603e9 Release: 2.8.0-rc0 2023-02-03 22:32:01 +01:00
Matthias Urhahn 71aaba1b86 Fix data display for non dual apple pods (#98)
* Fix data display for fake pods and beats devices (with two pods)

* Rename methods
2023-02-03 22:31:23 +01:00
Matthias Urhahn 64b9d5f17a Remove ~ and % from signal quality value, it's not necessarily a 0 to 100 percent indicator. (#96) 2023-02-02 16:47:06 +01:00
Matthias Urhahn a9f23c4bfe Change defaults setting for "Show all devices" (#95)
* Fix potential widget issue where a broadcast race condition causes the goAsync() to fail.

* Enable "show all devices" by default to help new users.
2023-02-02 16:46:52 +01:00
Matthias Urhahn f454f46778 Fix potential widget issue where a broadcast race condition causes the goAsync() to fail. (#94) 2023-02-02 16:46:36 +01:00
Matthias Urhahn 432936ea2c Merge pull request #93 from d4rken-org/crowd_reliability
Improve tracking in crowds
2023-02-02 16:46:08 +01:00
Matthias Urhahn 8211b0ac88 Track case battery state outside of history list and simplify history clean up. (#92) 2023-02-02 16:45:39 +01:00
Matthias Urhahn c314987e13 Fix popup icon sizes (#91) 2023-02-02 16:45:22 +01:00
Matthias Urhahn fc6cd74752 If the current pods don't have a lid state value, and there is none in recent history, we can assume NOT_IN_CASE. (#90) 2023-02-02 16:45:05 +01:00
Matthias Urhahn a47011e579 Merge branch 'main' into crowd_reliability 2023-02-02 14:57:04 +01:00
darken fbeeec6539 Boost signal quality rating by up to 25% depending on how long they have been tracked. 2023-02-02 14:46:29 +01:00
darken e1cd79cd9d Cap reliability at 100% 2023-02-02 14:45:44 +01:00
darken 692ced1ee7 Timeout after 30 not 20 seconds. 2023-02-02 14:45:22 +01:00
darken c5cc127df4 Smooth rssi values and use reliability (missed signals) as boost, not as factor. 2023-02-02 11:50:33 +01:00
Matthias Urhahn 5f00f10ae8 Remove short hash from generated apk file names (#89) 2023-02-02 08:38:22 +01:00
Matthias Urhahn 0fc9716f06 Only apply Bugsnag to gplay build flavor (#88)
* wip

* Setup bugsnag only in gplay builds.

* Bugsnag does not support uploading mapping files or build information from library modules. This should be done from the application module which produces your APK instead.

* Only apply Bugsnag plugin for the right flavors.

* Run unit tests in debug, otherwise we run issues requiring api keys in the env
2023-02-01 11:17:50 +01:00
darken 48fd3543e3 Release: 2.7.2-rc0 2023-01-30 13:38:53 +01:00
Matthias Urhahn 41985794c2 Adjust supported AirPods Max features. (#86)
Remove state detection (was UNKNOWN when it should have been MUSIC).
Add PodStyle detection.
2023-01-30 13:37:40 +01:00
Matthias Urhahn 87a92fd001 Fix feature display for single pods inside the app. (#87) 2023-01-30 13:37:31 +01:00
darken f6f53f2a15 Release: 2.7.1-rc1 2023-01-28 22:14:47 +01:00
Matthias Urhahn 2531769b79 Update translations (#85)
Add arabic and update chinese (TW)
2023-01-28 22:12:25 +01:00
darken e9fa9f6cb8 Release: 2.7.0-rc0 2023-01-25 16:54:32 +01:00
Matthias Urhahn b98ca86886 Faster CI checks (#83)
* Parallelize CI code checks

* Only assemble debug variant, otherwise we need to setup signing.
2023-01-25 16:47:46 +01:00
Matthias Urhahn d917c8ffe6 New compatibility option for receiving BLE data (#82)
* Improve compat options: Add alternative method for receiving BLE scan results via PendingIntents

* Add missing logtags

* Reduce log spam

* Make the linter happy
2023-01-25 16:27:29 +01:00
Matthias Urhahn 5d44737199 Merge pull request #80 from d4rken-org/compatibility_options
Improve compatibility options
2023-01-25 14:56:10 +01:00
Matthias Urhahn 6e8693a6b0 Merge pull request #81 from d4rken-org/modularize_permissions
Move shared permissions into the common module
2023-01-25 14:55:54 +01:00
darken 6d9e7404a1 Improve compatibility options 2023-01-25 14:46:20 +01:00
darken 70ceb389b5 Move shared permissions into the common module. 2023-01-25 14:44:38 +01:00
darken 832e364a2f Update translations 2023-01-25 09:26:50 +01:00
darken 51f9c5c7d8 Release: 2.6.1-rc0 2023-01-20 12:55:28 +01:00
Matthias Urhahn 15141c8528 More granular pod features (#76)
* Make pod features more granular
Don't assume all AirPods/Beats return a correct connection state.

* Refactoring

* Fix refactoring regression

* AirPods Gen1 don't support state detection
2023-01-18 22:15:38 +01:00
Matthias Urhahn 0cdac10fd4 Update README.md 2023-01-14 16:45:11 +01:00
darken aaab548bfd Release: 2.6.0-rc0 2023-01-14 16:44:16 +01:00
Matthias Urhahn 46611b5606 Add preliminary support for Beats Fit Pro (#75) 2023-01-14 16:35:04 +01:00
Matthias Urhahn fdbdb46577 Overhaul play/pause reaction code (#74) 2023-01-14 16:21:57 +01:00
darken e7fd81d0eb Release: 2.5.3-rc1 2023-01-12 12:51:46 +01:00
darken ce50bae6a0 Merge branch 'main' of github.com:d4rken-org/capod into main 2023-01-12 12:51:03 +01:00
darken 8331cf3866 Release: 2.5.3-rc0 2023-01-12 12:50:53 +01:00
Matthias Urhahn 670262e53b Update README.md 2023-01-12 12:50:44 +01:00
Matthias Urhahn c584f942cf Support for fake AirPods 2. Generation (#73) 2023-01-12 12:32:44 +01:00
darken e83b6bb583 Update readme 2023-01-12 12:11:46 +01:00
darken cc5de668f0 Add additional test data for fake AirPods Pro2 2023-01-12 12:07:24 +01:00
darken 653c7b8c4b Release: 2.5.2-rc0 2023-01-06 16:45:28 +01:00
darken 4e38bcd4cc Fix left pod icon tint. 2023-01-06 16:44:30 +01:00
Matthias Urhahn fcbeda4888 Improve feature support for fake AirPods Gen3 (#72)
Remove microphone detection and add ear detection.
2023-01-06 16:40:03 +01:00
Matthias Urhahn 4101fddc5b Improve feature support for fake AirPods Gen1 (#71)
Remove microphone detection and add ear detection and pod charge detection.
2023-01-06 16:39:46 +01:00
darken 86df7bea22 Improve feature support for fake AirPods Pro
Remove microphone detection and add pod charging and ear detection.
2023-01-06 16:02:38 +01:00
darken 4db8c0c286 Add support for fake AirPods Pro 2 2023-01-06 16:02:25 +01:00
darken 1d03335903 Improve dual pod widget layout, use same style as notification. 2023-01-02 18:26:33 +01:00
darken b1b541014f Release: 2.5.1-rc0 2022-12-24 11:57:34 +01:00
darken b40a7e435f Update translations 2022-12-24 11:56:46 +01:00
Matthias Urhahn 8305fb4c5f Airpod pro2 icon iteration (#64)
* Update airpods pro icons to SVG variant

* Fix AirPodsPro2 not using the new icons

* Revert "Update airpods pro icons to SVG variant"

This reverts commit d7ca13d439.

* Add padding to icons
2022-12-24 11:48:32 +01:00
darken 6c678be511 Release: 2.5.0-rc1 2022-12-23 09:54:19 +01:00
darken 2823663f09 Merge branch 'main' of github.com:d4rken-org/capod into main 2022-12-23 09:53:08 +01:00
darken 033c2264f8 Release: 2.5.0-rc0 2022-12-23 09:52:59 +01:00
darken 8eb6ce19f7 Add support for specific left/right/case icons.
Add AirPods Pro 2 Icons by @christoschronopoulos
2022-12-23 09:52:17 +01:00
darken 1cfc4e6f65 Merge branch 'improve_fake_airpod_support' into main 2022-12-23 08:27:00 +01:00
darken 454d76e305 Release: 2.4.0-rc0 2022-12-19 17:10:30 +01:00
darken f3609fc3a5 Update translations 2022-12-19 17:09:55 +01:00
darken 16da51d095 Refactor fake airpods code and improve tests 2022-12-19 17:09:55 +01:00
darken 51fb4c7a29 Update translations 2022-12-19 17:03:17 +01:00
darken 0b27cb55dc Refactor fake airpods code and improve tests 2022-12-19 17:03:17 +01:00
darken 9e836eef69 Add support for fake AirPods Gen3 2022-12-19 16:32:09 +01:00
darken 07be4b8258 Release: 2.3.1-rc0 2022-11-07 22:51:12 +01:00
darken 217087c6d4 Clean up tests 2022-11-07 22:50:52 +01:00
Matthias Urhahn 2315a18c86 Improve history caching for AirPods. (#55)
Don't throw away the last history item that contains case infos.
2022-11-07 22:28:13 +01:00
Matthias Urhahn f59e9d2dc5 Fix comparison that determines which list item is the primary device. (#54) 2022-11-07 19:42:31 +01:00
Matthias Urhahn 1da220085c Merge pull request #53 from d4rken-org/improve_automatic_monitor_mode
Improve automatic monitor mode
2022-11-07 19:36:30 +01:00
Matthias Urhahn 962b2a01f1 Merge branch 'main' into improve_automatic_monitor_mode 2022-11-07 19:35:49 +01:00
darken eeda14f424 When in MonitorMode.AUTOMATIC and there is no mainDeviceAddress set, then just keep running while there is any connected headset device. 2022-11-07 19:25:31 +01:00
darken 6269e20085 When selecting automatic monitor-mode, check if the user has selected a device address, if not pop up the selection dialog. 2022-11-07 19:08:52 +01:00
Matthias Urhahn 3d05296eb2 Merge pull request #51 from GitGitro/main
add monochrome icon
2022-11-07 18:36:48 +01:00
GitGitro e90ab95cd0 add monochrome icon, closes #50,closes #23 2022-11-07 15:31:31 +01:00
Matthias Urhahn 23ec2f025e Update README.md 2022-11-05 12:31:21 +01:00
darken 624898dd5e Release: 2.3.0-rc0 2022-11-05 12:20:06 +01:00
darken c6d6604399 Merge remote-tracking branch 'origin/main' into main 2022-11-05 12:19:19 +01:00
darken 7473704954 Update changelog 2022-11-05 12:19:14 +01:00
Matthias Urhahn 7a6b216bf6 Update translations (#49) 2022-11-05 12:11:54 +01:00
Matthias Urhahn 1ef09124af Improve notification theming (#47)
* Make notification themes more dynamic to adapt to day/night/system themes.

* Fix broken style references
2022-11-05 12:11:33 +01:00
darken 6340f73d61 Throttle pod device data for the UI, notifications or widgets.
We don't need faster updates than 1 per second.
(We keep reactions at maximum speed tho)
2022-11-05 12:11:09 +01:00
darken b9f4992a33 A CAPod widget, oh my! ¯\_(ツ)_/¯ 2022-11-05 11:53:09 +01:00
darken 07a1fee013 Fix proguard rule inclusion 2022-10-28 22:27:50 +02:00
darken 63f37e51ad Release: 2.2.1-rc1 2022-10-27 17:40:57 +02:00
darken 01ea08a5cb Update release script 2022-10-27 17:38:50 +02:00
darken bddb20c5bb More version plumping 2022-10-27 17:38:50 +02:00
darken f9a97297da Add a release script base.
Based on https://gist.github.com/jv-k/703e79306554c26a65a7cfdb9ca119c6

WIP
2022-10-27 17:38:50 +02:00
darken 5fc2b56635 Update translations 2022-10-27 08:09:49 +02:00
darken 172953ccb3 Fix monitor mode being set to ALWAYS when visiting the reactions settings screen.
Fixes #41
2022-10-27 07:59:21 +02:00
darken d55300f53d Fix bold highlighting of the main pod device not being reset. 2022-10-22 10:49:45 +03:00
darken c3d4500e3a Version bump (v2.2.1-rc0) 2022-09-24 15:50:25 +02:00
darken cf72e28727 Fix app crash if Bluetooth adapter is null and we can't retrieve the list of bonded devices in settings. 2022-09-24 15:49:56 +02:00
darken b488a1be16 Version bump (v2.2.0-rc0) 2022-09-24 12:00:21 +02:00
darken 805d0cb4b6 Update translations 2022-09-24 11:59:58 +02:00
darken fa045c2504 Increase worker scheduling flex interval for WearOS. 2022-09-24 11:59:09 +02:00
Matthias Urhahn 23fd373a9b AirPods Pro 2 (#32)
* Preliminary support for AirPods Pro 2, based on #31, assuming identifier is `11 20`

* Fix AirPods Pro 2 identifier (it's 0x1420)

* Add second AirPods Pro 2 test case from reddit user.

* Update Readme with AirPods Pro 2
2022-09-24 11:51:53 +02:00
darken ab619cb073 Version bump (2.1.1-rc0) 2022-09-19 22:26:43 +02:00
darken 2d96822014 Add request for POST_NOTIFICATIONS on Android 13.
Closes #29
2022-09-19 22:26:43 +02:00
darken 887adc87de Reduce background monitor time to reduce battery usage. 2022-09-16 09:31:18 +02:00
darken 4219bd02c5 Setup fastlane for uploading app and wearos AAB 2022-09-14 21:40:13 +02:00
darken 48544464ae Version bump 2022-09-14 21:19:07 +02:00
darken 9b4f2477e4 Add sponsor link to settings toolbar 2022-09-14 21:15:21 +02:00
darken 26e8840484 On Foss upgrade navigate to gituhb sponsors directly 2022-09-14 21:06:09 +02:00
darken b6ef6d3ce9 Fix upgrade check 2022-09-14 21:00:15 +02:00
darken cdfcd24049 Ensure that BLEScanner only starts when all necessary permissions are granted.
(Or rather that scanner is restarted when permissions change).
+ some refactoring to improve readability
2022-09-14 20:51:39 +02:00
darken 95a8e0b125 Version bump 2022-09-14 19:55:56 +02:00
darken 84b7509f33 Adjust versioning for GPlay wear os bundle upload 2022-09-14 19:39:30 +02:00
darken a53baec9dc Version bump 2022-09-14 18:59:08 +02:00
darken 4b4bc86cf7 Fix Github release for WearOS APK 2022-09-14 18:31:34 +02:00
darken 4d3860089e Add fastlane wear screenshots 2022-09-14 18:00:18 +02:00
darken 758f55f13c Update README.md 2022-09-14 18:00:18 +02:00
darken 1e191c3adc More translation updates 2022-09-14 18:00:18 +02:00
darken 1ba2cf7c9d Sync translations with Crowdin (remove duplicate strings between modules) 2022-09-14 18:00:18 +02:00
darken 9ed1cc00d5 Fix crowdin configuration 2022-09-14 18:00:18 +02:00
darken 3be1173197 Tweak dual pods wear layout 2022-09-14 18:00:18 +02:00
darken f6e69c62b1 Clean up strings 2022-09-14 18:00:18 +02:00
darken 6d9679a7fc More cleanup 2022-09-14 18:00:18 +02:00
darken 6ea260a81f More resource cleanup 2022-09-14 18:00:18 +02:00
darken 26da600761 Fix module packagename 2022-09-14 18:00:18 +02:00
darken 33172a7aee Use the default low_latency mode for wearOS too, we want to get as many BLEScanRecords as possible in a short time frame and then sleep again. 2022-09-14 18:00:18 +02:00
darken 7eb9f6ac95 Fix failing test 2022-09-14 18:00:18 +02:00
darken fc06c285d5 Fix periodic checks aborting with no data. 2022-09-14 18:00:18 +02:00
darken ca2e4fec79 Implement periodic checks for wear 2022-09-14 18:00:18 +02:00
darken 87ca90f6cc Fix "last seen at". 2022-09-14 18:00:18 +02:00
darken 7dfea78c87 Simplify WearOS app because this is such a cluster fuck and I've ran out of motivation. 2022-09-14 18:00:18 +02:00
darken d7808009dc Tweak main overview 2022-09-14 18:00:18 +02:00
darken 62afb7a130 Cache main pod device 2022-09-14 18:00:18 +02:00
darken 87bef9cb9c Icon and reintroduce splash screen 2022-09-14 18:00:18 +02:00
darken 3860cb21c6 Code fluff 2022-09-14 18:00:18 +02:00
darken bf4ad7886c Improve wear os overview screen 2022-09-14 18:00:18 +02:00
darken 1d51c207f4 Basic working data display in wear-app 2022-09-14 18:00:18 +02:00
darken 2eb3f70e0c Wear UI wip 2022-09-14 18:00:18 +02:00
darken eb4d883194 More refactoring and modularization 2022-09-14 18:00:18 +02:00
darken 3f5c9ff9de Fix reaction settings fragment crash 2022-09-14 18:00:18 +02:00
darken 47039796de Nicer text display if we don't use raw github 2022-09-14 18:00:18 +02:00
darken fd054abcca Add codestyle 2022-09-14 18:00:18 +02:00
darken 2861bfbd47 Fix worker inject 2022-09-14 18:00:18 +02:00
darken 6ea3127f9c More refactoring to split up modules, both compile.
Wear is still WIP.
2022-09-14 18:00:18 +02:00
darken f7acb5e115 Fix copy paste regressions 2022-09-14 18:00:18 +02:00
darken b1ad1f7699 Cleanup 2022-09-14 18:00:18 +02:00
darken ab713df3c0 Fix warnings 2022-09-14 18:00:18 +02:00
darken e0d44f2f1c Use Kotlin for Gradle DSL 2022-09-14 18:00:18 +02:00
darken 61463bc05f More refactoring 2022-09-14 18:00:18 +02:00
darken a219fc3527 Fix build issues 2022-09-14 18:00:18 +02:00
darken 1cd4028ce8 Adjust crowdin config 2022-09-14 18:00:18 +02:00
darken de9a30eb93 Refactoring 2022-09-14 18:00:18 +02:00
darken 2210561af3 Refactoring 2022-09-14 18:00:18 +02:00
darken c5ddc474c4 Update translations 2022-09-14 18:00:18 +02:00
darken 688748da36 Go multi-module: app-common, app-phone, app-wear 2022-09-14 18:00:18 +02:00
darken 753d311593 Tweak versioning and bump (1.3.14-rc0) 2022-08-24 11:56:42 +02:00
darken 5bae3d9931 Remove in-app email shortcut 2022-08-24 11:40:16 +02:00
darken a3495a8218 Update translations 2022-08-24 11:33:48 +02:00
Poussinou 5fa68f77a2 Update README.md 2022-07-31 11:13:51 +02:00
darken dbba2e9791 Another fastlane gplay upload fix. 2022-07-25 21:00:43 +02:00
darken 6207f26a68 Update fastlane to 2.208 2022-07-25 20:57:24 +02:00
darken 7fcd39822e Update fastlane to 2.208 2022-07-25 20:56:45 +02:00
darken d20f8a604f Fix CI issues with Google Play upload (part 2...) 2022-07-25 20:47:26 +02:00
darken a742646127 Fix CI github release apk search path. 2022-07-25 20:09:37 +02:00
darken f5078d5ef2 Tell fastlane to skip APK upload 2022-07-25 20:04:22 +02:00
darken 3784084abc Fix fastlane build arguments 2022-07-25 19:53:23 +02:00
darken 2f463b7735 Adjust description to pass markdown parsing 2022-07-25 19:42:15 +02:00
darken 7be5625d66 Fix gplay deploy script 2022-07-25 19:40:49 +02:00
darken aef14a9cd2 Create changelog file for v1.3.13(1031300) 2022-07-25 17:42:26 +02:00
darken 67648a04d3 Update translations 2022-07-25 17:36:29 +02:00
Matthias Urhahn 58345e3194 Update FUNDING.yml 2022-07-25 10:22:32 +02:00
darken 4cb2ad9a5a Adjust support item order 2022-07-25 01:29:18 +02:00
darken 27395d8e22 Remove email from readme 2022-07-25 01:29:07 +02:00
darken 230c5ea180 Adjust donation links in foss build 2022-07-25 01:22:07 +02:00
Matthias Urhahn 4d4370ddce Update README.md 2022-07-24 16:01:07 +02:00
darken 7e913e7847 Make bugsnag opt-out in gplay and opt-in in foss builds. 2022-07-24 16:00:38 +02:00
darken ec7871e4e9 Change issue tracker icon 2022-07-24 16:00:07 +02:00
darken cc5a45a14f Update repo links 2022-07-24 16:00:07 +02:00
Matthias Urhahn 5fff3457ba Update README.md 2022-07-24 15:03:19 +02:00
darken 6f5d66c1d9 Update README.md 2022-07-24 14:02:53 +02:00
darken d94795bf4f Update PRIVACY_POLICY.md 2022-07-24 13:58:17 +02:00
darken 009b71c42a Update PP 2022-07-24 13:43:47 +02:00
darken a10716d74e Update gitignore 2022-07-24 13:43:06 +02:00
darken b44bf5f401 Setup CI for testing, gplay release and foss release. 2022-07-24 13:38:52 +02:00
Matthias Urhahn e0af1838e2 Delete local.properties 2022-07-24 13:38:43 +02:00
darken 7d5502a34f Version bump (1.3.13) 2022-07-08 19:27:28 +02:00
darken 78ccddf87c Fix notification icon/text color to match light/dark theme. 2022-05-01 17:48:32 +02:00
darken df1bc48e84 Version bump (1.3.12) 2022-05-01 17:36:17 +02:00
darken 3b48d61571 Version bump (1.3.11) 2022-05-01 17:17:53 +02:00
darken 92d5e2a40d Updated translations 2022-05-01 17:13:42 +02:00
darken 234857c414 When enabling unfiltered BLE data, also enable "show all" to make debugging easier. 2022-05-01 16:42:17 +02:00
darken b1bbe785ea Fix debug log naming 2022-04-18 11:10:10 +02:00
darken 487fb49bff Update translations 2022-04-17 13:27:23 +02:00
darken b14b7fa926 Version bump (1.3.10) 2022-04-17 13:08:42 +02:00
darken 4cc1ab143d Change fake airpods pro name. 2022-04-17 13:07:27 +02:00
darken c23de67cd2 Fix ANR 2022-04-09 14:08:07 +02:00
darken e9bb4868c8 Fix NPE on some Android 9 ROMs due to bug in AOSP 2022-04-09 13:54:14 +02:00
darken ae6fd0eabd Updated translations 2022-04-09 11:43:21 +02:00
darken f4f73906bc Version bump (1.3.9) 2022-04-08 23:24:28 +02:00
darken c437decbbf Add VarunrAirPodsPro.kt 2022-04-08 23:18:35 +02:00
darken 1d36677ead Updated translations 2022-02-16 20:03:59 +01:00
darken 542b69ca92 Add acknowledgement entry for translator names. 2022-02-16 19:58:52 +01:00
darken 8547c9c396 Version bump (1.3.7) 2022-02-13 21:52:53 +01:00
darken 4e258e65c1 Fix paddings, cards less information were unnecessarily large. 2022-02-13 21:46:12 +01:00
darken 9f49b470fe Refactor interfaces to "single" and "dual" pods that each have one or more extra features. 2022-02-13 20:48:34 +01:00
darken 4a71cfe65a Version bump (1.3.7)? 2022-02-12 09:31:51 +01:00
darken d06959f964 Update fastlane scripts 2022-02-12 09:31:06 +01:00
darken 8c339ca2eb Shorten too long titles, replacing with EN for now 2022-02-12 09:30:48 +01:00
darken 3aab53566d Updated translations 2022-02-12 07:53:45 +01:00
darken 749cfed137 Remove unknown device locking, wasn't actually that useful. 2022-02-12 07:53:07 +01:00
darken ce25ad6711 Add support for Tws i99999 2022-02-12 07:45:47 +01:00
darken 568aeec0d5 Improve logging 2022-02-10 13:09:45 +01:00
darken 6b02fd66e6 Version bump (1.3.6) 2022-02-10 10:51:48 +01:00
darken 0e313004ac Updated translations 2022-02-10 10:39:39 +01:00
darken 270de8702b Improve source strings based on AO's feedback. 2022-02-10 10:27:07 +01:00
darken 324878d68d Add compatibility mode. 2022-02-10 10:19:07 +01:00
darken 9b39240016 Remove unused string 2022-02-10 08:11:24 +01:00
darken 4d6f185b58 Fix unfiltered mode only showing apple devices. 2022-02-10 07:01:10 +01:00
darken 5979081928 Version bump (1.3.5) 2022-02-04 06:59:57 +01:00
darken 0d852675cd Update translations 2022-02-04 06:57:05 +01:00
darken 29f9d9e4be Fix string placeholders. 2022-02-03 09:35:39 +01:00
darken 59c0e818e1 Remove generic unknown value label and replace it with more specific variants. 2022-02-03 09:34:55 +01:00
darken 6d6647e511 Fix package 2022-02-02 18:04:43 +01:00
darken 612acd6d72 Fix case 2022-02-02 11:52:01 +01:00
darken 425a9f321b Version bump (1.3.4) 2022-02-02 08:56:44 +01:00
darken 55ccfa27b2 Update translations 2022-02-02 08:50:57 +01:00
darken 7029263622 Fix untranslated string 2022-02-02 08:43:50 +01:00
darken 6153cae3fd Update translations 2022-02-02 08:36:27 +01:00
darken 7f78f2599e Fix translation issues. 2022-02-01 20:28:10 +01:00
darken 3f05802483 Add missing source string 2022-02-01 20:08:11 +01:00
darken b5d0b47c77 Version bump (1.3.3) 2022-01-31 22:28:19 +01:00
darken 52415cea0d Show unknown devices of all kinds. 2022-01-31 22:23:11 +01:00
darken 72448e678f Update translations. 2022-01-31 20:54:44 +01:00
darken e95d0b59ac Add foss and gplay variant to Crowdin 2022-01-31 20:40:08 +01:00
darken 36650aa938 Update translations 2022-01-31 20:31:33 +01:00
darken b2693c6e57 Add option for unfiltered BLE scan. 2022-01-31 19:20:39 +01:00
darken 08b76f3280 Fix string issues (crowdin feedback) 2022-01-31 16:58:19 +01:00
darken 51513270ea Version bump 2022-01-29 20:33:07 +01:00
darken b4d9316665 Add translation scalfolding. 2022-01-29 20:26:15 +01:00
darken a234ac0dd2 Add crowdin scripts 2022-01-29 20:23:06 +01:00
darken 3fc18bc40b Improve sorting and heuristics. 2022-01-29 20:00:04 +01:00
darken 0f924ecb82 Track first time a device is seen and display it. 2022-01-29 18:00:13 +01:00
darken 6bd350ce42 Refactor fake data into extra class 2022-01-29 16:41:50 +01:00
darken e1b4ab14e4 Version bump 2022-01-27 23:01:01 +01:00
darken adef0b39e9 Version bump (1.3.0) 2022-01-27 22:49:51 +01:00
darken af12a0e5cb Improved signal quality heuristic and value caching. 2022-01-27 22:45:47 +01:00
darken 90fc2fac24 Improve sorting 2022-01-27 00:46:06 +01:00
darken 3be7d8b43f Improve logging 2022-01-26 18:29:02 +01:00
darken a35d8c57b4 Improve device sorting and main device determination 2022-01-26 18:17:52 +01:00
darken f792431710 Fluff 2022-01-26 18:17:35 +01:00
darken 7ff20acfc0 When bluetooth is disabled, stop emissions. 2022-01-26 14:50:48 +01:00
darken 8e20e0e108 Don't report cancellation exceptions. 2022-01-26 14:33:56 +01:00
darken a6fda04640 Fix handling of Bluetooth enable/disable and restarting the monitor. 2022-01-26 13:11:01 +01:00
darken 0bbcfb5e88 Code fluff 2022-01-26 13:09:15 +01:00
darken e79ba6bfe5 Version bump (1.2.1) 2022-01-26 12:53:13 +01:00
darken 369b0207c4 Version bump (1.2.0) 2022-01-26 12:39:30 +01:00
darken ce737b45b9 Add cooldown mechanism to lid reactions. 2022-01-26 12:28:23 +01:00
darken cde3afdcd9 Average RSSI 2022-01-26 01:02:21 +01:00
darken 07b3f4320e Remove test logging 2022-01-26 00:09:30 +01:00
darken e1c71cf6b6 Improve sorting 2022-01-26 00:08:51 +01:00
darken c233cd2445 Sort main pod up to the top. 2022-01-25 22:56:24 +01:00
darken 8f609ca989 Handle ear detection and microphone detection for dual pods better. 2022-01-25 22:54:53 +01:00
darken 2de2369600 Fix popup window overdraw 2022-01-25 12:06:09 +01:00
darken d5d2ae784c Add popup via system alert window. 2022-01-25 09:59:57 +01:00
darken 56bedb64c4 Only request "access background" when monitor mode is always. 2022-01-24 20:17:01 +01:00
darken 3b48ca90d3 Make low latency the default scanmode for better userexperience. 2022-01-24 15:43:19 +01:00
darken 272ecc006f Better logging of unknown devices. 2022-01-24 15:42:31 +01:00
darken ddacfa6841 Don't report fake device data. 2022-01-24 15:36:10 +01:00
darken c5b1c417a4 Add one-pod mode for reactions 2022-01-24 15:29:05 +01:00
darken 38bc3be5b7 Improve layouts 2022-01-24 15:10:30 +01:00
darken 57cd823b63 Improve notification layouts 2022-01-23 20:52:39 +01:00
darken 6cd799130e Clean up strings 2022-01-23 20:16:29 +01:00
darken a1c58e0a32 Improve single and basic layout. 2022-01-23 20:08:34 +01:00
darken 9cd4cea63d Improve dual pods layout 2022-01-23 19:04:23 +01:00
darken 1d5e13cc24 Improve permission texts, privacy policy link and layouts. 2022-01-21 12:18:59 +01:00
darken 6d2c4ba8ca Improve permission texts. 2022-01-21 00:05:00 +01:00
darken 357baa12e6 Improve permission texts. 2022-01-20 23:21:54 +01:00
darken 909a9ba194 Version bump 2022-01-20 23:07:23 +01:00
darken 79f68c3a0d Add more privacy policy... 2022-01-20 23:07:07 +01:00
1694 changed files with 150439 additions and 10915 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

+3
View File
@@ -0,0 +1,3 @@
worktrees/
tmp/
scheduled_tasks.lock
+72
View File
@@ -0,0 +1,72 @@
# CAPod - Companion for AirPods
Android app that detects and monitors AirPods via Bluetooth LE. Displays battery levels, triggers popup notifications on case open, and provides home screen widgets.
## Project Structure
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
- **FOSS** (`foss`): Open-source, no Google Play dependencies
- **Google Play** (`gplay`): Includes billing client for IAP
Quick build check: `./gradlew assembleFossDebug`
## Key Locations
| Path | Contains |
|------|----------|
| `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/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
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
- Always use string resources for user-facing text
- Check `git log --oneline -20` for commit message style before committing
- Ordinary unit tests use JUnit 5 + kotest assertions + mockk and extend `testhelpers.BaseTest` — not
the Android defaults. `testFossDebugUnitTest` does not run `testGplay` tests
- Changing a production screen that backs a `@PreviewTest` entry in `PlayStoreScreenshots.kt` means
regenerating the smoke screenshot set
## Rules Reference
Always loaded:
| Rule | Covers |
|------|--------|
| `.claude/rules/architecture.md` | BLE vs AAP paths, `DeviceMonitor` merge boundary, FOSS pro gating |
| `.claude/rules/build-commands.md` | Gradle commands and what CI actually gates |
| `.claude/rules/commit-guidelines.md` | Commit message format and prefixes |
| `.claude/rules/pull-requests.md` | PR title and description conventions |
| `.claude/rules/agent-instructions.md` | Delegation limits and implementation scope |
| `.claude/rules/release.md` | Release guardrails — never hand-edit versions or tags |
Loaded on demand, when a matching file is read (`paths:` frontmatter):
| Rule | Loads for |
|------|-----------|
| `.claude/rules/architecture-aap-protocol.md` | `**/aap/**`, conversation reaction |
| `.claude/rules/code-style.md` | Kotlin/Compose sources in `main/`, `foss/`, `gplay/`, `debug/` |
| `.claude/rules/testing.md` | `app/src/test/`, `testFoss/`, `testGplay/` |
| `.claude/rules/localization.md` | `**/res/values/strings.xml` (base locale) |
| `.claude/rules/screenshots.md` | Screenshot composables, `screenshotTest/`, fastlane scripts |
Skills, invoked by name:
| Skill | Purpose |
|-------|---------|
| `/release` | Release workflow dispatch, inputs, channel mapping, rollback |
## Scratch space
`.claude/tmp/` is gitignored. Put plans, repro screenshots, throwaway scripts, and captured logs
there rather than in `/tmp` — they stay greppable and survive across sessions without being
committed.
+32
View File
@@ -0,0 +1,32 @@
---
description: Sub-agent delegation limits and implementation scope for this project
---
# Agent Instructions
## Delegation
Delegation adds coordination overhead and multiplies token cost, so it has to earn its place through
genuine independence and parallel speedup.
- Delegate only for large, genuinely independent work that parallelizes — a wide multi-file
investigation across unrelated areas, for example
- Don't delegate what you'd finish yourself in a handful of tool calls
- Don't spawn a sub-agent to verify or double-check your own work
- If one sub-agent can do it, use one rather than several
- Sub-agents don't inherit your conversation — state the full task, the relevant paths, and
whether you want research only or research plus implementation
- `Explore` is the right type for read-only codebase investigation
Running Gradle through the build-runner agent is a separate standing rule in the user's global
CLAUDE.md; it is context isolation, not delegation, and this file does not restate it.
## Implementation scope
- Follow existing patterns — match the code style and architecture already in use
- Change only what the task needs
- When behavior is unexpected, fix the root cause rather than working around it
- Don't create new files when editing an existing one would do
- Don't refactor surrounding code while fixing a bug
- Don't add comments or docs to code you didn't change
- Don't guess at file paths — use Glob/Grep
@@ -0,0 +1,67 @@
---
description: AAP protocol landmines and settled dead ends — what not to send, and what has already been proven impossible
paths:
- "app/src/main/java/**/aap/**"
- "app/src/main/java/**/monitor/core/aap/**"
- "app/src/main/java/**/reaction/core/conversation/**"
---
# AAP Protocol — Landmines and Dead Ends
This file holds only what the code doesn't already say. The Conversational Awareness status
taxonomy is KDoc'd on `ConversationAwarenessEvent`, the `0x4B` frame shapes are documented at the
decode site in `DefaultAapDeviceProfile`, and known control IDs are catalogued in `AapControlId`
read those, don't duplicate them here.
## Never send `0x0001` mid-session
`AapMessageType.CAPABILITIES_REQUEST` (`0x0001`) is a **handshake-phase opcode only**. Sending
`04 00 04 00 01 00` on an established session makes the device close the L2CAP stream
(`Stream closed by remote`), forcing a full reconnect. Verified experimentally on AirPods Pro 3
(fw `81.2675000075000000.6503`). The Stream State Info payload also differs between the original
connection (45B) and the forced reconnect (28B), suggesting device-side state loss.
The enum lists it with no warning, so it looks callable. It isn't.
This came up as a "cheap refresh settings" probe after writing `DYNAMIC_END_OF_CHARGE` (`0x3B`),
because the device doesn't echo that write in-session. **There is no read-setting primitive in AAP.**
Settings are push-only — on connect, on external change, or not at all. The supported pattern is
optimistic UI state + profile-learned persistence + let reconnects refresh.
## `0x37` does not use the Apple-bool encoding
Hearing Protection PPE (`0x37`, the EN 352 82 dBA media cap) is **Pro 3 only** and encodes as a
plain `01 = on` / `00 = off`. Every other AAP boolean uses the Apple-bool convention where false is
`0x02``encodeAppleBool` is wrong for this one. Companion `0x38` carries the cap level
(observed `0x52` = 82 dBA).
Hardware-confirmed reads on Pro 3 (A3064), 2026-06-10. A full settings flood on Pro 2 USB-C (A3048)
never contains `0x37` or `0x38`. Write-effect is **not** yet hardware-verified; it goes over the same
PSM `0x1001` channel CAPod already writes ANC and CA to, so the risk is low, but it is untested.
## Settled dead ends — do not re-investigate
**Real-time ambient dB level (#521) is not implementable.** AirPods send no dB or attenuation
telemetry over any AAP opcode or ATT characteristic. Apple's feature measures SPL with the Watch or
iPhone microphone and subtracts a *static per-model lookup table* held in the private
`HearingUtilities.framework`; AirPods contribute only their current listening mode. Established via
the iOS 26.1 decompile plus a sweep of librepods, apple-wireshark, and the tyalie AAP definitions.
`0x50` is PerfStats, `0x53` a PME config blob, `0x58` an Opus mic audio stream — none is a metric.
**Loud Sound Reduction (#520) has no non-root toggle.** LSR lives on a separate raw ATT channel, not
AAP: a second L2CAP socket to **PSM 31 (`0x001F`)**, handle `0x1B`, plain `0x01`/`0x00`. Connecting
and *reading* works without Apple vendor-ID spoofing. **Writes are silently ignored** — the pods
return a Write Response (`13`) and the immediate read-back is unchanged. Reproduced back-to-back on
Pixel 8 + Pro 2 USB-C, 2026-06-10. This matches librepods only exposing the toggle behind their
root/Xposed VID-spoofing hook. A functional toggle is root-only; a read-only status indicator is
feasible today.
Not to be confused with `0x37` above — different feature, different channel, and that one is a
normal writable AAP setting.
## Session exclusivity
The pods accept exactly **one AAP session**. Any debug activity that boots the app starts
`MonitorService`, which auto-connects and wins the socket — a proof-of-concept activity will connect
at the L2CAP layer and then receive nothing. Protocol experiments have to go through the monitor's
own session, i.e. the real feature write path.
+61
View File
@@ -0,0 +1,61 @@
---
description: Load-bearing architectural invariants that are not obvious from reading the code
---
# Architecture
Invariants worth knowing before you touch device state, the AAP stack, or the upgrade flow. Class
inventories and source-set layout are omitted deliberately — read the tree for those.
## BLE vs AAP
Two independent data paths. Which one a feature can use decides whether it is even possible.
| | BLE (advertisements) | AAP (L2CAP session) |
|---|---|---|
| Direction | Read-only, passive | Bidirectional commands + events |
| Prerequisite | `BLUETOOTH_SCAN` on Android 12+, Bluetooth/location permissions below | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
| Data | Battery, case open, in-ear, pod model | Settings, ANC control, press controls, stem events, device info |
| Availability | Any pod in range | Only your own paired pods |
A figure BLE never advertises cannot be obtained without a bonded AAP session, and anything
requiring a write is AAP-only.
## `DeviceMonitor` is the state merge boundary
`DeviceMonitor` (singleton) `combine`s four live sources — `BlePodMonitor.devices`,
`AapConnectionManager.allStates`, `BluetoothManager2.connectedDevices` (supplies `isSystemConnected`),
and `DeviceProfilesRepo.profiles` — then merges `DeviceStateCache` on top, deliberately after the
combine so cache writes don't feed back into it.
The invariant is about **state**, not about the whole AAP layer:
- Unified device state comes from `DeviceMonitor.devices` — don't assemble your own from `BlePodMonitor`
- Commands go **through** `AapConnectionManager.sendCommand(...)`. ViewModels legitimately inject it
(`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel` all do)
- Nothing outside the AAP engine touches `AapConnection` (the L2CAP socket wrapper) directly
- `TroubleShooterViewModel` reaching into `BlePodMonitor` for raw diagnostic scans is an intentional
exception, not a pattern to copy
Because the cache is merged in, a `PodDevice` may carry data while the device is out of range —
presence in the flow does not imply a live connection.
## `AapConnectionManager` owns sessions
It holds every open AAP session keyed by `BluetoothAddress`. Consumers call `sendCommand(...)` and
observe `allStates`.
The stack under `pods/core/apple/aap/` splits into `protocol/` (pure data) and `engine/` (per-connection
state machine). The glue in `monitor/core/aap/` wires it into the foreground service and persists
learned settings and session keys across restarts.
## FOSS is not "always pro"
`UpgradeRepo` has two flavor implementations. `UpgradeControlFoss` starts users at `isPro = false`
and only persists the pro flag after `upgrade()` is called via the local sponsor flow. Do not assume
the FOSS flavor bypasses pro gating.
## Navigation is mid-migration
Navigation3 (`addNavigation3()`) drives current Compose routing, but legacy `androidx.navigation`
helpers still exist (`NavDirectionsExtensions`, `ViewModel3`). Don't assume SafeArgs is fully gone.
+55
View File
@@ -0,0 +1,55 @@
---
description: Gradle build, test, and lint commands, and what CI actually gates
---
# Build Commands
## Quick local check
```bash
./gradlew assembleFossDebug testFossDebugUnitTest
```
`assembleFossDebug` is the fastest variant — use it for iteration.
## What CI gates
`.github/workflows/code-checks.yml`, on every PR. Core Gradle gates:
```bash
# Lint vitals — flavor x variant matrix. Note: Beta/Release only, never Debug.
./gradlew lintVitalFossBeta lintVitalFossRelease lintVitalGplayBeta lintVitalGplayRelease
# Builds — Debug only
./gradlew app:assembleFossDebug app:assembleGplayDebug
# Unit tests — both flavors
./gradlew testFossDebugUnitTest testGplayDebugUnitTest
```
Four non-Gradle checks also run, **unconditionally** — there is no path filter, so they gate your PR
even if you didn't touch those areas:
```bash
bash fastlane/check_metadata_length.sh # Play Store metadata length limits
shellcheck tools/release/bump.sh
bats tools/release/bump.bats
./tools/release/bump.sh --mode=check # version.properties + VERSION consistency
```
Reproducing those locally is usually only worth it when you changed fastlane metadata or release
tooling, but a failure there blocks the PR regardless.
**Do not run `./gradlew check` as a pre-submit gate.** It runs the full non-vital `lint` task, which
is already failing on `main` for reasons unrelated to your change — you'll burn time chasing
pre-existing findings that CI never looks at. CI gates `lintVital*`, not `lint`.
## Other commands
```bash
./gradlew assembleGplayRelease # release build
./gradlew bundleGplayRelease # Play Store bundle
./gradlew connectedFossDebugAndroidTest # instrumentation, needs a device/emulator
./gradlew lintFix # auto-fix where possible
./gradlew updateLintBaseline # refresh the baseline
```
+113
View File
@@ -0,0 +1,113 @@
---
description: Kotlin and Compose conventions — logging, ViewModel base classes, the ScreenHost/Screen split, DataStore settings
paths:
- "app/src/main/**/*.kt"
- "app/src/foss/**/*.kt"
- "app/src/gplay/**/*.kt"
- "app/src/debug/**/*.kt"
---
# Code Style
## Logging
`logTag()` builds the tag; `log()` takes a lambda so the message is only built if it's emitted.
```kotlin
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.logging.Logging.Priority.*
companion object {
private val TAG = logTag("Profiles", "Repo") // multi-part tags are the norm
}
log(TAG) { "Processing $item" } // DEBUG is the default
log(TAG, VERBOSE) { "Devices changed" }
log(TAG, ERROR) { "Failed: ${e.asLog()}" } // asLog() for stacktraces
```
Never suppress protocol logging — downgrading a level is fine, removing the call is not.
## ViewModel base classes
Four exist. Use **`ViewModel4`** for new work — it's the current one (12 subclasses) and wires
`NavigationEventSource` + `ErrorEventSource2`.
- `ViewModel4` — current, use this
- `ViewModel2` — plain base, no nav/error event sources (4 subclasses)
- `ViewModel1` — legacy (1 subclass)
- `ViewModel3`**dead, zero subclasses.** It's the `ViewModel4` shape against the older
`NavEventSource`/`ErrorEventSource` interfaces. Don't extend it.
## Compose: the Host/Screen split
Every screen is two composables.
**`<Feature>ScreenHost`** — the only place that touches `hiltViewModel()`, installs the event
handlers, and collects state.
**`<Feature>Screen`** — presentation only. Takes a plain state object plus `on*` callbacks, so it
previews without Hilt.
```kotlin
@Composable
fun SettingsScreenHost(vm: SettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
state?.let {
SettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onWiki = { vm.openUrl("https://github.com/d4rken-org/capod/wiki") },
)
}
}
@Composable
fun SettingsScreen(
state: SettingsViewModel.State,
onNavigateUp: () -> Unit,
onWiki: () -> Unit,
modifier: Modifier = Modifier, // last, after the required params
) { ... }
```
- `modifier: Modifier = Modifier` goes after the required parameters — i.e. it is the first
*optional* one, per the Compose API guidelines. capod is not fully consistent here (roughly 10
composables put it after required params, 3 put it genuinely first); match the file you're in
rather than reformatting neighbours
- The Host null-guards state; `collectAsStateWithLifecycle(initialValue = null)` is the usual shape
- Wrap previews in `PreviewWrapper` (`common/compose/PreviewWrapper.kt`), which applies `CapodTheme`
plus a background `Surface`
- Trailing commas on multi-line parameter lists and argument lists
## DataStore settings
`createValue()` is overloaded. Primitives need no serializer:
```kotlin
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC)
```
`@Serializable` types take a `Json`, and optionally fall back instead of throwing on corrupt or
legacy stored JSON:
```kotlin
val config = dataStore.createValue("some.config", SomeConfig(), json, onErrorFallbackToDefault = true)
```
Read and write via `.value()` / `.value(x)` (suspend) or `.flow` (reactive). Both `value` functions
are **extension functions**, not members — see `.claude/rules/testing.md` for what that means when
mocking.
## General
- Package by feature, not by layer
- Prefer adding to an existing file over creating a new one
- Prefer flow-based, cancellable solutions
- No comments for self-evident code
- Place `@Suppress` as close to the affected code as possible — on the function or constructor,
not the whole class
+40
View File
@@ -0,0 +1,40 @@
---
description: Git commit message format and conventions
---
# Commit Guidelines
## Format
```
<prefix>: <Short summary>
```
Summary line should be concise and describe the change. No period at the end.
## Prefixes
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
## Component Scope (optional)
When a change is scoped to a specific area, include it after the prefix:
- `fix(widget): Fix layout for devices with single charge detection`
- `feat(monitor): Add battery level caching`
- `refactor(popup): Extract pod view factory`
## Rules
- Keep the summary line under 72 characters
- Use imperative mood ("Add feature" not "Added feature")
- Reference issue numbers when applicable
- Do not include `Co-authored-by` trailers
- Look at recent `git log` output to match the project's existing style
+23
View File
@@ -0,0 +1,23 @@
---
description: Guidelines for adding and naming Android string resources
paths:
- "**/res/values/strings.xml"
---
# Localization Guidelines
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_monitor_mode_label`)
- **Provide context**: String names should indicate usage and location
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
## Naming Examples
- `profiles_create_title` (screen title)
- `profiles_name_label` (form field label)
- `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_*`).
+66
View File
@@ -0,0 +1,66 @@
---
description: Pull request title and description conventions
---
# Pull Request Guidelines
## Title
```
<Category>: <Short user-facing summary>
```
Titles appear in auto-generated changelogs and are read by users. Use ELI5, user-facing language —
no class names, library names, or implementation details. `refactor(settings): Migrate preferences
to DataStore` is the shape to avoid; `General: Remember settings between app restarts` is the shape
to use.
| Category | 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 |
## Description
PRs are reviewed in GitHub's web UI, which already shows the file tree, the diff, and the tests.
The description answers what the diff can't. Use exactly these sections, in this order:
1. `## What changed`
2. `## Technical Context`
3. `## Review checklist` *(optional)*
No `Scope`, `Files changed`, `Tests`, or `Review guidance` sections.
**What changed** — user-facing explanation: the problem fixed or the feature added, from the user's
perspective. For refactors, tests, CI, and dependency bumps, write "No user-facing behavior change"
followed by a brief internal description.
**Technical Context** — one bullet per point, no prose paragraphs, no nested `**Bug 1**` headers.
Cover only what the diff can't show:
- **Why** this approach, and what was rejected
- **Root cause** for bug fixes — the diff shows the fix, not what caused it
- **Non-obvious side effects** or behavioral changes
**Review checklist**`- [ ]` items, only when there are several non-trivial things to verify.
A single tricky point stays a Technical Context bullet.
## Labels
Apply labels that match the change. Run `gh label list` to confirm what exists — do not invent new
ones. Skip labels that don't fit; no labels beats wrong labels.
- **Type**: `bug` for fixes, `enhancement` for new features or improvements
- **Transport**: `coms/AAP` when the change touches the L2CAP session path, `coms/BLE` when it
touches advertisement parsing. Both if it spans the merge in `DeviceMonitor`
- **Scope**: `device support` for new or fixed pod models, `Translations` for string/locale work,
`Build/Deploy` for CI, Gradle, and release tooling
- `Needs Info/Repro` is a triage label for issues — not for your own PRs
## Conventions
- Link issues with "Closes #123" / "Fixes #123" / "Resolves #123"
- Prefix breaking changes with "BREAKING:"
- No `Co-authored-by` trailers
+17
View File
@@ -0,0 +1,17 @@
---
description: Release guardrails — what never to do by hand. Full procedure is the /release skill.
---
# Release
The procedure lives in the `/release` skill (invoke it deliberately; it does not auto-load).
These constraints apply regardless of whether that skill was invoked.
- **`release-prepare.yml` is the only sanctioned path** for bumping a version or creating a release
tag. Do not edit `version.properties` or `VERSION` by hand, and do not create `v*` tags manually —
`validate-tag` in `release-tag.yml` rejects anything not matching `v<M.m.p>-(rc|beta)N`.
- **`tools/release/bump.sh` is the single source of truth for version logic.** Its versionCode
formula mirrors `buildSrc/src/main/java/ProjectConfig.kt` — the two must stay in sync.
- CI runs `./tools/release/bump.sh --mode=check` on every PR (`check-release-tooling` in
`code-checks.yml`). If you did touch `version.properties` or `VERSION`, run that locally first.
- All numeric version fields are bounded `0..99`; the versionCode formula collapses at ≥100.
+106
View File
@@ -0,0 +1,106 @@
---
description: Play Store screenshot pipeline — generation, copying, and adding or removing screens
paths:
- "app/src/debug/**/screenshots/**"
- "app/src/screenshotTest/**"
- "fastlane/generate_screenshots.sh"
- "fastlane/copy_screenshots.sh"
- "fastlane/Fastfile"
- "fastlane/metadata/android/*/images/phoneScreenshots/**"
---
# 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 (7 exist; `HomescreenWidgetContent` has an IDE preview only and is **not** in the Play Store pipeline) |
| `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, 476 PNGs (68 locales x 7)
./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/`
+79
View File
@@ -0,0 +1,79 @@
---
description: Unit test conventions — JUnit 5, kotest assertions, mockk, BaseTest, and which Gradle task runs which source set
paths:
- "app/src/test/**"
- "app/src/testFoss/**"
- "app/src/testGplay/**"
- "app/build.gradle.kts"
- "buildSrc/src/main/java/Dependencies.kt"
---
# Testing
The stack here is not the Android default — check this before reaching for a familiar library.
## Libraries
- **JUnit 5** (`org.junit.jupiter.api.Test`). Gradle sets `useJUnitPlatform()`.
- **kotest** for assertions: `io.kotest.matchers.shouldBe`, `shouldBeNull`, `shouldBeInstanceOf`,
`shouldContainExactly`, `io.kotest.assertions.throwables.shouldThrow`. Use kotest for new
assertions — `MediaControlTest` still uses JUnit `Assertions.*` and is a legacy exception.
- **mockk** for mocking. Not Mockito.
- **Turbine is not a dependency.** `testhelpers.flow.FlowTest` provides a `Flow<T>.test()` helper —
use it rather than adding one.
## Base classes
Extend `testhelpers.BaseTest`, or the applicable specialized base that already extends it:
- `BaseBlePodsTest` — BLE advertisement parsing per pod model
- `BaseAapSessionTest` — AAP protocol/session tests
`BaseTest` installs a `JUnitLogger` and calls `unmockkAll()` in `@AfterAll`. Skipping it can leave
global mockk and logging state behind for later test classes.
The only exceptions are the Robolectric-backed tests (Compose UI via
`testhelpers.compose.BaseComposeRobolectricTest`, and the few DataStore-backed ones such as
`CurriculumVitaeProHistoryTest`), which use JUnit 4 `@RunWith`/`@Rule` via `junit-vintage-engine`.
Don't copy that pattern for a plain unit test.
## Source sets and Gradle tasks
Each task compiles and runs only its own flavor — running the wrong one silently skips your test.
| Test location | Task |
|---|---|
| `app/src/test/` (shared) | either; run both before pushing |
| `app/src/testFoss/` | `./gradlew testFossDebugUnitTest` |
| `app/src/testGplay/` | `./gradlew testGplayDebugUnitTest` |
CI runs both. Flavor-specific tests are for code that only exists in that flavor — billing in
`gplay`, the sponsor-based upgrade flow in `foss`.
## Helpers that already exist
- `runTest2(autoCancel, context, expectedError, testBody)` in `testhelpers/coroutine/TestExtensions.kt`
use `expectedError = SomeException::class` instead of hand-rolling a throws-assertion around `runTest`
- `FakeDataStoreValue<T>(initial)` in `testhelpers/datastore/` — a working fake with a real backing
`MutableStateFlow`; read/write it through `.value` and pass `.mock` to the code under test
## Mocking `DataStoreValue`
`DataStoreValue.value()` and `.value(T)` are **extension functions** (`DataStoreValue.kt:54,56`), not
members, so MockK cannot stub them. They delegate to `flow.first()` and `update { }` — stub those:
```kotlin
every { someSetting.flow } returns flowOf(value) // covers .value() reads
coVerify { someSetting.update(any()) } // verifies .value(x) writes
```
`UpgradeRepoGplayTest` uses this shape. Prefer `FakeDataStoreValue` when you need reads and writes to
actually round-trip.
## Reading ViewModel state
`ViewModel2.asLiveState()` is `stateIn(..., initialValue = null).filterNotNull()` with
`SharingStarted.WhileSubscribed(5_000)` — so `vm.state` is a `Flow`, not a `StateFlow`, and has no
`.value` to read. Collect it: `vm.state.first()` is the established pattern across the existing
ViewModel tests. Because the upstream only runs while subscribed, a test that never collects sees
nothing happen at all.
+19
View File
@@ -0,0 +1,19 @@
{
"permissions": {
"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,
"google-play@claude-code-cafe": true,
"devtools@claude-code-cafe": true
}
}
+115
View File
@@ -0,0 +1,115 @@
---
description: Cut a capod release via the "Release prepare" workflow — dispatch inputs, channel mapping, rollback, and auth setup.
disable-model-invocation: true
argument-hint: "[bump_kind] [version_type|version_override]"
---
# 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.
## Required order
A real cut pushes a commit and a tag to `main` and is public the moment it lands. Do not skip ahead.
1. Run the dry run first and read its summary — never dispatch `dry_run=false` blind.
2. Report the planned version and `versionCode` back to the user.
3. Get explicit confirmation for that specific version before dispatching `dry_run=false`.
4. If the user named `bump_kind`/`version_type`/`version_override`, use exactly those. If the request
is ambiguous about which field moves, ask rather than assuming `build`.
## Dispatch
`gh workflow run` only fires the dispatch — it returns nothing about the result. The summary is
written asynchronously, so you have to go fetch it.
```bash
# Step 1 — plan only. No commit, no tag, no push. Always run this first.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
# Step 2 — find the run just dispatched and wait for it.
gh run list --workflow=release-prepare.yml --limit 1 # note the run id
gh run watch <run-id> --exit-status
# Step 3 — read the computed plan (version + versionCode) before going further.
gh run view <run-id> --log | tail -40
```
Report the planned version and `versionCode`, get explicit confirmation, then:
```bash
# Step 4 — real cut. Repeat the dry run's inputs EXACTLY; change only dry_run.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
```
The `bump_kind=build` above is only an example. If the confirmed plan came from a `patch`/`minor`/
`major` bump, a `version_type` switch, or a `version_override`, Step 4 must carry those same flags —
otherwise you cut a different version than the one that was approved.
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*` | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
`release-tag.yml` accepts only `v<M.m.p>-rcN` or `v<M.m.p>-betaN` — any other suffix fails
`validate-tag` before a build starts. There is no third channel.
`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`.
+2 -1
View File
@@ -1,4 +1,5 @@
# These are supported funding model platforms
github:
- d4rken
custom:
- "https://www.buymeacoffee.com/tydarken"
+33
View File
@@ -0,0 +1,33 @@
name: 'Common project setup'
description: 'Setup the base project'
runs:
using: "composite"
steps:
- name: Set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
shell: bash
run: chmod +x gradlew
- name: Cache Gradle Wrapper
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.gradle/wrapper
!~/.gradle/wrapper/dists/**/gradle*.zip
key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-wrapper-
- name: Cache Gradle Dependencies
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-
+14
View File
@@ -0,0 +1,14 @@
changelog:
exclude:
labels:
- changelog-ignore
categories:
- title: ":rocket: Enhancements"
labels:
- enhancement
- title: ":lady_beetle: Bug fixes"
labels:
- bug
- title: ":shrug: Other changes"
labels:
- "*"
-28
View File
@@ -1,28 +0,0 @@
name: Android CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew assembleDebug
- name: Run tests
run: ./gradlew testGplayDebugUnitTest testFossDebugUnitTest
+107
View File
@@ -0,0 +1,107 @@
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
strategy:
fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Beta,Release ]
module: [ app ]
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Check for fatal lint issues
run: ./gradlew lintVital${{ matrix.flavor }}${{ matrix.variant }}
build-modules:
name: Build apps
strategy:
fail-fast: false
matrix:
flavor: [ Foss,Gplay ]
variant: [ Debug ]
module: [ app ]
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Build modules
run: ./gradlew ${{ matrix.module }}:assemble${{ matrix.flavor }}${{ matrix.variant }}
test-modules:
name: Unit tests
strategy:
fail-fast: false
matrix:
variant: [ Debug ]
flavor: [ testFoss,testGplay ]
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
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
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-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: gradle/wrapper-validation-action@v1
- 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"
+196
View File
@@ -0,0 +1,196 @@
name: Tagged releases
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:
- name: Decode Keystore
env:
ENCODED_KEYSTORE: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
run: |
TMP_KEYSTORE_DIR_PATH="${RUNNER_TEMP}"/keystore
mkdir -p "${TMP_KEYSTORE_DIR_PATH}"
TMP_KEYSTORE_FILE_PATH="${TMP_KEYSTORE_DIR_PATH}"/keystore.jks
echo $ENCODED_KEYSTORE | base64 -di > "${TMP_KEYSTORE_FILE_PATH}"
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
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: Assemble beta APK
if: contains(github.ref_name, '-beta')
run: ./gradlew assembleFossBeta
env:
VERSION: ${{ github.ref }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production APK
if: "!contains(github.ref_name, '-beta')"
run: ./gradlew assembleFossRelease
env:
VERSION: ${{ github.ref }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Create pre-release
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: ${{ 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(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: ${{ 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:
ENCODED_KEYSTORE: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
run: |
TMP_KEYSTORE_DIR_PATH="${RUNNER_TEMP}"/keystore
mkdir -p "${TMP_KEYSTORE_DIR_PATH}"
TMP_KEYSTORE_FILE_PATH="${TMP_KEYSTORE_DIR_PATH}"/keystore.jks
echo $ENCODED_KEYSTORE | base64 -di > "${TMP_KEYSTORE_FILE_PATH}"
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Decode Google Play service account key
env:
ENCODED_SERVICE_KEY: ${{ secrets.GPLAY_SERVICE_ACCOUNT_KEY_JSON_BASE64 }}
run: |
TMP_SERVICEKEY_DIR="${RUNNER_TEMP}"/gplaykey
mkdir -p "${TMP_SERVICEKEY_DIR}"
TMP_SERVICEKEY_FILE_PATH="${TMP_SERVICEKEY_DIR}"/service_account.json
echo $ENCODED_SERVICE_KEY | base64 -di > "${TMP_SERVICEKEY_FILE_PATH}"
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
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: Set up ruby env
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(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
run: bundle exec fastlane beta
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production and upload to Google Play
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 }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+26
View File
@@ -0,0 +1,26 @@
name: Thumbnail images
# Shrinks oversized screenshots in issues and comments into clickable
# thumbnails. The logic lives in d4rken-org/.github so every app repo shares
# one copy; this stub only supplies the triggers, because workflow_call cannot
# be triggered by issue_comment directly.
#
# The shared workflow splits into an unprivileged parsing job and a privileged
# job that only makes the API call, so issues:write below is the ceiling, not
# what the parsing half receives.
#
# Add <!-- no-thumbnail --> to a body to opt it out.
on:
issue_comment:
types: [created, edited]
issues:
types: [opened, edited]
permissions: {}
jobs:
thumbnail:
permissions:
issues: write
uses: d4rken-org/.github/.github/workflows/thumbnail-images.yml@94e36d9a67a887e24338f95fd0429925cea21c98
+33 -2
View File
@@ -1,3 +1,34 @@
.idea
*.iml
.gradle
build/
/local.properties
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
/.idea/**/*
!/.idea/codeStyles/
!/.idea/codeStyles/**/*
*.jks
.local/*
/fastlane/report.xml
/fastlane/Appfile
/fastlane/README.md
.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/
+168
View File
@@ -0,0 +1,168 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="RIGHT_MARGIN" value="120" />
<JSON>
<option name="OBJECT_WRAPPING" value="1" />
<option name="ARRAY_WRAPPING" value="1" />
</JSON>
<JavaCodeStyleSettings>
<option name="DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION" value="true" />
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="JAVA">
<option name="KEEP_SIMPLE_BLOCKS_IN_ONE_LINE" value="true" />
<option name="KEEP_SIMPLE_METHODS_IN_ONE_LINE" value="true" />
<option name="KEEP_SIMPLE_LAMBDAS_IN_ONE_LINE" value="true" />
<option name="KEEP_SIMPLE_CLASSES_IN_ONE_LINE" value="true" />
<option name="FIELD_ANNOTATION_WRAP" value="1" />
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="METHOD_ANNOTATION_WRAP" value="1" />
<option name="CLASS_ANNOTATION_WRAP" value="1" />
<option name="FIELD_ANNOTATION_WRAP" value="1" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+90
View File
@@ -0,0 +1,90 @@
---
layout: plain
permalink: /changelog
title: "Changelog"
---
# Changelog for CAPod
{% for release in site.github.releases %}
## {{ release.tag_name }} - {{ release.published_at | date: "%B %d, %Y" }}
{% assign clean_body = release.body | strip %}
{% assign no_comments = clean_body | replace: "<!-- Release notes generated using configuration in .github/release.yml", "" %}
{% assign no_comments = no_comments | split: "-->" %}
{% if no_comments.size > 1 %}
{% assign clean_content = no_comments[1] | strip %}
{% else %}
{% assign clean_content = no_comments[0] | strip %}
{% endif %}
{% comment %} Make links clickable {% endcomment %}
{% assign lines = clean_content | split: "
" %}
{% assign processed_lines = "" %}
{% for line in lines %}
{% if line contains "**Full Changelog**:" %}
{% comment %} Handle Full Changelog links {% endcomment %}
{% assign parts = line | split: ": " %}
{% if parts.size > 1 %}
{% assign url = parts[1] | strip %}
{% assign clickable_line = "**[View Changes](" | append: url | append: ")**" %}
{% assign processed_lines = processed_lines | append: clickable_line | append: "
" %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% elsif line contains " in https://github.com/" and line contains "/pull/" %}
{% comment %} Handle pull request links {% endcomment %}
{% assign pr_parts = line | split: " in https://github.com/" %}
{% if pr_parts.size > 1 %}
{% assign before_url = pr_parts[0] %}
{% assign after_url = pr_parts[1] %}
{% assign url = "https://github.com/" | append: after_url %}
{% assign pr_number = after_url | split: "/pull/" %}
{% if pr_number.size > 1 %}
{% assign pr_num = pr_number[1] | split: " " | first %}
{% assign clickable_line = before_url | append: " in [#" | append: pr_num | append: "](" | append: url | append: ")" %}
{% assign processed_lines = processed_lines | append: clickable_line | append: "
" %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% else %}
{% assign processed_lines = processed_lines | append: line | append: "
" %}
{% endif %}
{% endfor %}
{% comment %} Add proper spacing between sections and bullet points {% endcomment %}
{% assign final_content = processed_lines | replace: "
### ", "
### " %}
{% assign final_content = final_content | replace: "
## ", "
## " %}
{% assign final_content = final_content | replace: "
- ", "
- " %}
{% comment %} Check if there are any bullet points (actual release notes) {% endcomment %}
{% if final_content contains "## " or final_content contains "- " %}
{{ final_content | markdownify }}
{% else %}
*No release notes available.*
{{ final_content | markdownify }}
{% endif %}
---
{% endfor %}
+1
View File
@@ -0,0 +1 @@
capod.darken.eu
+55 -11
View File
@@ -1,24 +1,68 @@
# Privacy policy for the app `CAPod`
---
layout: plain
permalink: /privacy
title: "Privacy Policy"
---
* I do not collect personal information
* I do not sell, monetize or otherwise misappropriate any collected data.
# Privacy policy
This is the privacy policy for the Android app "CAPod - Companion for AirPods" by Matthias Urhahn (darken).
Anonymous device information may be collected in the event of a crash (see [Automatic crash reports](#automatic-crash-reports)).
## Preamble
CAPod respects your privacy.
I do not collect, share or sell personal information.
Send a [quick mail](mailto:support@darken.eu) if you have questions.
My underlying privacy principle is the [Golden Rule](https://en.wikipedia.org/wiki/Golden_Rule).
Send a [quick mail](mailto:support@darken.eu) if you have further questions.
## Location data
## Automatic crash reports
CAPod does not collect, share or sell location data.
The app uses "Bugsnag" for automatic crash reports:
Location permissions are required to receive Bluetooth Low Energy (BLE) data and ebale its core functionality.
The permission "access fine location" (`ACCESS_FINE_LOCATION`) and "access coarse location" (`ACCESS_COARSE_LOCATION`) are required to receive Bluetooth Low Energy data on Android 11 and lower.
On Android 12+ the newer and more fine grained `BLUETOOTH_SCAN` permission is used instead.
Bluetooth Low Energy is a technology that devices like AirPods use to communicate their status to nearby devices.
CAPod requests location permissions because these permissions are required to work with Bluetooth Low Energy data.
This is a privacy measure on Android's side because you could determine someones location by scanning for Bluetooth
devices:
If you know the physical location of a Bluetooth device (e.g. AirTags) you could use Bluetooth data to calculate your
position.
### Location access in the background
CAPod uses the "location access in the background" permission (`ACCESS_BACKGROUND_LOCATION`) on Android 11 and older to
receive Bluetooth Low Energy data while the app is in the background. This permission enables the "Show popup" and "
Autoconnect" features and allows CAPod to react to nearby devices whil the app is closed.
## Automatic error reports
*This was removed in v2.11.0+*
If an error occurs, an automated report may be sent to help me fix the issue.
This is optional and you can opt out of this in the settings.
Error reports are collected using "Bugsnag":
https://www.bugsnag.com/
Bugsnags privacy policy can be found here:
Bugsnag's privacy policy can be found here:
https://docs.bugsnag.com/legal/privacy-policy/
Crash reports may contain device and app related information.
Error reports contain device and app information related to the error that occured.
Additional information about the error context may also be included, e.g. what this app did shortly before the error.
You can disable automatic crash reports in the app's settings.
Additional details about the type of data that is collected by the error tracking SDK can be found here:
https://docs.bugsnag.com/platforms/android/playstore-privacy/
Error reports are pseudonymous. Unless you tell me your install-ID, I don't know that an error report came from you.
Error reports are automatically deleted after 90 days.
## Debug logs
The app has a debug log feature that can be used to assist troubleshooting efforts. This feature creates a log file that contains verbose output of what the app is doing.
It is manually triggered by the user through an option in the app settings. The recorded log file can be shared through compatible apps (e.g. your email app) using the system's share dialog. As this log file may contain sensitive information (e.g. details about files or installed applications) it should only be shared with trusted parties.
+50 -12
View File
@@ -1,9 +1,13 @@
<img src="https://github.com/d4rken/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)
![Build](https://github.com/d4rken/capod/actions/workflows/android.yml/badge.svg)
[![Github All Releases](https://img.shields.io/github/downloads/d4rken/capod/total.svg)](https://github.com/d4rken/capod/releases/latest)
[![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)
[![Google Play Downloads](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%24totalinstalls)](https://github.com/d4rken-org/capod/edit/main/README.md#download)
A companion app that adds support for AirPod specific features to Android:
@@ -12,43 +16,72 @@ A companion app that adds support for AirPod specific features to Android:
* Additional infos about connection, microphone & case.
* Can receive and show all nearby devices.
* Ear detection with automatic play/pause.
* Automatically connect phone & airpods.
* Automatically connect phone & AirPods.
* Show popup when case is opened.
* Widgets
CAPod is ad-free. Some additional features require an in-app purchase.
Currently supported models:
* AirPods Gen1
* AirPods Gen2
* AirPods Pro
* AirPods 1. Generation
* AirPods 2. Generation
* AirPods 3. Generation
* AirPods 4. Generation
* AirPods 4. Generation with ANC
* AirPods Pro 1. Generation
* AirPods Pro 2. Generation
* 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
* Fake AirPods 1. Generation 🎭
* Fake AirPods 2. Generation 🎭
* Fake AirPods 3. Generation 🎭
* Fake AirPods Pro1 🎭
* Fake AirPods Pro2 🎭
## Download
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [GitHub](https://github.com/d4rken/capod/releases/latest)
| Source | Status |
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [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
* Buy the CAPod Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod)
* [Sponsor development](https://github.com/sponsors/d4rken) on GitHub
* Help translate CAPod [on Crowdin](https://crowdin.com/project/capod)
* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
## Get help
* [Github Issues](https://github.com/d4rken/capod/issues)
* [Github Issues](https://github.com/d4rken-org/capod/issues)
* [Discord](https://discord.gg/vHubYPp)
## Screenshots
<img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken/capod/raw/main/.assets/screenshots/4.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
@@ -58,6 +91,11 @@ Currently supported models:
Travis & Rye, Erik & Sipes, Brandon & Teplov, Sam. (2019). Handoff All Your Privacy A Review of Apples Bluetooth
Low Energy Continuity Protocol. Proceedings on Privacy Enhancing Technologies. 2019. 34-53. 10.2478/popets-2019-0057.
* Celosia, Guillaume. (2020). Privacy Challenges in Wireless Communications of the Internet of Things.
* The [MagicPods](https://github.com/steam3d/MagicPods-Windows) project. If you are looking for "CAPod for Windows",
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
+1
View File
@@ -0,0 +1 @@
5.2.4-rc0 50204000
+25
View File
@@ -0,0 +1,25 @@
theme: minima
plugins:
- jekyll-relative-links
- jekyll-github-metadata
- jemoji
relative_links:
enabled: true
collections: true
title: "CAPod"
description: "A companion app for AirPods on Android."
author: "by Matthias Urhahn"
include:
- PRIVACY_POLICY.md
- README.md
- CHANGELOG.md
exclude:
- buildSrc
- gradle/wrapper
- fastlane
- crowdin*
- app
- CONTRIBUTING.md
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="{{ page.lang | default: site.lang | default: 'en' }}">
{%- include head.html -%}
<body>
<main class="page-content" aria-label="Content">
<div class="wrapper">
{{ content }}
</div>
</main>
{%- include footer.html -%}
</body>
</html>
-266
View File
@@ -1,266 +0,0 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'kotlin-parcelize'
id 'androidx.navigation.safeargs.kotlin'
id 'com.bugsnag.android.gradle'
id 'dagger.hilt.android.plugin'
}
def gitSha = 'git rev-parse --short HEAD'.execute([], project.rootDir).text.trim()
def buildTime = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT+1"))
android {
def packageName = "eu.darken.capod"
compileSdkVersion buildConfig.compileSdk
Properties bugsnagProps = new Properties()
def bugsnagPropsFile = new File(System.properties['user.home'], ".appconfig/${packageName}/bugsnag.properties")
if (bugsnagPropsFile.canRead()) bugsnagProps.load(new FileInputStream(bugsnagPropsFile))
defaultConfig {
applicationId "${packageName}"
minSdkVersion buildConfig.minSdk
targetSdkVersion buildConfig.targetSdk
versionCode buildConfig.version.code
versionName buildConfig.version.name
testInstrumentationRunner "eu.darken.capod.HiltTestRunner"
buildConfigField "String", "GITSHA", "\"${gitSha}\""
buildConfigField "String", "BUILDTIME", "\"${buildTime}\""
manifestPlaceholders = [bugsnagApiKey: "fake"]
}
signingConfigs {
release {}
}
def signingPropFile = new File(System.properties['user.home'], ".appconfig/${packageName}/signing.properties")
if (signingPropFile.canRead()) {
Properties signingProps = new Properties()
signingProps.load(new FileInputStream(signingPropFile))
signingConfigs {
release {
storeFile new File(signingProps['release.storePath'])
keyAlias signingProps['release.keyAlias']
storePassword signingProps['release.storePassword']
keyPassword signingProps['release.keyPassword']
}
}
}
buildTypes {
def proguardRulesRelease = fileTree(dir: "../proguard", include: ["*.pro"]).asList().toArray()
debug {
minifyEnabled false
shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
proguardFiles proguardRulesRelease
proguardFiles 'proguard-rules-debug.pro'
manifestPlaceholders = [bugsnagApiKey: bugsnagProps.getProperty("bugsnag.apikey", "")]
}
release {
signingConfig signingConfigs.release
lintOptions {
abortOnError true
fatal 'StopShip'
}
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
proguardFiles proguardRulesRelease
manifestPlaceholders = [bugsnagApiKey: bugsnagProps.getProperty("bugsnag.apikey", "")]
}
}
flavorDimensions "version"
productFlavors {
gplay {
}
foss {
}
}
applicationVariants.all { variant ->
def flavor = variant.productFlavors[0].name.toUpperCase()
def buildType = variant.buildType.name.toUpperCase()
def versionCode = defaultConfig.versionCode
def versionName = defaultConfig.versionName
if (variant.buildType.name == "debug") {
variant.mergedFlavor.resourceConfigurations.clear()
variant.mergedFlavor.resourceConfigurations.add("en")
variant.mergedFlavor.resourceConfigurations.add("de")
} else if (variant.buildType.name != "debug") {
variant.outputs.each { output ->
output.outputFileName = "${packageName}-v${versionName}(${versionCode})-${gitSha}-${flavor}-${buildType}.apk"
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding true
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += [
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xuse-experimental=kotlinx.coroutines.FlowPreview",
"-Xuse-experimental=kotlin.time.ExperimentalTime",
"-Xuse-experimental=kotlin.ExperimentalUnsignedTypes",
"-Xuse-experimental=kotlin.contracts.ExperimentalContracts",
"-Xopt-in=kotlin.RequiresOptIn"
]
}
}
testOptions {
unitTests.all {
useJUnitPlatform()
}
unitTests {
includeAndroidResources = true
}
}
sourceSets {
test {
java.srcDirs += "$projectDir/src/testShared/java"
}
androidTest {
java.srcDirs += "$projectDir/src/testShared/java"
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
}
dependencies {
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin.core}"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.kotlin.coroutines}"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.kotlin.coroutines}"
testImplementation "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin.core}"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.kotlin.coroutines}"
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.kotlin.coroutines}") {
// conflicts with mockito due to direct inclusion of byte buddy
exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug"
}
// Debugging
implementation('com.bugsnag:bugsnag-android:5.9.2')
implementation 'com.getkeepsafe.relinker:relinker:1.4.3'
implementation("com.squareup.moshi:moshi:1.13.0")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.13.0")
// DI
implementation "com.google.dagger:dagger:${versions.dagger.core}"
implementation "com.google.dagger:dagger-android:${versions.dagger.core}"
kapt "com.google.dagger:dagger-compiler:${versions.dagger.core}"
kapt "com.google.dagger:dagger-android-processor:${versions.dagger.core}"
implementation "com.google.dagger:hilt-android:${versions.dagger.core}"
kapt "com.google.dagger:hilt-android-compiler:${versions.dagger.core}"
testImplementation "com.google.dagger:hilt-android-testing:${versions.dagger.core}"
kaptTest "com.google.dagger:hilt-android-compiler:${versions.dagger.core}"
androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.dagger.core}"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:${versions.dagger.core}"
kapt "androidx.hilt:hilt-compiler:1.0.0"
implementation 'androidx.hilt:hilt-common:1.0.0'
kaptTest "androidx.hilt:hilt-compiler:1.0.0"
testImplementation 'androidx.hilt:hilt-common:1.0.0'
// Support libs
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'androidx.annotation:annotation:1.3.0'
implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.fragment:fragment-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.4.0'
implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
implementation "androidx.navigation:navigation-fragment-ktx:2.3.5"
implementation "androidx.navigation:navigation-ui-ktx:2.3.5"
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
def work_version = "2.7.1"
implementation "androidx.work:work-runtime:${work_version}"
testImplementation "androidx.work:work-testing:${work_version}"
implementation "androidx.work:work-runtime-ktx:${work_version}"
implementation 'androidx.hilt:hilt-work:1.0.0'
// IAP
gplayImplementation 'com.android.billingclient:billing:4.0.0'
// UI
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
implementation 'com.google.android.material:material:1.6.0-alpha01'
// Testing
testImplementation 'junit:junit:4.13.2'
testImplementation "org.junit.vintage:junit-vintage-engine:5.7.1"
testImplementation "androidx.test:core-ktx:1.4.0"
testImplementation "io.mockk:mockk:1.12.1"
androidTestImplementation "io.mockk:mockk-android:1.11.0"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.1"
testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.1"
testImplementation "org.junit.jupiter:junit-jupiter-params:5.7.1"
androidTestImplementation "androidx.navigation:navigation-testing:2.3.5"
testImplementation "io.kotest:kotest-runner-junit5:4.6.2"
testImplementation "io.kotest:kotest-assertions-core-jvm:4.6.2"
testImplementation "io.kotest:kotest-property-jvm:4.6.2"
androidTestImplementation "io.kotest:kotest-assertions-core-jvm:4.6.2"
androidTestImplementation "io.kotest:kotest-property-jvm:4.6.2"
testImplementation 'android.arch.core:core-testing:1.1.1'
androidTestImplementation 'android.arch.core:core-testing:1.1.1'
debugImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0'
}
+214
View File
@@ -0,0 +1,214 @@
plugins {
id("projectConfig")
id("com.android.application")
id("kotlin-android")
id("com.google.devtools.ksp")
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")
android {
compileSdk = projectConfig.compileSdk
defaultConfig {
namespace = projectConfig.packageName
minSdk = projectConfig.minSdk
targetSdk = projectConfig.targetSdk
versionCode = projectConfig.version.code.toInt()
versionName = projectConfig.version.name
testInstrumentationRunner = "eu.darken.capod.HiltTestRunner"
buildConfigField("String", "PACKAGENAME", "\"${projectConfig.packageName}\"")
buildConfigField("String", "VERSION_CODE", "\"${projectConfig.version.code}\"")
buildConfigField("String", "VERSION_NAME", "\"${projectConfig.version.name}\"")
}
// Enable automatic per-app language preferences generation
androidResources {
@Suppress("UnstableApiUsage")
generateLocaleConfig = true
}
signingConfigs {
val basePath = File(System.getProperty("user.home"), ".config/projects/${projectConfig.packageName}")
create("releaseFoss") {
setupCredentials(File(basePath, "signing-foss.properties"))
}
create("releaseGplay") {
setupCredentials(File(basePath, "signing-gplay-upload.properties"))
}
}
flavorDimensions.add("version")
productFlavors {
create("foss") {
dimension = "version"
signingConfig = signingConfigs["releaseFoss"]
// The info block is encrypted and can only be read by google
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
create("gplay") {
dimension = "version"
signingConfig = signingConfigs["releaseGplay"]
}
}
buildTypes {
val customProguardRules = fileTree(File(projectDir, "proguard")) {
include("*.pro")
}
debug {
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(*customProguardRules.toList().toTypedArray())
proguardFiles("proguard-rules-debug.pro")
}
create("beta") {
lint {
abortOnError = true
fatal.add("StopShip")
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(*customProguardRules.toList().toTypedArray())
}
release {
lint {
abortOnError = true
fatal.add("StopShip")
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(*customProguardRules.toList().toTypedArray())
}
}
experimentalProperties["android.experimental.enableScreenshotTest"] = true
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.ExperimentalStdlibApi",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlin.ExperimentalUnsignedTypes",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-Xannotation-default-target=param-property"
)
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
//noinspection WrongGradleMethod
tasks.withType<Test> {
useJUnitPlatform()
maxHeapSize = "4g"
}
}
}
androidComponents {
onVariants { variant ->
val buildType = variant.buildType ?: return@onVariants
if (buildType != "release" && buildType != "beta") return@onVariants
val formattedVariantName = variant.name
.replace(Regex("([a-z])([A-Z])"), "$1-$2")
.uppercase()
val apkFolder = variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.APK)
val loader = variant.artifacts.getBuiltArtifactsLoader()
val packageName = projectConfig.packageName
val renameTask = tasks.register("rename${variant.name.replaceFirstChar { it.uppercase() }}Apk") {
inputs.files(apkFolder)
outputs.upToDateWhen { false }
doLast {
val builtArtifacts = loader.load(apkFolder.get()) ?: return@doLast
builtArtifacts.elements.forEach { element ->
val apkFile = File(element.outputFile)
val outputFileName = "$packageName-v${element.versionName}-${element.versionCode}-$formattedVariantName.apk"
if (apkFile.exists() && apkFile.name != outputFileName) {
apkFile.copyTo(File(apkFile.parentFile, outputFileName), overwrite = true)
}
}
}
}
tasks.matching { it.name == "assemble${variant.name.replaceFirstChar { it.uppercase() }}" }.configureEach {
finalizedBy(renameTask)
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
addBaseKotlin()
addDagger()
addOkio()
addBaseAndroid()
addBaseAndroidUi()
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
addNavigation()
addCompose()
addGlance()
addWorkerManager()
addDataStore()
addNavigation3()
addSerialization()
addTesting()
"gplayImplementation"("com.android.billingclient:billing:8.3.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:8.3.0")
"gplayImplementation"("com.google.android.play:review:2.0.2")
"gplayImplementation"("com.google.android.play:review-ktx:2.0.2")
// Robolectric-backed Compose UI tests (run as regular unit tests via the vintage engine).
testImplementation(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
testImplementation("androidx.compose.ui:ui-test-junit4")
testImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("org.robolectric:robolectric:4.15.1")
"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>;
}
+9 -22
View File
@@ -1,23 +1,10 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
-keep class eu.darken.capod.BuildConfig { *; }
-dontobfuscate
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-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>(...); }
# Play Core KTX references this compile-time-only GMS annotation not on the runtime classpath
-dontwarn com.google.android.gms.common.annotation.NoNullnessRewrite
+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,297 @@
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.core.MonitorMode
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,
effectiveMode = MonitorMode.AUTOMATIC,
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()
@@ -0,0 +1,31 @@
package eu.darken.capod.common.review
import android.app.Activity
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FossReviewTool @Inject constructor() : ReviewTool {
override val state: Flow<ReviewTool.State> = flowOf(ReviewTool.State())
override suspend fun dismiss() {
log(TAG, INFO) { "dismiss()" }
// NOOP
}
override suspend fun reviewNow(activity: Activity) {
log(TAG, INFO) { "reviewNow($activity)" }
// NOOP
}
companion object {
private val TAG = logTag("Review", "Tool", "FOSS")
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.common.review
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ReviewModule {
@Binds
@Singleton
abstract fun reviewTool(tool: FossReviewTool): ReviewTool
}
@@ -4,7 +4,8 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.upgrade.core.UpgradeControlFoss
import eu.darken.capod.common.upgrade.core.UpgradeDiagnosticsFoss
import eu.darken.capod.common.upgrade.core.UpgradeRepoFoss
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@@ -12,6 +13,10 @@ import javax.inject.Singleton
abstract class UpgradeModule {
@Binds
@Singleton
abstract fun control(foss: UpgradeControlFoss): UpgradeRepo
abstract fun control(foss: UpgradeRepoFoss): UpgradeRepo
@Binds
@Singleton
abstract fun diagnostics(foss: UpgradeDiagnosticsFoss): UpgradeDiagnostics
}
@@ -1,24 +1,42 @@
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
// Retained legacy migration: installs that predate the DataStore move still carry their upgrade
// state in the "settings_foss" SharedPreferences file.
private val Context.fossCacheDataStore by preferencesDataStore(
name = "settings_foss",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_foss")) },
)
@Singleton
class FossCache @Inject constructor(
@ApplicationContext context: Context,
moshi: Moshi
class FossCache internal constructor(
// Test seam: the store is handed in so a test can supply its own DataStore instead of the
// Context-bound production delegate. Same pattern as BillingCache.
private val dataStore: DataStore<Preferences>,
json: Json,
) {
private val preferences = context.getSharedPreferences("settings_foss", Context.MODE_PRIVATE)
@Inject constructor(
@ApplicationContext context: Context,
@SerializationCapod json: Json,
) : this(context.fossCacheDataStore, json)
val upgrade = preferences.createFlowPreference<FossUpgrade?>(
val upgrade = dataStore.createValue<FossUpgrade?>(
key = "foss.upgrade",
moshi = moshi,
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,71 +0,0 @@
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
@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 ->
if (data == null) {
Info()
} else {
Info(
isPro = true,
upgradedAt = data.upgradedAt,
upgradeReason = data.reason
)
}
}
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/d4rken/capod")
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()
}
data class Info(
override val isPro: Boolean = false,
override val upgradedAt: Instant? = null,
val upgradeReason: FossUpgrade.Reason? = null,
) : UpgradeRepo.Info {
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
}
}
@@ -0,0 +1,15 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
import javax.inject.Inject
import javax.inject.Singleton
/**
* FOSS has no store entitlement to reconcile: the upgrade state is a local sponsor record, already
* covered by the existing header fields. Nothing to add.
*/
@Singleton
class UpgradeDiagnosticsFoss @Inject constructor() : UpgradeDiagnostics {
override suspend fun debugInfo(): String? = null
}
@@ -0,0 +1,151 @@
package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import java.time.Instant
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UpgradeRepoFoss @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val fossCache: FossCache,
private val webpageTool: WebpageTool,
) : UpgradeRepo {
override val storeSite: String = STORE_SITE
override val upgradeSite: String = UPGRADE_SITE
override val betaSite: String = BETA_SITE
private val refreshTrigger = MutableStateFlow(UUID.randomUUID())
// Written only from the sharing coroutine (single collector) — no synchronization needed.
// Recorded INSIDE the flatMapLatest block, upstream of its channel buffer: a downstream onEach
// can still be waiting on a buffered emission when the inner flow throws, and the catch below
// would then read a stale (null) value and revoke an entitlement we already saw.
private var lastKnownInfo: Info? = null
override val upgradeInfo: Flow<UpgradeRepo.Info> = refreshTrigger
.flatMapLatest {
fossCache.upgrade.flow
.map { data ->
if (data == null) {
Info()
} else {
Info(
isPro = true,
upgradedAt = data.upgradedAt,
upgradeReason = data.reason,
)
}
}
// Same coroutine as the throw below, so the ordering is guaranteed. Only
// successfully mapped elements pass here — catch emissions go straight downstream
// and never record themselves as a last known state.
.onEach { lastKnownInfo = it }
.catch { e ->
// A SharedFlow cannot fail: without this, a thrown cache read dies inside
// shareIn's sharing coroutine and every collector hangs forever (VM state stuck
// on Loading, checkSponsorReturn suspended mid-unlock). The catch sits INSIDE
// flatMapLatest so the error completes only this inner subscription — refresh()
// resubscribes the cache and recovery stays possible. Last-known preservation:
// a late read failure must not revoke an entitlement we already saw; the error
// rides on the previous Info instead. Contrast: gplay keeps a retryWhen loop
// because billing re-settles in-place — the FOSS read is a local one-shot, and
// refresh-driven resubscription IS the retry.
if (e is CancellationException) throw e
log(TAG, WARN) { "upgradeInfo read failed: ${e.asLog()}" }
emit((lastKnownInfo ?: Info()).copy(error = e))
}
}
.setupCommonEventHandlers(TAG) { "upgradeInfo" }
.shareIn(appScope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
// Synchronous so the caller learns whether the page actually opened: the FOSS unlock heuristic
// only arms on a successful launch, and a fire-and-forget coroutine can't report that back.
fun openGithubSponsorsPage(): Boolean {
log(TAG) { "openGithubSponsorsPage()" }
return webpageTool.open(upgradeSite)
}
// Writes capod's RETAINED persistence schema: existing supporter records are serialized with
// `reason` (foss.upgrade.reason.*). Adopting canonical's `upgradeType` schema would decode
// every stored record as null and strip those supporters' entitlement.
/**
* Create-only-if-absent inside the store transaction: an existing record (and its upgradedAt —
* the user-visible "supporter since" date) is never replaced. The VM-level isPro guard alone is
* not race-free: it reads a shareIn replay that can be stale. Note the kept record is still
* re-encoded through the current schema — decoded fields are preserved exactly.
*
* Caveat from [FossCache]'s `onErrorFallbackToDefault = true`: a stored record that fails to
* decode reads as null and therefore counts as ABSENT to this transaction, i.e. it gets
* replaced. That matches the pre-existing read behaviour — such a record already presents the
* user as free — and re-creating it on the next successful sponsor visit is the recovery path.
* Decode failures therefore fall back to absent by design (the flag), so the error path around
* [upgradeInfo] covers IO/corruption throws, not schema mismatches.
*
* @return true if a new record was created, false if an existing record was kept.
*/
internal suspend fun persistUpgrade(): Boolean {
log(TAG) { "persistUpgrade()" }
val updated = fossCache.upgrade.update { existing ->
existing ?: FossUpgrade(
upgradedAt = Instant.now(),
reason = FossUpgrade.Reason.DONATED,
)
}
// A returned transaction proves the store is readable again: revive a possibly error-stuck
// inner flow so the record propagates to collectors still holding the error replay.
refresh()
return if (updated.old == null) {
true
} else {
log(TAG, WARN) {
"persistUpgrade(): Record already exists (upgradedAt=${updated.old.upgradedAt}), keeping it"
}
false
}
}
override suspend fun refresh() {
log(TAG) { "refresh()" }
refreshTrigger.value = UUID.randomUUID()
}
data class Info(
override val isPro: Boolean = false,
override val upgradedAt: Instant? = null,
val upgradeReason: FossUpgrade.Reason? = null,
override val error: Throwable? = null,
) : UpgradeRepo.Info {
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
// The FOSS entitlement is a local cache read — authoritative from the first emission,
// there is no billing handshake to wait out.
override val isSettled: Boolean = true
}
companion object {
private const val STORE_SITE = "https://github.com/d4rken-org/capod/releases"
private const val UPGRADE_SITE = "https://github.com/sponsors/d4rken"
private const val BETA_SITE = "https://github.com/d4rken-org/capod/releases"
private val TAG = logTag("Upgrade", "Foss", "Repo")
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.common.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> { key -> UpgradeScreenHost(route = key) }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: UpgradeNavigation): NavigationEntry
}
}
@@ -0,0 +1,333 @@
package eu.darken.capod.common.upgrade.ui
import android.widget.Toast
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome
import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Info
import androidx.compose.material.icons.twotone.Verified
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
import eu.darken.capod.common.navigation.Nav
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
// Which presentation the FOSS upgrade screen shows: the classic support pitch, or one of the
// status views behind the settings "upgrade status" entry.
internal enum class FossUpgradeView {
PITCH,
STATUS_FREE,
STATUS_UPGRADED,
}
// The Settings row and the PITCH title both read this one resource, so they cannot name the tier
// differently — unlike the composed brand title, this stays a support ask ("Sponsor CAPod") rather
// than naming the flavor. gplay's counterpart in UpgradeScreen.kt composes from brandTitleText
// instead; each flavor implementation lives only in its own source set.
@Composable
internal fun settingsUpgradeStatusTitle(): String = stringResource(R.string.upgrade_foss_sponsor_label)
@Composable
fun UpgradeScreenHost(
route: Nav.Main.Upgrade = Nav.Main.Upgrade(),
vm: UpgradeViewModel = hiltViewModel(),
) {
LaunchedEffect(route) { vm.bindRoute(route) }
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Seeded from the ViewModel's handle-backed pending launch: after a process death while the
// sponsor page was open, a blank tracker would swallow the very first return. The handle is the
// authority on whether a return is still expected, so it reconstructs the tracker's state.
val sponsorReturnTracker = remember(vm) {
SponsorReturnTracker(wentToBackground = vm.hasPendingSponsorLaunch())
}
LaunchedEffect(Unit) {
vm.snackbarEvents.collect { stringRes ->
snackbarHostState.showSnackbar(context.getString(stringRes))
}
}
LaunchedEffect(Unit) {
vm.toastEvents.collect { stringRes ->
Toast.makeText(context, context.getString(stringRes), Toast.LENGTH_LONG).show()
}
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
sponsorReturnTracker.onStop()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (sponsorReturnTracker.consumeResumeReturn()) {
vm.checkSponsorReturn()
}
}
val state by vm.state.collectAsStateWithLifecycle()
UpgradeScreen(
// Until the route binding lands (one frame): the default route keeps rendering the pitch
// exactly as before, only the manage route waits for the status decision.
view = state?.view ?: FossUpgradeView.PITCH.takeIf { !route.manage },
supporterSince = state?.supporterSince,
snackbarHostState = snackbarHostState,
onGithubSponsors = vm::goGithubSponsors,
onOpenSponsors = vm::openSponsors,
onShowUpgradeOptions = vm::onShowUpgradeOptions,
onNavigateUp = vm::navUp,
)
}
@Composable
internal fun UpgradeScreen(
view: FossUpgradeView? = FossUpgradeView.PITCH,
supporterSince: Instant? = null,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onGithubSponsors: () -> Unit = {},
onOpenSponsors: () -> Unit = {},
onShowUpgradeOptions: () -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
UpgradeScreenScaffold(
// Status views describe the existing install, not a support ask — they get the composed
// flavor title, with the postfix highlighted for supporters like the dashboard does it.
title = if (view == FossUpgradeView.PITCH) {
AnnotatedString(settingsUpgradeStatusTitle())
} else {
// "CAPod FOSS", not "CAPod Pro": the FOSS flavor's own qualifier resource supplies the
// tier word, so the title names this build. The upgraded gate keeps the highlight for
// supporters only.
upgradeScreenTitle(upgraded = view == FossUpgradeView.STATUS_UPGRADED)
},
onNavigateUp = onNavigateUp,
snackbarHostState = snackbarHostState,
) { paddingValues ->
when (view) {
null -> Unit // Route not bound yet (single frame); content lands with the next state.
FossUpgradeView.PITCH -> UpgradePitchContent(
paddingValues = paddingValues,
onGithubSponsors = onGithubSponsors,
)
FossUpgradeView.STATUS_FREE -> UpgradeStatusFreeContent(
paddingValues = paddingValues,
onShowUpgradeOptions = onShowUpgradeOptions,
)
FossUpgradeView.STATUS_UPGRADED -> UpgradeStatusUpgradedContent(
paddingValues = paddingValues,
supporterSince = supporterSince,
onOpenSponsors = onOpenSponsors,
)
}
}
}
@Composable
private fun UpgradePitchContent(
paddingValues: PaddingValues,
onGithubSponsors: () -> Unit,
) {
UpgradeScreenContent(
paddingValues = paddingValues,
) {
UpgradeHeroCard(
text = stringResource(R.string.upgrade_foss_preamble),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_why_title),
icon = Icons.TwoTone.AutoAwesome,
) {
UpgradeFeatureList(text = upgradeBenefitsText())
}
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_how_title),
icon = Icons.TwoTone.Favorite,
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_how_body))
}
UpgradeActionCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
),
) {
Button(
onClick = onGithubSponsors,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.FOSS_SPONSOR),
) {
Text(stringResource(R.string.upgrade_foss_sponsor_action))
}
UpgradeHintText(text = stringResource(R.string.upgrade_foss_sponsor_subtitle))
}
}
}
@Composable
private fun UpgradeStatusFreeContent(
paddingValues: PaddingValues,
onShowUpgradeOptions: () -> Unit,
) {
UpgradeScreenContent(
paddingValues = paddingValues,
) {
UpgradeHeader(
mascotSize = 104.dp,
)
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_status_free_title),
icon = Icons.TwoTone.Info,
modifier = Modifier.testTag(UpgradeScreenTags.FOSS_STATUS_FREE),
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_status_free_body))
Button(
onClick = onShowUpgradeOptions,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS),
) {
Text(stringResource(R.string.upgrade_screen_status_free_action))
}
}
}
}
@Composable
private fun UpgradeStatusUpgradedContent(
paddingValues: PaddingValues,
supporterSince: Instant? = null,
onOpenSponsors: () -> Unit,
) {
UpgradeScreenContent(
paddingValues = paddingValues,
) {
UpgradeHeader(
mascotSize = 104.dp,
)
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_status_upgraded_title),
icon = Icons.TwoTone.Verified,
modifier = Modifier.testTag(UpgradeScreenTags.FOSS_STATUS_UPGRADED),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(
text = stringResource(R.string.upgrade_foss_supporter_thanks),
style = MaterialTheme.typography.bodyMedium,
)
supporterSince?.let { since ->
val formatter = remember {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault())
}
Text(
text = stringResource(R.string.upgrade_foss_supporter_since, formatter.format(since)),
style = MaterialTheme.typography.bodySmall,
)
}
}
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_recurring_title),
icon = Icons.TwoTone.Favorite,
) {
UpgradeSectionBody(text = stringResource(R.string.upgrade_screen_recurring_body))
OutlinedButton(
onClick = onOpenSponsors,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.FOSS_DONATE),
) {
Text(stringResource(R.string.upgrade_foss_sponsor_again_action))
}
}
}
}
internal class SponsorReturnTracker(
private var wentToBackground: Boolean = false,
) {
fun onStop() {
wentToBackground = true
}
fun consumeResumeReturn(): Boolean {
return if (wentToBackground) {
wentToBackground = false
true
} else {
false
}
}
}
@Preview2
@Composable
private fun UpgradeScreenPreview() {
PreviewWrapper {
UpgradeScreen()
}
}
@Preview2
@Composable
private fun UpgradeScreenStatusFreePreview() {
PreviewWrapper {
UpgradeScreen(view = FossUpgradeView.STATUS_FREE)
}
}
@Preview2
@Composable
private fun UpgradeScreenStatusUpgradedPreview() {
PreviewWrapper {
UpgradeScreen(
view = FossUpgradeView.STATUS_UPGRADED,
supporterSince = Instant.ofEpochMilli(1_700_000_000_000L),
)
}
}
@@ -0,0 +1,198 @@
package eu.darken.capod.common.upgrade.ui
import android.os.SystemClock
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.UpgradeRepoFoss
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class UpgradeViewModel @Inject constructor(
private val handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
private val upgradeRepo: UpgradeRepoFoss,
) : ViewModel4(dispatcherProvider = dispatcherProvider) {
// Route is bound from the Host via bindRoute(); SavedStateHandle.toRoute<>() crashes under Nav3.
private val routeFlow = MutableStateFlow<Nav.Main.Upgrade?>(null)
fun bindRoute(route: Nav.Main.Upgrade) {
if (routeFlow.value != null) return
routeFlow.value = route
}
val snackbarEvents = SingleEventFlow<Int>()
val toastEvents = SingleEventFlow<Int>()
// Which presentation the screen shows. The manage route (settings "upgrade status" entry)
// gets a status view first; the pitch only appears once a free user asks for the upgrade
// options. Upgrading wins on EVERY route, not just manage: forced routes (Pro-locked settings)
// stay open after the sponsor flow completes, and the pitch with its live sponsor button reads
// as "sponsoring didn't work" to a fresh supporter — the toast alone is too transient for a
// money moment without a receipt behind it. null until the route is bound.
internal val state: StateFlow<State?> = combine(
routeFlow,
upgradeRepo.upgradeInfo,
handle.getStateFlow(KEY_SHOW_UPGRADE_OPTIONS, false),
) { route, info, showOptions ->
val view = when {
route == null -> null
info.isPro -> FossUpgradeView.STATUS_UPGRADED
route.manage && !showOptions -> FossUpgradeView.STATUS_FREE
else -> FossUpgradeView.PITCH
}
// Derived in the same emission as the view on purpose: a sibling flow would let the
// upgraded status render for a frame without the date it is supposed to carry.
view?.let {
State(
view = it,
supporterSince = info.upgradedAt.takeIf { _ -> it == FossUpgradeView.STATUS_UPGRADED },
)
}
}.safeStateIn(
initialValue = null,
onError = { State(view = FossUpgradeView.PITCH) },
)
// internal like FossUpgradeView: the view enum is a screen-local presentation detail.
internal data class State(
val view: FossUpgradeView,
val supporterSince: Instant? = null,
)
init {
routeFlow
.filterNotNull()
.take(1)
.onEach { route ->
// The manage route is the settings "upgrade status" entry — upgraded users must
// not be bounced out. Forced routes keep their existing don't-auto-close semantics.
if (!route.forced && !route.manage) {
upgradeRepo.upgradeInfo
.filter { it.isPro }
.take(1)
.onEach { navUp() }
.launchInViewModel()
}
}
.launchInViewModel()
upgradeRepo.upgradeInfo
.filter { !it.isPro && it.error != null }
.onEach { current ->
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
errorEvents.tryEmit(current.error!!)
}
.launchInViewModel()
}
fun onShowUpgradeOptions() {
log(TAG) { "onShowUpgradeOptions()" }
// Handle-backed: surviving process recreation keeps the user on the pitch they asked for.
handle[KEY_SHOW_UPGRADE_OPTIONS] = true
}
/** Armed variant: the pitch's sponsor button, which starts the return-after-5s unlock heuristic. */
fun goGithubSponsors() {
log(TAG) { "goGithubSponsors()" }
if (hasPendingSponsorLaunch()) {
log(TAG) { "A sponsor launch is already awaiting its return" }
return
}
// Only arm the heuristic if the page actually opened; otherwise an unrelated later
// background/foreground round-trip would grant supporter status with no page ever shown.
if (!upgradeRepo.openGithubSponsorsPage()) {
log(TAG) { "Sponsor page didn't open; not arming the unlock heuristic" }
return
}
handle[KEY_SPONSOR_PRESSED_AT] = SystemClock.elapsedRealtime()
}
/**
* Unarmed variant: the status view's donate button. An existing supporter re-visiting the page
* must not re-arm the unlock heuristic — there is nothing left to unlock.
*/
fun openSponsors() {
log(TAG) { "openSponsors()" }
upgradeRepo.openGithubSponsorsPage()
}
/**
* Whether a sponsor-page launch is still awaiting its return.
*
* Handle-backed, so it survives process recreation while the browser is in front — the screen's
* in-memory return tracker does not, and gating on that alone drops the first return after a
* recreation.
*/
fun hasPendingSponsorLaunch(): Boolean = handle.contains(KEY_SPONSOR_PRESSED_AT)
fun checkSponsorReturn() = launch {
val pressedAt = handle.remove<Long>(KEY_SPONSOR_PRESSED_AT) ?: return@launch
try {
// Evaluated before the duration: an already upgraded supporter (recurring donation
// button) has nothing left to unlock, so this fast path exists for the UX — return
// quietly, no redundant write attempt and no thanks toast for an unlock that already
// happened. Data integrity is not this guard's job: the repo's create-only transaction
// owns that.
if (upgradeRepo.upgradeInfo.first().isPro) {
log(TAG) { "checkSponsorReturn(): Already upgraded, staying quiet" }
return@launch
}
val elapsed = SystemClock.elapsedRealtime() - pressedAt
log(TAG) { "checkSponsorReturn(): elapsed=${elapsed}ms" }
if (elapsed < SPONSOR_DELAY_MS) {
log(TAG) { "checkSponsorReturn(): Too quick, showing snackbar" }
snackbarEvents.tryEmit(R.string.upgrade_foss_sponsor_returned_early)
} else {
log(TAG) { "checkSponsorReturn(): Delay passed, persisting upgrade" }
val created = upgradeRepo.persistUpgrade()
if (created) {
toastEvents.tryEmit(R.string.upgrade_foss_supporter_thanks)
} else {
// The isPro fast-path read a stale emission; the transaction kept the existing record.
log(TAG) { "checkSponsorReturn(): Record already existed, staying quiet" }
}
}
} catch (e: Exception) {
// The marker was consumed above; neither a failed entitlement read nor a failed write may
// eat the user's valid sponsor visit — restore it so the next return/resume can retry the
// unlock. Conditional: the user may have armed a NEWER launch while this attempt was
// suspended, and that one must survive. The contains-check has a small check-then-act
// window against a concurrent new arm; accepted — the create-only transaction owns data
// integrity, a wrong winner only changes which REAL visit's timestamp gates the unlock.
// Rethrow unconditionally: cancellation is not swallowed, other errors surface via the
// normal error path. A restored marker after a successful persist is harmless — the next
// evaluation hits the quiet isPro path.
if (!handle.contains(KEY_SPONSOR_PRESSED_AT)) {
handle[KEY_SPONSOR_PRESSED_AT] = pressedAt
}
throw e
}
}
companion object {
private const val KEY_SPONSOR_PRESSED_AT = "sponsor_pressed_at"
private const val KEY_SHOW_UPGRADE_OPTIONS = "show_upgrade_options"
private const val SPONSOR_DELAY_MS = 5_000L
private val TAG = logTag("Upgrade", "ViewModel")
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.debug.autoreport
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
abstract class AutoReportingModule {
@Binds
@Singleton
abstract fun autoreporting(foss: FossAutoReporting): AutomaticBugReporter
}
@@ -0,0 +1,17 @@
package eu.darken.capod.debug.autoreport
import android.app.Application
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FossAutoReporting @Inject constructor() : AutomaticBugReporter {
override fun setup(application: Application) {
// NOOP
}
override fun notify(throwable: Throwable) {
throw IllegalStateException("Who initliazed this? Without setup no calls to here!")
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Ondersteuner sedert %s</string>
<string name="upgrade_foss_supporter_thanks">Dankie dat jy CAPod se ontwikkeling ondersteun!</string>
<string name="upgrade_foss_sponsor_again_action">Maak borgblad oop</string>
<string name="upgrade_foss_sponsor_label">Ondersteun CAPod</string>
<string name="settings_upgrade_status_description">Jou ondersteunersstatus.</string>
<string name="upgrade_screen_why_title">Opgraderingsvoordele</string>
<string name="upgrade_screen_how_title">Hoe om te help</string>
<string name="upgrade_screen_how_body">Word \'n beskermheer en borg ontwikkeling! Tik die knoppie hieronder om alle ekstra funksies te aktiveer en my GitHub Sponsors profiel oop te maak.</string>
<string name="upgrade_screen_status_free_title">Gratis weergawe</string>
<string name="upgrade_screen_status_free_body">Jy gebruik die gratis weergawe van CAPod. Ekstra funksies kan ontsluit word deur die ontwikkeling te ondersteun.</string>
<string name="upgrade_screen_status_free_action">Sien opgraderingsopsies</string>
<string name="upgrade_screen_status_upgraded_title">Opgradering aktief</string>
<string name="upgrade_screen_recurring_title">Hou dit voort</string>
<string name="upgrade_screen_recurring_body">CAPod bly verbeter deur opdaterings en regstellings. As jy dit wil volhou, oorweeg \'n herhalende skenking via GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">ልገሳ</string>
<string name="foss_upgrade_alreadydonated_label">አስቀድሜ ልገሳ ፈጽሜአለሁ</string>
<string name="foss_upgrade_no_money_label">ገንዘቤን በሙሉ በAirPods ላይ አውጥቻለሁ</string>
<string name="upgrade_foss_preamble">CAPod FOSS ነፃ እና ክፍት ምንጭ ነው። ግኾሚ ሆኖ ካግኝት፣ ፕሮጀክቱ ቀጥሎ እንደይይድ ለመርዳት ላማቱን ማስደገፏ ያስቡ።</string>
<string name="upgrade_foss_sponsor_action">ልማትን ይደግፉ</string>
<string name="upgrade_foss_sponsor_subtitle">ምንም ማስታወቂያ የሉም። ምንም ክትትል የሉም። ምንም Google Play ቆለፋ የሉም።</string>
<string name="upgrade_foss_sponsor_returned_early">ቀድሞ ተመለሱ፡ ድጋፏዎ CAPodን ህያው ያቆይላል።</string>
<string name="upgrade_foss_supporter_since">ደጋፊ ከ%s ጀምሮ</string>
<string name="upgrade_foss_supporter_thanks">የCAPod ልማትን ስለደገፉ እናመሰግናለን!</string>
<string name="upgrade_foss_sponsor_again_action">የስፖንሰር ገጽን ክፈት</string>
<string name="upgrade_foss_sponsor_label">CAPod ን ደግፍ</string>
<string name="settings_upgrade_status_description">የእርስዎ ደጋፊ ሁኔታ።</string>
<string name="upgrade_screen_why_title">የማሻሻል ጥቅማጥቅሞች</string>
<string name="upgrade_screen_how_title">እንዴት መርዳት እንደሚችሉ</string>
<string name="upgrade_screen_how_body">ደጋፊ ይሁኑ እና ልማትን ይደግፉ! ሁሉንም ተጨማሪ ባህሪያት ለማንቃት እና የእኔን GitHub Sponsors መገለጫ ለመክፈት ከታች ያለውን ቁልፍ ይንኩ።</string>
<string name="upgrade_screen_status_free_title">ነጻ ስሪት</string>
<string name="upgrade_screen_status_free_body">CAPod ነጻ ስሪት ይጠቀማሉ። ተጨማሪ ባህሪዎች በልማት ድጋፍ ሊከፈቱ ይችላሉ።</string>
<string name="upgrade_screen_status_free_action">የማሻሻል አማራጮችን ይመልከቱ</string>
<string name="upgrade_screen_status_upgraded_title">ማሻሻል ንቁ ነው</string>
<string name="upgrade_screen_recurring_title">ይህን ይቀጥሉ</string>
<string name="upgrade_screen_recurring_body">CAPod በዝመናዎች እና ማስተካከያዎች ሁሉ ጊዜ ይሻሻላል። ይህንን ለማቆየት ከወደዱ፣ በGitHub Sponsors ደግሞ ምስዋትን ያስቡ።</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?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">أنفق كل أموالي على سماعات أيربودز</string>
<string name="upgrade_foss_preamble">برنامج كابود FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من جوجل بلاي.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يبقي كابود حيًّا.</string>
<string name="upgrade_foss_supporter_since">داعم منذ %s</string>
<string name="upgrade_foss_supporter_thanks">شكرًا لدعمك تطوير CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">فتح صفحة الرعاية</string>
<string name="upgrade_foss_sponsor_label">دعم كابود</string>
<string name="settings_upgrade_status_description">حالة دعمك.</string>
<string name="upgrade_screen_why_title">مزايا الترقية</string>
<string name="upgrade_screen_how_title">كيفية المساعدة</string>
<string name="upgrade_screen_how_body">كُن راعيًا وادعم تطوير المشروع! اضغط على الزرّ أدناه لتفعيل جميع المزايا الإضافية وفتح ملفّي الشخصي على GitHub Sponsors.</string>
<string name="upgrade_screen_status_free_title">النسخة المجّانية</string>
<string name="upgrade_screen_status_free_body">أنت تستخدم النسخة المجّانية من كابود. يمكنك فتح ميزات إضافية من خلال دعم التطوير.</string>
<string name="upgrade_screen_status_free_action">الاطّلاع على خيارات الترقية</string>
<string name="upgrade_screen_status_upgraded_title">الترقية نشطة</string>
<string name="upgrade_screen_recurring_title">أبقِ التطبيق مستمرًّا</string>
<string name="upgrade_screen_recurring_body">يستمرّ كابود في التطوّر من خلال التحديثات والإصلاحات. إذا كنت ترغب في دعم هذا التطوّر، ففكّر في التبرّع بشكل دوري عبر GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">%s tarixindən bəri dəstəkçi</string>
<string name="upgrade_foss_supporter_thanks">CAPod-un inkişafını dəstəklədiyiniz üçün təşəkkürlər!</string>
<string name="upgrade_foss_sponsor_again_action">Sponsor səhifəsini aç</string>
<string name="upgrade_foss_sponsor_label">CAPod-u sponsorlaşdırın</string>
<string name="settings_upgrade_status_description">Sizin tərəfdar statusu.</string>
<string name="upgrade_screen_why_title">Yüksəltmə üstünləri</string>
<string name="upgrade_screen_how_title">Necə kömək etmək</string>
<string name="upgrade_screen_how_body">Himayədar olun və inkişafı sponsor edin! Bütün əlavə xüsusiyyətləri aktivləşdirmək və mənim GitHub Sponsors profilimi açmaq üçün aşağıdakı düyməni toxunun.</string>
<string name="upgrade_screen_status_free_title">Pulsuz versiya</string>
<string name="upgrade_screen_status_free_body">Siz CAPod-un pulsuz versiyasından istifadə edirsiniz. Əlavə xüsusiyyətlər inkişafı dəstəkləyərək açılır.</string>
<string name="upgrade_screen_status_free_action">Yüksəltmə variantlarına baxın</string>
<string name="upgrade_screen_status_upgraded_title">Yüksəltmə aktiv</string>
<string name="upgrade_screen_recurring_title">Davam etdirin</string>
<string name="upgrade_screen_recurring_body">CAPod yeniləmələr və düzəltmələr vasitəsilə təkamül etməkdə davam edir. Bunu dəstəkləmək istəsəniz, GitHub Sponsors vasitəsilə təkrari donasiya nəzərdən keçirin.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Ахвяраваць</string>
<string name="foss_upgrade_alreadydonated_label">Я ўжо ахвяраваў</string>
<string name="foss_upgrade_no_money_label">Я патраціў усе грошы на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS бясплатны і з адкрытым зыходным кодам. Калі вы лічыце яго карысным, разгледзьце магчымасць спансіравання распрацоўкі, каб дапамагчы трымаць праект ў жывых.</string>
<string name="upgrade_foss_sponsor_action">Падтрымаць распрацоўку</string>
<string name="upgrade_foss_sponsor_subtitle">Ніякай рэкламы. Ніякай адсочкі. Ніякай залежнасці ад Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Ужо вяртаецеся? Ваша падтрымка трымае CAPod ў жывых.</string>
<string name="upgrade_foss_supporter_since">Спонсар з %s</string>
<string name="upgrade_foss_supporter_thanks">Дзякуй за падтрымку развіцця CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Адкрыць старонку спонсарства</string>
<string name="upgrade_foss_sponsor_label">Спонсараваць CAPod</string>
<string name="settings_upgrade_status_description">Ваш статус прыхільніка.</string>
<string name="upgrade_screen_why_title">Перавагі абнаўлення</string>
<string name="upgrade_screen_how_title">Як дапамагчы</string>
<string name="upgrade_screen_how_body">Станьце спонсарам распрацоўкі! Націсніце кнопку ніжэй, каб актывіраваць усе дадатковыя функцыі і адкрыць мой профіль на GitHub Sponsors.</string>
<string name="upgrade_screen_status_free_title">Бясплатная версія</string>
<string name="upgrade_screen_status_free_body">Вы выкарыстоўваеце бясплатную версію CAPod. Дадатковыя функцыі можна разблакаваць, падтрымаўшы развіццё.</string>
<string name="upgrade_screen_status_free_action">Прагледзець варыянты абнаўлення</string>
<string name="upgrade_screen_status_upgraded_title">Абнаўленне актыўнае</string>
<string name="upgrade_screen_recurring_title">Падтрымайце далей</string>
<string name="upgrade_screen_recurring_body">CAPod пастаянна развіваецца дзякуючы абнаўленням і выпраўленням. Калі вы хочаце гэта падтрымаць, разгледзьце магчымасць рэгулярнага ахвяравання праз GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Дарение</string>
<string name="foss_upgrade_alreadydonated_label">Вече съм дарил</string>
<string name="foss_upgrade_no_money_label">Харча всичките си пари за AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS е безплатен и с отворен код. Ако ви е полезен, размислете да подкрепите разработката, за да помогнете проектът да продължи.</string>
<string name="upgrade_foss_sponsor_action">Спонсорирайте разработката</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклами. Без следене. Без заключване в Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Вече ли? Вашата подкрепа поддържа CAPod жив.</string>
<string name="upgrade_foss_supporter_since">Поддържник от %s</string>
<string name="upgrade_foss_supporter_thanks">Благодарим ви, че подкрепяте развитието на CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Отвори страницата за спонсорство</string>
<string name="upgrade_foss_sponsor_label">Спонсорирайте CAPod</string>
<string name="settings_upgrade_status_description">Вашият статус на поддръжник.</string>
<string name="upgrade_screen_why_title">Преимущества на надстройката</string>
<string name="upgrade_screen_how_title">Как да помогнете</string>
<string name="upgrade_screen_how_body">Станете покровител и спонсорирайте разработката! Докоснете бутона по-долу, за да активирате всички допълнителни функции и да отворите моя GitHub Sponsors профил.</string>
<string name="upgrade_screen_status_free_title">Безплатна версия</string>
<string name="upgrade_screen_status_free_body">Използвате безплатната версия на CAPod. Допълнителни функции могат да бъдат отключени чрез подкрепа на разработката.</string>
<string name="upgrade_screen_status_free_action">Вижте опциите за надстройка</string>
<string name="upgrade_screen_status_upgraded_title">Надстройката е активна</string>
<string name="upgrade_screen_recurring_title">Поддържете това</string>
<string name="upgrade_screen_recurring_body">CAPod продължава да се развива чрез актуализации и корекции. Ако искате да подкрепите това, разгледайте възможност за редовно дарение чрез GitHub Sponsors.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">দান করুন</string>
<string name="foss_upgrade_alreadydonated_label">আমি ইতিমধ্যে দান করেছি</string>
<string name="foss_upgrade_no_money_label">আমি আমার সব টাকা AirPods এর জন্য খরচ করি</string>
<string name="upgrade_foss_preamble">CAPod FOSS বিনামূল্যে এবং মুক্ত উৎস। আপনি যদি এটি উপযোগী মনে করেন, প্রকল্পটি চালু রাখতে সাহায্য করতে ডেভেলপমেন্ট স্পনসর করার কথা বিবেচনা করুন।</string>
<string name="upgrade_foss_sponsor_action">স্পনসর ডেভলপমেন্ট</string>
<string name="upgrade_foss_sponsor_subtitle">কোনো বিজ্ঞাপন নেই। কোনো ট্র্যাকিং নেই। Google Play-এর সাথে বাঁধানো নেই।</string>
<string name="upgrade_foss_sponsor_returned_early">ইতোমধ্যে ফিরে গেলেন? আপনার সমর্থন CAPodকে বাঁচিয়ে রাখে।</string>
<string name="upgrade_foss_supporter_since">%s থেকে সমর্থক</string>
<string name="upgrade_foss_supporter_thanks">CAPod-এর উন্নয়নে সমর্থন করার জন্য আপনাকে ধন্যবাদ!</string>
<string name="upgrade_foss_sponsor_again_action">স্পনসর পৃষ্ঠা খুলুন</string>
<string name="upgrade_foss_sponsor_label">CAPod-কে স্পন্সর করুন</string>
<string name="settings_upgrade_status_description">আপনার সমর্থক স্থিতি।</string>
<string name="upgrade_screen_why_title">আপগ্রেড সুবিধা</string>
<string name="upgrade_screen_how_title">কিভাবে সাহায্য করতে পারি</string>
<string name="upgrade_screen_how_body">একটি পৃষ্ঠপোষক এবং স্পনসর উন্নয়ন হয়ে! সমস্ত অতিরিক্ত বৈশিষ্ট্য সক্রিয় করতে নীচের বোতামটি আলতো চাপুন এবং আমার GitHub স্পনসর প্রোফাইল খুলুন।</string>
<string name="upgrade_screen_status_free_title">বিনামূল্যে সংস্করণ</string>
<string name="upgrade_screen_status_free_body">আপনি CAPod এর বিনামূল্যে সংস্করণ ব্যবহার করছেন। উন্নয়ন সমর্থন করে অতিরিক্ত বৈশিষ্ট্য আনলক করা যায়।</string>
<string name="upgrade_screen_status_free_action">আপগ্রেড বিকল্পগুলি দেখুন</string>
<string name="upgrade_screen_status_upgraded_title">আপগ্রেড সক্রিয়</string>
<string name="upgrade_screen_recurring_title">এটি চালু রাখুন</string>
<string name="upgrade_screen_recurring_body">CAPod আপডেট এবং সংশোধনের মাধ্যমে ক্রমাগত উন্নত হচ্ছে। আপনি যদি তা বজায় রাখতে চান তবে GitHub Sponsors এর মাধ্যমে নিয়মিত দান বিবেচনা করুন।</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Col·laborador des de %s</string>
<string name="upgrade_foss_supporter_thanks">Gràcies per donar suport al desenvolupament del CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Obre la pàgina del patrocinador</string>
<string name="upgrade_foss_sponsor_label">Patrocineu el CAPod</string>
<string name="settings_upgrade_status_description">El vostre estat de col·laborador.</string>
<string name="upgrade_screen_why_title">Beneficis de la millora</string>
<string name="upgrade_screen_how_title">Com ajudar</string>
<string name="upgrade_screen_how_body">Convertiu-vos en mecenes i patrocinadors del desenvolupament! Toqueu el botó següent per activar totes les funcions addicionals i obrir el meu perfil de patrocinadors del GitHub.</string>
<string name="upgrade_screen_status_free_title">Versió gratuïta</string>
<string name="upgrade_screen_status_free_body">Esteu utilitzant la versió gratuïta de CAPod. Podeu desbloquejar funcions addicionals donant suport al desenvolupament.</string>
<string name="upgrade_screen_status_free_action">Veure opcions de millora</string>
<string name="upgrade_screen_status_upgraded_title">Millora activa</string>
<string name="upgrade_screen_recurring_title">Manteniu-ho en marxa</string>
<string name="upgrade_screen_recurring_body">CAPod continua evolucionant a través d\'actualitzacions i correccions. Si voleu sostenir-ho, considereu una donació recurrent a través de GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Podporovatel od %s</string>
<string name="upgrade_foss_supporter_thanks">Děkujeme za podporu vývoje CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Otevřít stránku sponzora</string>
<string name="upgrade_foss_sponsor_label">Sponzorovat CAPod</string>
<string name="settings_upgrade_status_description">Váš stav podporovatele.</string>
<string name="upgrade_screen_why_title">Výhody upgradu</string>
<string name="upgrade_screen_how_title">Jak pomoci</string>
<string name="upgrade_screen_how_body">Staňte se patronem a sponzorem vývoje! Klepnutím na tlačítko níže aktivujete všechny další funkce a otevřete můj profil sponzora na GitHubu.</string>
<string name="upgrade_screen_status_free_title">Bezplatná verze</string>
<string name="upgrade_screen_status_free_body">Používáte bezplatnou verzi CAPod. Další funkce lze odemknout podporou vývoje.</string>
<string name="upgrade_screen_status_free_action">Zobrazit možnosti upgradu</string>
<string name="upgrade_screen_status_upgraded_title">Upgrade je aktivní</string>
<string name="upgrade_screen_recurring_title">Pokračuj v tom</string>
<string name="upgrade_screen_recurring_body">CAPod se neustále vyvíjí prostřednictvím aktualizací a oprav. Pokud to chcete podporovat, zvažte pravidelný příspěvek prostřednictvím GitHub sponzorů.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Støtte siden %s</string>
<string name="upgrade_foss_supporter_thanks">Tak fordi du støtter udviklingen af CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Åbn sponsorside</string>
<string name="upgrade_foss_sponsor_label">Sponsor CAPod</string>
<string name="settings_upgrade_status_description">Din supporterstatus.</string>
<string name="upgrade_screen_why_title">Opgraderingens fordele</string>
<string name="upgrade_screen_how_title">Sådan hjælper du</string>
<string name="upgrade_screen_how_body">Bliv patron og sponsorér udvikling! Tryk på knappen nedenfor for at aktivere alle ekstra funktioner og åbne min GitHub-sponsorprofil.</string>
<string name="upgrade_screen_status_free_title">Gratis version</string>
<string name="upgrade_screen_status_free_body">Du bruger den gratis version af CAPod. Ekstra funktioner kan låses op ved at støtte udviklingen.</string>
<string name="upgrade_screen_status_free_action">Se opgraderingsmuligheder</string>
<string name="upgrade_screen_status_upgraded_title">Opgradering aktiv</string>
<string name="upgrade_screen_recurring_title">Hold det kørende</string>
<string name="upgrade_screen_recurring_body">CAPod udvikles løbende gennem opdateringer og fejlrettelser. Hvis du gerne vil understøtte det, kan du overveje en tilbagevendende donation via GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Unterstützer seit %s</string>
<string name="upgrade_foss_supporter_thanks">Vielen Dank, dass du die Entwicklung von CAPod unterstützt!</string>
<string name="upgrade_foss_sponsor_again_action">Sponsoring-Seite öffnen</string>
<string name="upgrade_foss_sponsor_label">CAPod unterstützen</string>
<string name="settings_upgrade_status_description">Dein Unterstützerstatus.</string>
<string name="upgrade_screen_why_title">Vorteile des Upgrades</string>
<string name="upgrade_screen_how_title">Wie man hilft</string>
<string name="upgrade_screen_how_body">Werde Unterstützer und fördere die Entwicklung! Tippe auf den Button unten, um alle Zusatzfunktionen zu aktivieren und mein GitHub-Sponsors-Profil zu öffnen.</string>
<string name="upgrade_screen_status_free_title">Kostenlose Version</string>
<string name="upgrade_screen_status_free_body">Du verwendest die kostenlose Version von CAPod. Zusätzliche Funktionen können durch die Unterstützung der Entwicklung freigeschaltet werden.</string>
<string name="upgrade_screen_status_free_action">Upgrade-Optionen ansehen</string>
<string name="upgrade_screen_status_upgraded_title">Upgrade aktiv</string>
<string name="upgrade_screen_recurring_title">Projekt unterstützen</string>
<string name="upgrade_screen_recurring_body">CAPod entwickelt sich durch Updates und Fehlerbehebungen kontinuierlich weiter. Wenn du das unterstützen möchtest, ziehe eine wiederkehrende Spende über GitHub Sponsors in Betracht.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Δωρεά</string>
<string name="foss_upgrade_alreadydonated_label">Ήδη δώρισα</string>
<string name="foss_upgrade_no_money_label">Ξόδεψα όλα τα χρήματά μου στα AirPods</string>
<string name="upgrade_foss_preamble">Το CAPod FOSS είναι δωρεάν και ανοιχτού κώδικα. Αν το βρίσκετε χρήσιμο, εξετάστε το ενδεχόμενο να χρηματοδοτήσετε την ανάπτυξη για να συνεχίσει το έργο.</string>
<string name="upgrade_foss_sponsor_action">Χορηγήστε την ανάπτυξη</string>
<string name="upgrade_foss_sponsor_subtitle">Χωρίς διαφημίσεις. Χωρίς παρακολούθηση. Χωρίς δέσμευση στο Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Επιστρέψατε ήδη; Η υποστήριξή σας κρατά το CAPod ζωντανό.</string>
<string name="upgrade_foss_supporter_since">Υποστηρικτής από %s</string>
<string name="upgrade_foss_supporter_thanks">Σας ευχαριστούμε που υποστηρίζετε την ανάπτυξη του CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Άνοιγμα σελίδας χορηγίας</string>
<string name="upgrade_foss_sponsor_label">Χορηγήστε το CAPod</string>
<string name="settings_upgrade_status_description">Η κατάστασή σας ως υποστηρικτή.</string>
<string name="upgrade_screen_why_title">Πλεονεκτήματα αναβάθμισης</string>
<string name="upgrade_screen_how_title">Πώς να βοηθήσετε</string>
<string name="upgrade_screen_how_body">Γίνετε patron και χορηγήστε την ανάπτυξη! Πατήστε το κουμπί παρακάτω για να ενεργοποιήσετε όλα τα επιπλέον χαρακτηριστικά και ανοίξτε το προφίλ μου GitHub Sponsors.</string>
<string name="upgrade_screen_status_free_title">Δωρεάν έκδοση</string>
<string name="upgrade_screen_status_free_body">Χρησιμοποιείτε τη δωρεάν έκδοση του CAPod. Επιπλέον δυνατότητες μπορούν να ξεκλειδωθούν υποστηρίζοντας την ανάπτυξη.</string>
<string name="upgrade_screen_status_free_action">Δείτε τις επιλογές αναβάθμισης</string>
<string name="upgrade_screen_status_upgraded_title">Αναβάθμιση ενεργή</string>
<string name="upgrade_screen_recurring_title">Κρατήστε το ζωντανό</string>
<string name="upgrade_screen_recurring_body">Το CAPod συνεχίζει να εξελίσσεται μέσω ενημερώσεων και διορθώσεων. Αν θέλετε να το στηρίξετε, σκεφτείτε μια επαναλαμβανόμενη δωρεά μέσω του GitHub Sponsors.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<string name="upgrade_foss_sponsor_label">Apoyá CAPod</string>
<string name="settings_upgrade_status_description">Tu estado de patrocinador.</string>
<string name="upgrade_screen_why_title">Beneficios de la mejora</string>
<string name="upgrade_screen_how_title">Como ayudar</string>
<string name="upgrade_screen_how_body">¡Conviértete en patrocinador y patrocina el desarrollo! Toque el botón de abajo para activar todas las funciones adicionales y abrir mi perfil de Patrocinadores de GitHub.</string>
<string name="upgrade_screen_status_free_title">Versión gratuita</string>
<string name="upgrade_screen_status_free_body">Estás usando la versión gratuita de CAPod. Se pueden desbloquear características adicionales apoyando el desarrollo.</string>
<string name="upgrade_screen_status_free_action">Ver opciones de mejora</string>
<string name="upgrade_screen_status_upgraded_title">Mejora activa</string>
<string name="upgrade_screen_recurring_title">Mantené el impulso</string>
<string name="upgrade_screen_recurring_body">CAPod sigue evolucionando a través de actualizaciones y correcciones. Si querés sostener eso, considera una donación recurrente a través de GitHub Sponsors.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<string name="upgrade_foss_sponsor_label">Patrocina CAPod</string>
<string name="settings_upgrade_status_description">Tu estado de patrocinador.</string>
<string name="upgrade_screen_why_title">Beneficios de la actualización</string>
<string name="upgrade_screen_how_title">Cómo ayudar</string>
<string name="upgrade_screen_how_body">¡Conviértete en un patrocinador y apoya el desarrollo constante de la aplicación! Presiona el botón a continuación para activar todas las funciones adicionales además de abrir mi perfil de Patrocinadores en GitHub.</string>
<string name="upgrade_screen_status_free_title">Versión gratuita</string>
<string name="upgrade_screen_status_free_body">Estás usando la versión gratuita de CAPod. Puedes desbloquear características extra apoyando el desarrollo.</string>
<string name="upgrade_screen_status_free_action">Ver opciones de actualización</string>
<string name="upgrade_screen_status_upgraded_title">Actualización activa</string>
<string name="upgrade_screen_recurring_title">Sigue adelante</string>
<string name="upgrade_screen_recurring_body">CAPod sigue evolucionando con actualizaciones y correcciones. Si quieres sostener eso, considera una donación recurrente a través de GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<string name="upgrade_foss_sponsor_label">Patrocinar CAPod</string>
<string name="settings_upgrade_status_description">Tu estado como patrocinador.</string>
<string name="upgrade_screen_why_title">Beneficios de la actualización</string>
<string name="upgrade_screen_how_title">Como ayudar</string>
<string name="upgrade_screen_how_body">¡Conviértete en patrocinador y patrocina el desarrollo! Toque el botón de abajo para activar todas las funciones adicionales y abrir mi perfil de Patrocinadores de GitHub.</string>
<string name="upgrade_screen_status_free_title">Versión gratuita</string>
<string name="upgrade_screen_status_free_body">Estás usando la versión gratuita de CAPod. Puedes desbloquear funciones adicionales apoyando el desarrollo.</string>
<string name="upgrade_screen_status_free_action">Ver opciones de actualización</string>
<string name="upgrade_screen_status_upgraded_title">Actualización activa</string>
<string name="upgrade_screen_recurring_title">Apóyalo</string>
<string name="upgrade_screen_recurring_body">CAPod sigue evolucionando gracias a actualizaciones y correcciones. Si quieres mantener eso en marcha, considera una donación recurrente a través de GitHub Sponsors.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Toetaja alates %s</string>
<string name="upgrade_foss_supporter_thanks">Täname, et toetad CAPodi arendust!</string>
<string name="upgrade_foss_sponsor_again_action">Ava sponsorlehekülg</string>
<string name="upgrade_foss_sponsor_label">Rahasta CAPodi</string>
<string name="settings_upgrade_status_description">Sinu toetaja staatus.</string>
<string name="upgrade_screen_why_title">Uuendamise eelised</string>
<string name="upgrade_screen_how_title">Kuidas aidata?</string>
<string name="upgrade_screen_how_body">Hakake toetajaks ja rahastage arendamist. Kõikide lisavõimaluste kasutamiseks ja minu GitHub Sponsors profiili avamiseks puudutage alumist nuppu.</string>
<string name="upgrade_screen_status_free_title">Tasuta versioon</string>
<string name="upgrade_screen_status_free_body">Kasutate CAPodi tasuta versiooni. Lisavõimaluste kasutamiseks toetage arendust.</string>
<string name="upgrade_screen_status_free_action">Vaadake täiendamise valikuid</string>
<string name="upgrade_screen_status_upgraded_title">Uuendus kasutatav</string>
<string name="upgrade_screen_recurring_title">Jätkake nii</string>
<string name="upgrade_screen_recurring_body">CAPod areneb pidevalt värskenduste ja paranduste kaudu. Kui soovite seda toetada, kaaluge korduvat annetust GitHub Sponsorsi kaudu.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Laguntzaile %s(e)tik aurrera</string>
<string name="upgrade_foss_supporter_thanks">Eskerrik asko CAPod-en garapena babesten duzulako!</string>
<string name="upgrade_foss_sponsor_again_action">Ireki babesle-orria</string>
<string name="upgrade_foss_sponsor_label">CAPod babeslatu</string>
<string name="settings_upgrade_status_description">Zure babesle egoera</string>
<string name="upgrade_screen_why_title">Hobekuntza-onurak</string>
<string name="upgrade_screen_how_title">Nola lagundu</string>
<string name="upgrade_screen_how_body">Izan babesle eta garapen babeslari! Egin klik beheko botoian eginbide gehigarri guztiak aktibatzeko eta nire GitHub Sponsors profila irekitzeko.</string>
<string name="upgrade_screen_status_free_title">Doako bertsioa</string>
<string name="upgrade_screen_status_free_body">CAPod-en doako bertsioa erabiltzen ari zara. Funtzio gehigarriak desblokeatu ditzakezu garapena lagunduz.</string>
<string name="upgrade_screen_status_free_action">Hobekuntza-aukerak ikusi</string>
<string name="upgrade_screen_status_upgraded_title">Hobekuntza aktiboa</string>
<string name="upgrade_screen_recurring_title">Jarraitu</string>
<string name="upgrade_screen_recurring_body">CAPod eguneratzen eta konpondutzen jarraitzen du. Hori mantendu nahi baduzu, dohaintza periodikoa GitHub Sponsors bidez kontuan hartu.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?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">من تمام پولم را خرج ایرپاد می‌کنم</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_foss_supporter_since">حامی از %s</string>
<string name="upgrade_foss_supporter_thanks">از حمایت شما از توسعه CAPod سپاسگزاریم!</string>
<string name="upgrade_foss_sponsor_again_action">باز کردن صفحه حمایت مالی</string>
<string name="upgrade_foss_sponsor_label">حمایت از CAPod</string>
<string name="settings_upgrade_status_description">وضعیت حمایتی شما.</string>
<string name="upgrade_screen_why_title">مزایای ارتقا</string>
<string name="upgrade_screen_how_title">چگونه کمک کنیم</string>
<string name="upgrade_screen_how_body">به حامی تبدیل شوید و توسعه را حمایت کنید! برای فعال کردن همه ویژگی‌های اضافی و باز کردن نمایه GitHub Sponsors من، روی دکمه زیر ضربه بزنید.</string>
<string name="upgrade_screen_status_free_title">نسخه رایگان</string>
<string name="upgrade_screen_status_free_body">شما از نسخه رایگان CAPod استفاده می‌کنید. ویژگی‌های اضافی با حمایت از توسعه فعال می‌شوند.</string>
<string name="upgrade_screen_status_free_action">مشاهده گزینه‌های ارتقا</string>
<string name="upgrade_screen_status_upgraded_title">ارتقا فعال</string>
<string name="upgrade_screen_recurring_title">ادامه دهید</string>
<string name="upgrade_screen_recurring_body">CAPod از طریق بروزرسانی‌ها و رفع‌های خطا به تکامل خود ادامه می‌دهد. اگر می‌خواهید این تکامل ادامه یابد، کمک مالی مکرر از طریق GitHub Sponsors را در نظر بگیرید.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Tukija %s lähtien</string>
<string name="upgrade_foss_supporter_thanks">Kiitos, että tuet CAPodin kehitystä!</string>
<string name="upgrade_foss_sponsor_again_action">Avaa sponsorisivu</string>
<string name="upgrade_foss_sponsor_label">Tue CAPodia</string>
<string name="settings_upgrade_status_description">Kannattajantila</string>
<string name="upgrade_screen_why_title">Päivityksen edut</string>
<string name="upgrade_screen_how_title">Kuinka auttaa</string>
<string name="upgrade_screen_how_body">Ryhdy tukijaksi ja sponsoroi kehitystä! Napauta alla olevaa painiketta aktivoidaksesi kaikki lisäominaisuudet ja avataksesi GitHub Sponsors -profiilini.</string>
<string name="upgrade_screen_status_free_title">Ilmainen versio</string>
<string name="upgrade_screen_status_free_body">Käytät CAPodin ilmaista versiota. Lisäominaisuudet voidaan avata tukemalla kehitystä.</string>
<string name="upgrade_screen_status_free_action">Katso päivitysvaihtoehdot</string>
<string name="upgrade_screen_status_upgraded_title">Päivitys aktiivinen</string>
<string name="upgrade_screen_recurring_title">Pidä se käynnissä</string>
<string name="upgrade_screen_recurring_body">CAPod kehittyy jatkuvasti päivitysten ja korjausten kautta. Jos haluat tukea sitä, harkitse toistuvaa lahjoitusta GitHub Sponsorien kautta.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Tagasuporta simula %s</string>
<string name="upgrade_foss_supporter_thanks">Salamat sa pagsuporta sa development ng CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Buksan ang sponsor page</string>
<string name="upgrade_foss_sponsor_label">Suportahan ang CAPod</string>
<string name="settings_upgrade_status_description">Ang iyong status bilang supporter.</string>
<string name="upgrade_screen_why_title">Mga benepisyo ng upgrade</string>
<string name="upgrade_screen_how_title">Paano tumulong</string>
<string name="upgrade_screen_how_body">Maging patron at mag-sponsor ng development! I-tap ang button sa ibaba para ma-activate ang lahat ng extra features at buksan ang aking GitHub Sponsors profile.</string>
<string name="upgrade_screen_status_free_title">Libreng bersyon</string>
<string name="upgrade_screen_status_free_body">Gumagamit ka ng libreng bersyon ng CAPod. Ang mga extra features ay maaaring ma-unlock sa pamamagitan ng pag-suporta sa development.</string>
<string name="upgrade_screen_status_free_action">Tingnan ang mga opsyon sa upgrade</string>
<string name="upgrade_screen_status_upgraded_title">Aktibong upgrade</string>
<string name="upgrade_screen_recurring_title">Panatilihin ang pagpapatuloy</string>
<string name="upgrade_screen_recurring_body">Ang CAPod ay patuloy na umuunlad sa pamamagitan ng updates at fixes. Kung gusto mong suportahan iyan, isaalang-alang ang recurring donation sa pamamagitan ng GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<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 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_foss_supporter_since">Soutien depuis %s</string>
<string name="upgrade_foss_supporter_thanks">Merci de soutenir le développement de CAPod !</string>
<string name="upgrade_foss_sponsor_again_action">Ouvrir la page de parrainage</string>
<string name="upgrade_foss_sponsor_label">Soutenir CAPod</string>
<string name="settings_upgrade_status_description">Votre statut de supporter.</string>
<string name="upgrade_screen_why_title">Avantages de la mise à niveau</string>
<string name="upgrade_screen_how_title">Comment aider</string>
<string name="upgrade_screen_how_body">Devenez mécène et soutenez le développement. Touchez le bouton ci-dessous pour activer toutes les fonctions supplémentaires et ouvrir mon profil GitHub Sponsors.</string>
<string name="upgrade_screen_status_free_title">Version gratuite</string>
<string name="upgrade_screen_status_free_body">Vous utilisez la version gratuite de CAPod. Des fonctionnalités supplémentaires peuvent être déverrouillées en soutenant le développement.</string>
<string name="upgrade_screen_status_free_action">Voir les options de mise à niveau</string>
<string name="upgrade_screen_status_upgraded_title">Mise à niveau active</string>
<string name="upgrade_screen_recurring_title">Gardez ça en route</string>
<string name="upgrade_screen_recurring_body">CAPod continue à évoluer grâce aux mises à jour et aux corrections. Si vous souhaitez soutenir cela, envisagez un don récurrent via GitHub Sponsors.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">Grazas por apoiar o desenvolvemento de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir a páxina de patrocinio</string>
<string name="upgrade_foss_sponsor_label">Apoia CAPod</string>
<string name="settings_upgrade_status_description">O teu estado de apoiador.</string>
<string name="upgrade_screen_why_title">Vantaxes da actualización</string>
<string name="upgrade_screen_how_title">Como axudar</string>
<string name="upgrade_screen_how_body">Convértete en mecenas e patrocina o desenvolvemento! Toca o botón de abaixo para activar todas as características extra e abrir o meu perfil de GitHub Sponsors.</string>
<string name="upgrade_screen_status_free_title">Versión gratuita</string>
<string name="upgrade_screen_status_free_body">Estás usando a versión gratuita de CAPod. As características adicionais pódense desbloquear apoiando o desenvolvemento.</string>
<string name="upgrade_screen_status_free_action">Ver opcións de actualización</string>
<string name="upgrade_screen_status_upgraded_title">Actualización activa</string>
<string name="upgrade_screen_recurring_title">Manténo en marcha</string>
<string name="upgrade_screen_recurring_body">CAPod segue evolucionando a través de actualizacións e correccións. Se queres mantelo así, considera unha doación recorrente a través de GitHub Sponsors.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">दान करें</string>
<string name="foss_upgrade_alreadydonated_label">मैंने पहले ही दान कर दिया है</string>
<string name="foss_upgrade_no_money_label">मैं अपना सारा पैसा AirPods पर खर्च करता हूं</string>
<string name="upgrade_foss_preamble">CAPod FOSS मुफ्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो प्रोजेक्ट को जारी रखने में मदद के लिए विकास को प्रायोजित करने पर विचार करें।</string>
<string name="upgrade_foss_sponsor_action">विकास को प्रायोजक करें</string>
<string name="upgrade_foss_sponsor_subtitle">कोई विज्ञापन नहीं। कोई ट्रैकिंग नहीं। Google Play पर निर्भरता नहीं।</string>
<string name="upgrade_foss_sponsor_returned_early">अभी वापस? आपका समर्थन CAPod को जीवित रखता है।</string>
<string name="upgrade_foss_supporter_since">%s से समर्थक</string>
<string name="upgrade_foss_supporter_thanks">CAPod के विकास का समर्थन करने के लिए धन्यवाद!</string>
<string name="upgrade_foss_sponsor_again_action">स्पॉन्सर पेज खोलें</string>
<string name="upgrade_foss_sponsor_label">CAPod को प्रायोजित करें</string>
<string name="settings_upgrade_status_description">आपकी समर्थक स्थिति।</string>
<string name="upgrade_screen_why_title">अपग्रेड के लाभ</string>
<string name="upgrade_screen_how_title">कैसे सहायता करें</string>
<string name="upgrade_screen_how_body">संरक्षक बनें और विकास को प्रायोजित करें! सभी अतिरिक्त सुविधाओं को सक्रिय करने और मेरी GitHub Sponsors प्रोफ़ाइल खोलने के लिए नीचे दिए गए बटन को टैप करें।</string>
<string name="upgrade_screen_status_free_title">मुफ़्त संस्करण</string>
<string name="upgrade_screen_status_free_body">आप CAPod के मुफ़्त संस्करण का उपयोग कर रहे हैं। विकास का समर्थन करके अतिरिक्त सुविधाओं को अनलॉक किया जा सकता है।</string>
<string name="upgrade_screen_status_free_action">अपग्रेड विकल्प देखें</string>
<string name="upgrade_screen_status_upgraded_title">अपग्रेड सक्रिय</string>
<string name="upgrade_screen_recurring_title">जारी रखें</string>
<string name="upgrade_screen_recurring_body">CAPod अपडेट और सुधार के माध्यम से विकसित होता रहता है। यदि आप इसे जारी रखना चाहते हैं, तो GitHub Sponsors के माध्यम से आवर्ती दान पर विचार करें।</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Podržavatelj od %s</string>
<string name="upgrade_foss_supporter_thanks">Hvala vam što podržavate razvoj CAPoda!</string>
<string name="upgrade_foss_sponsor_again_action">Otvori stranicu za sponzoriranje</string>
<string name="upgrade_foss_sponsor_label">Sponzorirajte CAPod</string>
<string name="settings_upgrade_status_description">Vaš status sponzora</string>
<string name="upgrade_screen_why_title">Prednosti nadogradnje</string>
<string name="upgrade_screen_how_title">Kako pomoći</string>
<string name="upgrade_screen_how_body">Postanite pokrovitelj i sponzorirajte razvoj! Dodirnite gumb ispod da biste aktivirali sve dodatne značajke i otvorili moj GitHub sponzorski profil.</string>
<string name="upgrade_screen_status_free_title">Besplatna verzija</string>
<string name="upgrade_screen_status_free_body">Koristite besplatnu verziju aplikacije CAPod. Dodatne mogućnosti mogu se otključati podržavanjem razvoja.</string>
<string name="upgrade_screen_status_free_action">Pogledajte mogućnosti nadogradnje</string>
<string name="upgrade_screen_status_upgraded_title">Nadogradnja je aktivna</string>
<string name="upgrade_screen_recurring_title">Nastavi dalje</string>
<string name="upgrade_screen_recurring_body">CAPod se nastavlja razvijati kroz ažuriranja i ispravke. Ako želite održati to, razmislite o ponavljajućoj donaciji putem GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Támogató %s óta</string>
<string name="upgrade_foss_supporter_thanks">Köszönjük, hogy támogatod a CAPod fejlesztését!</string>
<string name="upgrade_foss_sponsor_again_action">Támogatói oldal megnyitása</string>
<string name="upgrade_foss_sponsor_label">Támogasson CAPod-ot</string>
<string name="settings_upgrade_status_description">Az Ön támogató státusza.</string>
<string name="upgrade_screen_why_title">Frissítési előnyök</string>
<string name="upgrade_screen_how_title">Hogyan lehet segíteni</string>
<string name="upgrade_screen_how_body">Legyen patron támogató és szponzorálja a fejlesztést! Kattintson az alábbi gombra az összes extra funkció aktiválásához és a GitHub Sponsors profilom megnyitásához.</string>
<string name="upgrade_screen_status_free_title">Ingyenes verzió</string>
<string name="upgrade_screen_status_free_body">A CAPod ingyenes verzióját használod. A további funkciókat a fejlesztés támogatásával feloldhatod.</string>
<string name="upgrade_screen_status_free_action">Frissítési lehetőségek</string>
<string name="upgrade_screen_status_upgraded_title">Frissítés aktív</string>
<string name="upgrade_screen_recurring_title">Folytasd a támogatást</string>
<string name="upgrade_screen_recurring_body">A CAPod folyamatosan fejlődik frissítéseken és hibajavításokon keresztül. Ha szeretnéd ezt fenntartani, fontold meg az ismétlődő adományt a GitHub Sponsors-on.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Նվիրաբերել</string>
<string name="foss_upgrade_alreadydonated_label">Ես արդեն նվիրաբերել եմ</string>
<string name="foss_upgrade_no_money_label">Ես իմ ամբողջ գումարը ծախսում եմ AirPods-ի վրա</string>
<string name="upgrade_foss_preamble">CAPod FOSS անվճար և բաց կոդով: Այն ոգտակար էք, խնդրում զարգացությունը կենդանի պահելու համար:</string>
<string name="upgrade_foss_sponsor_action">Հովանավորել զարգացումը</string>
<string name="upgrade_foss_sponsor_subtitle">Լրատնություն չկա: հետքխկություն չկա: Google Play-ի կապվածություն չկա:</string>
<string name="upgrade_foss_sponsor_returned_early">Արդևն հետ? ձևր աիկտակցությունը CAPod-ին կենդանի պահում:</string>
<string name="upgrade_foss_supporter_since">Հովանավոր է %s-ից</string>
<string name="upgrade_foss_supporter_thanks">Շնորհակալություն CAPod-ի զարգացումն աջակցելու համար!</string>
<string name="upgrade_foss_sponsor_again_action">Բացել հովանավորության էջը</string>
<string name="upgrade_foss_sponsor_label">Աջակեք CAPod-ին</string>
<string name="settings_upgrade_status_description">Ձեր աջակցողի վիճակը:</string>
<string name="upgrade_screen_why_title">Թարմացման առավելությունները</string>
<string name="upgrade_screen_how_title">Ինչպես օգնել</string>
<string name="upgrade_screen_how_body">Դարձեք հովանավոր և հովանավորեք զարգացումը: Սեղմեք ստորև գտնվող կոճակը՝ բոլոր լրացուցիչ գործառույթները ակտիվացնելու և իմ GitHub Sponsors պրոֆիլը բացելու համար:</string>
<string name="upgrade_screen_status_free_title">Անվճար տարբերակ</string>
<string name="upgrade_screen_status_free_body">Դուք օգտագործում եք CAPod-ի անվճար տարբերակը: Լրացուցիչ գործառույթները կարող են բացվել, աջակցելով զարգացմանը:</string>
<string name="upgrade_screen_status_free_action">Տեսնել թարմացման տարբերակները</string>
<string name="upgrade_screen_status_upgraded_title">Թարմացումն ակտիվ է</string>
<string name="upgrade_screen_recurring_title">Շարունակեք</string>
<string name="upgrade_screen_recurring_body">CAPod-ը շարունակում է զարգանալ թարմացումների և ուղղումների միջոցով: Եթե ցանկանում եք աջակցել դրան, դիտարկեք պարբերական նվիրատվություն GitHub Sponsors-ի միջոցով:</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Pendukung sejak %s</string>
<string name="upgrade_foss_supporter_thanks">Terima kasih karena telah mendukung pengembangan CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Buka halaman sponsor</string>
<string name="upgrade_foss_sponsor_label">Sponsori CAPod</string>
<string name="settings_upgrade_status_description">Status pendukung Anda.</string>
<string name="upgrade_screen_why_title">Manfaat upgrade</string>
<string name="upgrade_screen_how_title">Cara membantu</string>
<string name="upgrade_screen_how_body">Jadilah pelindung dan sponsor pengembangan! Ketuk tombol di bawah untuk mengaktifkan semua fitur tambahan dan membuka profil GitHub Sponsors saya.</string>
<string name="upgrade_screen_status_free_title">Versi gratis</string>
<string name="upgrade_screen_status_free_body">Anda menggunakan versi gratis CAPod. Fitur tambahan dapat dibuka dengan mendukung pengembangan.</string>
<string name="upgrade_screen_status_free_action">Lihat opsi upgrade</string>
<string name="upgrade_screen_status_upgraded_title">Upgrade aktif</string>
<string name="upgrade_screen_recurring_title">Terus berkembang</string>
<string name="upgrade_screen_recurring_body">CAPod terus berkembang melalui pembaruan dan perbaikan. Jika Anda ingin mendukung itu, pertimbangkan donasi berulang melalui GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Styrktaraðili síðan %s</string>
<string name="upgrade_foss_supporter_thanks">Takk fyrir að styðja þróun CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Opna styrktarsíðu</string>
<string name="upgrade_foss_sponsor_label">Styrktu CAPod</string>
<string name="settings_upgrade_status_description">Staða þín sem stuðningsmaður</string>
<string name="upgrade_screen_why_title">Uppfærslukostir</string>
<string name="upgrade_screen_how_title">Hvernig á að hjálpa</string>
<string name="upgrade_screen_how_body">Vertu styrktaraðili og styrktu þróun! Ýttu á hnappinn hér að neðan til að virkja alla viðbótareiginleika og opna GitHub Sponsors prófílinn minn.</string>
<string name="upgrade_screen_status_free_title">Ókeypis útgáfa</string>
<string name="upgrade_screen_status_free_body">Þú ert að nota ókeypis útgáfu CAPod. Hægt er að aflása aukaeiginleikum með því að styðja þróun.</string>
<string name="upgrade_screen_status_free_action">Skoðaðu uppfærsluvalkosti</string>
<string name="upgrade_screen_status_upgraded_title">Uppfærsla virk</string>
<string name="upgrade_screen_recurring_title">Haltu því gangandi</string>
<string name="upgrade_screen_recurring_body">CAPod heldur áfram að þróast með uppfærslum og lagfæringum. Ef þú vilt standa við það, skaltu íhuga endurtekna framlög í gegnum GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Sostenitore dal %s</string>
<string name="upgrade_foss_supporter_thanks">Grazie per supportare lo sviluppo di CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Apri la pagina sponsor</string>
<string name="upgrade_foss_sponsor_label">Sponsorizza CAPod</string>
<string name="settings_upgrade_status_description">Il tuo status di sostenitore.</string>
<string name="upgrade_screen_why_title">Vantaggi dell\'aggiornamento</string>
<string name="upgrade_screen_how_title">Come aiutare</string>
<string name="upgrade_screen_how_body">Diventa un sostenitore e sponsorizza lo sviluppo! Clicca il pulsante qui sotto per attivare tutte le funzioni extra e apri il mio profilo Sponsor Github.</string>
<string name="upgrade_screen_status_free_title">Versione gratuita</string>
<string name="upgrade_screen_status_free_body">Stai usando la versione gratuita di CAPod. Funzionalità aggiuntive possono essere sbloccate supportando lo sviluppo.</string>
<string name="upgrade_screen_status_free_action">Visualizza opzioni di aggiornamento</string>
<string name="upgrade_screen_status_upgraded_title">Aggiornamento attivo</string>
<string name="upgrade_screen_recurring_title">Continua così</string>
<string name="upgrade_screen_recurring_body">CAPod continua a evolversi con aggiornamenti e correzioni. Se desideri sostenerlo, considera una donazione ricorrente via GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">תרומה</string>
<string name="foss_upgrade_alreadydonated_label">כבר תרמתי</string>
<string name="foss_upgrade_no_money_label">מיטב כספי מושקע ב-AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS הוא חינם וקוד פתוח. אם הוא שימושי לך, שקול לשקול על חסוי הפיתוח כדי לעזור להמשיך את הפרויקט.</string>
<string name="upgrade_foss_sponsor_action">פיתוח חסות</string>
<string name="upgrade_foss_sponsor_subtitle">אין פרסומות. אין מעקב. אין תלות ב-Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">כבר חוזר? התמיכה שלך שומרת את CAPod בחיים.</string>
<string name="upgrade_foss_supporter_since">תומך מאז %s</string>
<string name="upgrade_foss_supporter_thanks">תודה שאתה תומך בפיתוח של CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">פתח את דף התמיכה</string>
<string name="upgrade_foss_sponsor_label">תמוך ב-CAPod</string>
<string name="settings_upgrade_status_description">סטטוס התומך שלך.</string>
<string name="upgrade_screen_why_title">יתרונות השדרוג</string>
<string name="upgrade_screen_how_title">איך לעזור</string>
<string name="upgrade_screen_how_body">הפוך לפטרון ונותן חסות לפיתוח! הקש על הכפתור למטה כדי להפעיל את כל התכונות הנוספות ולפתוח את פרופיל הספונסרים בגיטהאב שלי.</string>
<string name="upgrade_screen_status_free_title">גרסה חינם</string>
<string name="upgrade_screen_status_free_body">אתה משתמש בגרסה החינם של CAPod. ניתן לפתוח תכונות נוספות על ידי תמיכה בפיתוח.</string>
<string name="upgrade_screen_status_free_action">צפה באפשרויות השדרוג</string>
<string name="upgrade_screen_status_upgraded_title">השדרוג פעיל</string>
<string name="upgrade_screen_recurring_title">תמשך בכך</string>
<string name="upgrade_screen_recurring_body">CAPod ממשיך להתפתח דרך עדכונים ותיקונים. אם אתה רוצה לתמוך בכך, שקול תרומה חוזרת דרך GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">寄付</string>
<string name="foss_upgrade_alreadydonated_label">すでに寄付しました</string>
<string name="foss_upgrade_no_money_label">AirPodsに全財産をつぎ込んでいます</string>
<string name="upgrade_foss_preamble">CAPod FOSSは無料かつオープンソースです。便利だと思ったら、プロジェクトの継続に向けて開発をスポンサーすることをご検討ください。</string>
<string name="upgrade_foss_sponsor_action">開発支援者</string>
<string name="upgrade_foss_sponsor_subtitle">広告なし。追跡なし。Google Play依存なし。</string>
<string name="upgrade_foss_sponsor_returned_early">もう戻りますか?あなたのサポートがCAPodを支えています。</string>
<string name="upgrade_foss_supporter_since">%sからのサポーター</string>
<string name="upgrade_foss_supporter_thanks">CAPodの開発を支援していただきありがとうございます!</string>
<string name="upgrade_foss_sponsor_again_action">スポンサーページを開く</string>
<string name="upgrade_foss_sponsor_label">CAPod をスポンサーする</string>
<string name="settings_upgrade_status_description">サポーターステータス。</string>
<string name="upgrade_screen_why_title">アップグレードの特典</string>
<string name="upgrade_screen_how_title">協力するには</string>
<string name="upgrade_screen_how_body">パトロンとスポンサーの開発になりましょう!下のボタンをタップすると、すべての追加機能が有効になり、GitHub Sponsors プロファイルを開きます。</string>
<string name="upgrade_screen_status_free_title">無料版</string>
<string name="upgrade_screen_status_free_body">CAPod の無料版を使用しています。開発を支援することで、追加機能をアンロックできます。</string>
<string name="upgrade_screen_status_free_action">アップグレード オプションを確認</string>
<string name="upgrade_screen_status_upgraded_title">アップグレード有効</string>
<string name="upgrade_screen_recurring_title">サポート継続</string>
<string name="upgrade_screen_recurring_body">CAPod は更新と修正を通じて進化し続けています。サポートを続けたい場合は、GitHub Sponsors を通じて定期的な寄付を検討してください。</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">შემოწირულობა</string>
<string name="foss_upgrade_alreadydonated_label">მე უკვე შემოწირული მაქვს</string>
<string name="foss_upgrade_no_money_label">მთელ ჩემს ფულს AirPods-ებზე ვხარჯავ</string>
<string name="upgrade_foss_preamble">CAPod FOSS უფასოა და ღია წყაროა. თუ გსარგებლია, გაანაბრეთ განვითარება პროექტის გადასაგრელად.</string>
<string name="upgrade_foss_sponsor_action">სპონსორის განვითარება</string>
<string name="upgrade_foss_sponsor_subtitle">რეკლამა არა. თვალთვალება არა. Google Play-ზე დამოკიდება არა.</string>
<string name="upgrade_foss_sponsor_returned_early">უკვე დაბრუნდით? თქვენი მხარდაჯერა CAPod-ს გადარჩენებას ეხმარება.</string>
<string name="upgrade_foss_supporter_since">მხარდამჭერი %s-დან</string>
<string name="upgrade_foss_supporter_thanks">მადლობა CAPod-ის განვითარების მხარდაჭერისთვის!</string>
<string name="upgrade_foss_sponsor_again_action">სპონსორის გვერდის გახსნა</string>
<string name="upgrade_foss_sponsor_label">მხარი დაუჭიროთ CAPod-ს</string>
<string name="settings_upgrade_status_description">თქვენი მხარდამჭერის მდგომარეობა.</string>
<string name="upgrade_screen_why_title">განახლების უპირატესობები</string>
<string name="upgrade_screen_how_title">როგორ დაგეხმაროთ</string>
<string name="upgrade_screen_how_body">გახდით მფარველი და სპონსორობა გაუწიეთ განვითარებას! შეეხეთ ქვემოთ მოცემულ ღილაკს, რათა ააქტიუროთ ყველა დამატებითი ფუნქცია და გახსნათ ჩემი GitHub Sponsors პროფილი.</string>
<string name="upgrade_screen_status_free_title">უფასო ვერსია</string>
<string name="upgrade_screen_status_free_body">თქვენ იყენებთ CAPod-ის უფასო ვერსიას. დამატებითი ფუნქციები შეიძლება აშვებული იყოს განვითარების მხარდასაჭერით.</string>
<string name="upgrade_screen_status_free_action">განახლების ვარიანტების ნახვა</string>
<string name="upgrade_screen_status_upgraded_title">განახლება აქტიური</string>
<string name="upgrade_screen_recurring_title">ამის გაგრძელება</string>
<string name="upgrade_screen_recurring_body">CAPod განაგრძობს განვითარებას განახლებებისა და შეასწორებების მეშვეობით. თუ გსურთ ამის შენარჩუნება, განიხილეთ რეგულარული დონაცია GitHub Sponsors-ის მეშვეობით.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">បរិច្ចាគ</string>
<string name="foss_upgrade_alreadydonated_label">ខ្ញុំបានបរិច្ចាគរួចហើយ</string>
<string name="foss_upgrade_no_money_label">ខ្ញុំចំណាយលុយទាំងអស់របស់ខ្ញុំលើ AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ជាកម្មវិធីឥតគិតថ្លៃ និងប្រភពបើកចំហ។ ប្រសិនបើអ្នករកហើញថ្លៃវាមានប្រយោជន៍ សូមពិចារណាឧបត្ថម្ភការអភិវឌ្ឍន៍ ដើម្បីជួយឥ្សរិយគម្រោងបន្តដំណើរការ។</string>
<string name="upgrade_foss_sponsor_action">ឧបត្ថម្ភការអភិវឌ្ឍន៍</string>
<string name="upgrade_foss_sponsor_subtitle">គ្មានការផ្សាយពាណិជ្ជកម្ម។ គ្មានការតាមដាន។ គ្មានការចាក់សោ Google Play។</string>
<string name="upgrade_foss_sponsor_returned_early">ត្រលប់មកវិញហើយ។? ការគាំទ្ររបស់អ្នកចិញ្ចឹម CAPod ឥ្សរិយនៅរស់។</string>
<string name="upgrade_foss_supporter_since">អ្នកគាំទ្រតាំងពី %s</string>
<string name="upgrade_foss_supporter_thanks">សូមអរគុណសម្រាប់ការគាំទ្រការអភិវឌ្ឍន៍ CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">បើកទំព័រឧបត្ថម្ភ</string>
<string name="upgrade_foss_sponsor_label">ឧបត្ថម្ភ CAPod</string>
<string name="settings_upgrade_status_description">ស្ថានភាពអ្នកគាំទ្ររបស់អ្នក។</string>
<string name="upgrade_screen_why_title">អត្ថប្រយោជន៍លើកកម្ពស់</string>
<string name="upgrade_screen_how_title">របៀបជួយ</string>
<string name="upgrade_screen_how_body">ក្លាយជាអ្នកឧបត្ថម្ភ និងឧបត្ថម្ភការអភិវឌ្ឍន៍! ចុចប៊ូតុងខាងក្រោមដើម្បីធ្វើឱ្យមុខងារបន្ថែមទាំងអស់សកម្ម និងបើកប្រវត្តិរូប GitHub Sponsors របស់ខ្ញុំ។</string>
<string name="upgrade_screen_status_free_title">កំណែឥតគិតថ្លៃ</string>
<string name="upgrade_screen_status_free_body">អ្នកកំពុងប្រើប្រាស់កំណែឥតគិតថ្លៃរបស់ CAPod។ មុខងារលម្អិតបន្ថែមលេចឡើងដោយការគាំទ័របង្កើនលេខ។</string>
<string name="upgrade_screen_status_free_action">មើលជម្រើសលើកកម្ពស់</string>
<string name="upgrade_screen_status_upgraded_title">លើកកម្ពស់សកម្ម</string>
<string name="upgrade_screen_recurring_title">បន្តដង្ហើម</string>
<string name="upgrade_screen_recurring_body">CAPod បន្តលូតលាស់តាមរយៈការធ្វើឱ្យថ្មីនិងការកែប្រែ។ ប្រសិនបើអ្នកចង់រក្សាវាឱ្យរស់រាន, ពិចារណាការរួមចំណែកម្តងម្កាលរបស់អ្នកតាមរយៈ GitHub Sponsors។</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Piştgir ji %s ve</string>
<string name="upgrade_foss_supporter_thanks">Spas ji bo piştgiriya te ya pêşxistina CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Rûpela piştgiriyê veke</string>
<string name="upgrade_foss_sponsor_label">CAPod-ê piştgirî bike</string>
<string name="settings_upgrade_status_description">Rewşa piştgiriya te.</string>
<string name="upgrade_screen_why_title">Feydeyên Bardiyê</string>
<string name="upgrade_screen_how_title">Çawa Alîkarî Bike</string>
<string name="upgrade_screen_how_body">Bibin patronê û pîşesaziyê xwehûnand bikin! Bişka jêrîn dakirin da ku hemî taybetiyên zêde çalak bikin û profîla GitHub Sponsors-a min vekirin.</string>
<string name="upgrade_screen_status_free_title">Guhertoya Belaş</string>
<string name="upgrade_screen_status_free_body">Hûn guhertoya belaş ya CAPod bikar tîne. Taybetiyên zêde dikarin bê pîşesaziyê xwehûnandê tê de vebirîn.</string>
<string name="upgrade_screen_status_free_action">Vebijarkên Bardiya Bibînin</string>
<string name="upgrade_screen_status_upgraded_title">Bardiya Çalak e</string>
<string name="upgrade_screen_recurring_title">Berdewam Bike</string>
<string name="upgrade_screen_recurring_body">CAPod di navberê nûkariyan û raskariyan de cardin pêşve diçe. Ger hûn dixwazin vê berdewam bikin, hemî kirînên danûstî di riya GitHub Sponsors-a de bifikirin.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">ದಾನ ಮಾಡಿ</string>
<string name="foss_upgrade_alreadydonated_label">ನಾನು ಈಗಾಗಲೇ ದಾನ ಮಾಡಿದ್ದೇನೆ</string>
<string name="foss_upgrade_no_money_label">ನಾನು ನನ್ನ ಎಲ್ಲಾ ಹಣವನ್ನು AirPods ಗಾಗಿ ಖರ್ಚು ಮಾಡುತ್ತೇನೆ</string>
<string name="upgrade_foss_preamble">CAPod FOSS ಉಚಿತ ಮತ್ತು ತೆರೆದ ಮೂಲದ್ದಾಗಿದೆ. ನಿಮಗೆ ಉಪಯುಕ್ತವಾಗಿ ತೋರಿದರೆ, ಯೋಜನೆಯನ್ನು ಮುಂದುವರಿಸಲು ಅಭಿವೃದ್ಧಿಗೆ ಪ್ರಾಯೋಜಕತ್ವ ನೀಡಲು ಪರಿಗಣಿಸಿ.</string>
<string name="upgrade_foss_sponsor_action">ಅಭಿವೃದ್ಧಿಗೆ ಪ್ರಾಯೋಜಕತ್ವ ನೀಡಿ</string>
<string name="upgrade_foss_sponsor_subtitle">ಜಾಹೀರಾತುಗಳಿಲ್ಲ. ಭಕ್ಷಣೆ ಇಲ್ಲ. Google Play ಲಾಕ್-ಇನ್ ಇಲ್ಲ.</string>
<string name="upgrade_foss_sponsor_returned_early">ಇಷ್ಟೇ ಹಿಂದಿರುಗಿದಿರಿ? ನಿಮ್ಮ ಬೆಂಬಲ CAPod ಅನ್ನು ಜೀವಂತವಾಗಿಡುತ್ತದೆ.</string>
<string name="upgrade_foss_supporter_since">%s ರಿಂದ ಬೆಂಬಲಿಗ</string>
<string name="upgrade_foss_supporter_thanks">CAPod ಅಭಿವೃದ್ಧಿಗೆ ಬೆಂಬಲ ನೀಡಿದ್ದಕ್ಕಾಗಿ ಧನ್ಯವಾದಗಳು!</string>
<string name="upgrade_foss_sponsor_again_action">ಪ್ರಾಯೋಜಕ ಪುಟ ತೆರೆಯಿರಿ</string>
<string name="upgrade_foss_sponsor_label">CAPod ಬೆಂಬಲಿಸಿ</string>
<string name="settings_upgrade_status_description">ನಿಮ್ಮ ಬೆಂಬಲಕಾರ ಸ್ಥಿತಿ.</string>
<string name="upgrade_screen_why_title">ನವೀಕರಣ ಪ್ರಯೋಜನಗಳು</string>
<string name="upgrade_screen_how_title">ಸಹಾಯ ಮಾಡುವುದು ಹೇಗೆ</string>
<string name="upgrade_screen_how_body">ಆಶ್ರಯದಾತ ಮತ್ತು ಅಭಿವೃದ್ಧಿ ಸ್ಪನ್ಸರ್ ಆಗಿ ಮಾರ್ಪಡಿ! ಎಲ್ಲಾ ಹೆಚ್ಚುವರಿ ವೈಶಿಷ್ಟ್ಯಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಲು ಮತ್ತು ನನ್ನ ಗಿಟ್‌ಹಬ್ ಸ್ಪನ್ಸರ್‌ಗಳ ಪ್ರೊಫೈಲ್ ತೆರೆಯಲು ಕೆಳಗಿನ ಬಟನ್ ಟ್ಯಾಪ್ ಮಾಡಿ.</string>
<string name="upgrade_screen_status_free_title">ಉಚಿತ ಆವೃತ್ತಿ</string>
<string name="upgrade_screen_status_free_body">ನೀವು ಸಿಎಪಿಪಾಡ್‌ನ ಉಚಿತ ಆವೃತ್ತಿಯನ್ನು ಬಳಸುತ್ತಿದ್ದೀರಿ. ಅಭಿವೃದ್ಧಿಯನ್ನು ಬೆಂಬಲಿಸುವ ಮೂಲಕ ಹೆಚ್ಚುವರಿ ವೈಶಿಷ್ಟ್ಯಗಳನ್ನು ಅನ್‌ಲಾಕ್ ಮಾಡಬಹುದು.</string>
<string name="upgrade_screen_status_free_action">ನವೀಕರಣ ಆಯ್ಕೆಗಳನ್ನು ನೋಡಿ</string>
<string name="upgrade_screen_status_upgraded_title">ನವೀಕರಣ ಕ್ರಿಯಾಶೀಲ</string>
<string name="upgrade_screen_recurring_title">ಅದನ್ನು ಮುಂದುವರಿಸಿ</string>
<string name="upgrade_screen_recurring_body">CAPod ನವೀಕರಣ ಮತ್ತು ಸುಧಾರೆಗಳ ಮೂಲಕ ನಿರಂತರವಾಗಿ ಅಭಿವೃದ್ಧಿ ಆಗುತ್ತಿದೆ. ನೀವು ಇದನ್ನು ಮುಂದುವರಿಸಲು ಬಯಸಿದರೆ, GitHub Sponsors ಮೂಲಕ ನಿಯಮಿತ ದಾನವನ್ನು ಪರಿಗಣಿಸಿ.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">후원하기</string>
<string name="foss_upgrade_alreadydonated_label">이미 후원했어요</string>
<string name="foss_upgrade_no_money_label">AirPods에 돈을 다 썼어요</string>
<string name="upgrade_foss_preamble">CAPod FOSS는 무료 오픈소스입니다. 유용하게 사용하고 계신다면 프로젝트 유지에 도움이 되도록 후원을 고려해 주세요.</string>
<string name="upgrade_foss_sponsor_action">개발 후원하기</string>
<string name="upgrade_foss_sponsor_subtitle">광고 없음. 추적 없음. Google Play 의존 없음.</string>
<string name="upgrade_foss_sponsor_returned_early">벌써 돌아가세요? 여러분의 지원이 CAPod를 유지합니다.</string>
<string name="upgrade_foss_supporter_since">%s부터 후원자</string>
<string name="upgrade_foss_supporter_thanks">CAPod 개발을 후원해 주셔서 감사합니다!</string>
<string name="upgrade_foss_sponsor_again_action">후원 페이지 열기</string>
<string name="upgrade_foss_sponsor_label">CAPod 후원하기</string>
<string name="settings_upgrade_status_description">후원자 상태</string>
<string name="upgrade_screen_why_title">업그레이드 혜택</string>
<string name="upgrade_screen_how_title">도움 주기</string>
<string name="upgrade_screen_how_body">후원자가 되어 개발을 지원하세요! 아래 버튼을 눌러 Github Sponsors 프로필을 열고 추가 기능을 잠금해제하세요.</string>
<string name="upgrade_screen_status_free_title">무료 버전</string>
<string name="upgrade_screen_status_free_body">CAPod의 무료 버전을 사용 중입니다. 개발을 지원하여 추가 기능을 잠금 해제할 수 있습니다.</string>
<string name="upgrade_screen_status_free_action">업그레이드 옵션 보기</string>
<string name="upgrade_screen_status_upgraded_title">업그레이드 활성화됨</string>
<string name="upgrade_screen_recurring_title">계속 지원하기</string>
<string name="upgrade_screen_recurring_body">CAPod는 업데이트와 수정을 통해 계속 발전합니다. 이를 지속하고 싶다면 GitHub Sponsors를 통한 정기 기부를 고려해 주세요.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Демөөрчүлүк кылуу</string>
<string name="foss_upgrade_alreadydonated_label">Мен буга чейин демөөрчүлүк кылгам</string>
<string name="foss_upgrade_no_money_label">Мен бүт акчамды AirPods үчүн короттум</string>
<string name="upgrade_foss_preamble">CAPod FOSS беплатан жана ачык каттулууда. Егер пайдалуу таапсаңыз, жобаны улантырууга колдоо көрсөтүңүз.</string>
<string name="upgrade_foss_sponsor_action">Иштелеп чыгууну колдоо</string>
<string name="upgrade_foss_sponsor_subtitle">Жарнама. Кадалоолоо жок. Google Playга байланма.</string>
<string name="upgrade_foss_sponsor_returned_early">Артка кайттыңызбы? Сиздин колдооңуз CAPodты тирүү сактайт.</string>
<string name="upgrade_foss_supporter_since">%s\'дан бери колдоочу</string>
<string name="upgrade_foss_supporter_thanks">CAPod\'дун өнүгүшүн колдогонуңуз үчүн рахмат!</string>
<string name="upgrade_foss_sponsor_again_action">Демөөрчүлүк баракчасын ачуу</string>
<string name="upgrade_foss_sponsor_label">CAPod-ды спонсорлоо</string>
<string name="settings_upgrade_status_description">Колдоочу абалы</string>
<string name="upgrade_screen_why_title">Жогорулоонун пайдалары</string>
<string name="upgrade_screen_how_title">Жардам бергүүнүн жолу</string>
<string name="upgrade_screen_how_body">Демеки болуп, иштөөсүнө спонсорлик кылыңыз! Бардык кошумча өзгөчөлүктөрдүн ишке кирүүсүнө жана GitHub Sponsors профилимди ачуу үчүн төмөндөгү баскычын басыңыз.</string>
<string name="upgrade_screen_status_free_title">Акысыз версия</string>
<string name="upgrade_screen_status_free_body">CAPod программасынын акысыз версиясын колдонуп жатасыз. Иштөөнүн өнүгүүсүнө спонсорлик кылсаңыз кошумча өзгөчөлүктөрдүн ишке кирүүсүн ачуп ала аласыз.</string>
<string name="upgrade_screen_status_free_action">Жогорулоо вариантларын көрүңүз</string>
<string name="upgrade_screen_status_upgraded_title">Жогорулоо активтүү</string>
<string name="upgrade_screen_recurring_title">Улантуу</string>
<string name="upgrade_screen_recurring_body">CAPod жаңыланууларды жана оңдуулар аркылуу өнүгүп турат. Эгерде сиз муну сактоону кааласаңыз, GitHub Sponsors аркылуу спонсорлук туурасында ойлонуңүз.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">ບໍລິຈາກ</string>
<string name="foss_upgrade_alreadydonated_label">ຂ້ອຍໄດ້ບໍລິຈາກແລ້ວ</string>
<string name="foss_upgrade_no_money_label">ຂ້ອຍໃຊ້ເງິນທັງໝົດຂອງຂ້ອຍໃສ່ AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ແມ່ນຟຣີ ແລະ ໂອເພນຊອດ. ຖ້າທ່ານເຫັນວ່າມີປະໂຫຍດ, ພິຈາລະນາສະໜັບສະໜູນການພັດທະນາເພື່ອຊ່ວຍໃຫ້ໂຄງການດຳເນີນຕໍ່ໄປ.</string>
<string name="upgrade_foss_sponsor_action">ສະໜັບສະໜູນການພັດທະນາ</string>
<string name="upgrade_foss_sponsor_subtitle">ບໍ່ມີໂຄສະນາ. ບໍ່ມີການຕິດຕາມ. ບໍ່ຕິດ Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">ກັບມາແລ້ວ? ການສະໜັບສະໜູນຂອງທ່ານຮັກສາ CAPod ໃຫ້ດຳເນີນຕໍ່ໄປ.</string>
<string name="upgrade_foss_supporter_since">ຜູ້ສະໜັບສະໜູນຕັ້ງແຕ່ %s</string>
<string name="upgrade_foss_supporter_thanks">ຂອບຊູ່ສຳລັບການສະໜັບສະໜູນການພັດທະນາຂອງ CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">ເປິດໝ້າສະໜັບສະໜູນ</string>
<string name="upgrade_foss_sponsor_label">ສະໜັບສະໜູນ CAPod</string>
<string name="settings_upgrade_status_description">ສະຖານະຜູ້ສະໜັບສະໜູນຂອງທ່ານ</string>
<string name="upgrade_screen_why_title">ປະໂຫຍດຂອງການອັບເກຣດ</string>
<string name="upgrade_screen_how_title">ວິທີການຊ່ວຍເຫຼືອ</string>
<string name="upgrade_screen_how_body">ກາຍເປັນຜູ້ອຸປະຖໍາ ແລະ ໃຫ້ການສະໜັບສະໜູນການພັດທະນາ! ກົດປຸ່ມຂ້າງລຸ່ມເພື່ອເປີດໃຊ້ງານຄຸນສົມບັດເພີ່ມເຕີມທັງໝົດ ແລະ ເປີດໂປຣໄຟລ໌ GitHub Sponsors ຂອງຂ້ອຍ.</string>
<string name="upgrade_screen_status_free_title">ເວີຊັນຟຣີ</string>
<string name="upgrade_screen_status_free_body">ທ່ານກຳລັງໃຊ້ເວີຊັນຟຣີຂອງ CAPod. ຄຸນສົມບັດພິເສດສາມາດປົດລັອກໄດ້ໂດຍການສະຫນັບສະຫນູນການພັດທະນາ.</string>
<string name="upgrade_screen_status_free_action">ເບິ່ງທາງເລືອກການອັບເກຣດ</string>
<string name="upgrade_screen_status_upgraded_title">ອັບເກຣດເປີດໃຊ້ງານ</string>
<string name="upgrade_screen_recurring_title">ສືບຕໍ່ໃຫ້ໄປ</string>
<string name="upgrade_screen_recurring_body">CAPod ສືບຕໍ່ວິວັດທະນາການຜ່ານການອັບເດດ ແລະການແກ້ໄຂ. ຖ້າທ່ານຕ້ອງການຮັກສາສະຖານະນັ້ນ, ກະລຸນາພິຈາລະນາການບໍລິຈາກປະຈຳຜ່ານ GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Rėmėjas nuo %s</string>
<string name="upgrade_foss_supporter_thanks">Ačiū, kad remiate CAPod kūrimą!</string>
<string name="upgrade_foss_sponsor_again_action">Atidaryti rėmėjo puslapį</string>
<string name="upgrade_foss_sponsor_label">Remti CAPod</string>
<string name="settings_upgrade_status_description">Jūsų rėmėjo statusas.</string>
<string name="upgrade_screen_why_title">Atnaujinimo privalumai</string>
<string name="upgrade_screen_how_title">Kaip padėti</string>
<string name="upgrade_screen_how_body">Tapkite globėju ir rėmėju plėtros! Palieskite žemiau esantį mygtuką, kad suaktyvintumėte visas papildomas funkcijas ir atidarytumėte mano GitHub Sponsors profilį.</string>
<string name="upgrade_screen_status_free_title">Nemokama versija</string>
<string name="upgrade_screen_status_free_body">Jūs naudojate nemokamą CAPod versiją. Papildomos funkcijos gali būti atblokuotos palaikant kūrimą.</string>
<string name="upgrade_screen_status_free_action">Žiūrėti atnaujinimo galimybes</string>
<string name="upgrade_screen_status_upgraded_title">Atnaujinimas aktyvus</string>
<string name="upgrade_screen_recurring_title">Tęskime toliau</string>
<string name="upgrade_screen_recurring_body">CAPod nuolat tobulėja per atnaujinimus ir pataisas. Jei norite tai palaikyti, apsvarstyti pasikartojančią donaciją per GitHub Sponsors.</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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_foss_supporter_since">Atbalstītājs kopš %s</string>
<string name="upgrade_foss_supporter_thanks">Paldies, ka atbalsti CAPod izstrādi!</string>
<string name="upgrade_foss_sponsor_again_action">Atvērt sponsorēšanas lapu</string>
<string name="upgrade_foss_sponsor_label">Atbalstīt CAPod</string>
<string name="settings_upgrade_status_description">Jūsu atbalstītāja statuss.</string>
<string name="upgrade_screen_why_title">Jaunināšanas priekšrocības</string>
<string name="upgrade_screen_how_title">Kā palīdzēt</string>
<string name="upgrade_screen_how_body">Kļūstiet par aizbildni un sponsorējiet attīstību! Pieskarieties pogas zemāk, lai aktivizētu visas papildu funkcijas un atvērtu manu GitHub Sponsors profilu.</string>
<string name="upgrade_screen_status_free_title">Bezmaksas versija</string>
<string name="upgrade_screen_status_free_body">Jūs izmantojat CAPod bezmaksas versiju. Papildu funkcijas var atbloķēt, atbalstot izstrādi.</string>
<string name="upgrade_screen_status_free_action">Skatīt jaunināšanas opcijas</string>
<string name="upgrade_screen_status_upgraded_title">Jaunināšana aktīva</string>
<string name="upgrade_screen_recurring_title">Turpiniet atbalstu</string>
<string name="upgrade_screen_recurring_body">CAPod nepārtraukti attīstās caur atjauninājumiem un labojumiem. Ja vēlaties to atbalstīt, izsveriet regulāru ziedojumu caur GitHub Sponsors.</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Донирај</string>
<string name="foss_upgrade_alreadydonated_label">Веќе донирав</string>
<string name="foss_upgrade_no_money_label">Ги трошам сите пари на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS е бесплатен и отворен код. Ако ви е корисен, размислете да го спонзорирате развојот за да помогнете проектот да продолжи.</string>
<string name="upgrade_foss_sponsor_action">Спонзорирај развој</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклами. Без пратење. Без зависност од Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Веќе се вративте? Вашата поддршка го одржува CAPod жив.</string>
<string name="upgrade_foss_supporter_since">Поддржувач од %s</string>
<string name="upgrade_foss_supporter_thanks">Ти благодариме што го поддржуваш развојот на CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Отвори ја страницата за спонзорство</string>
<string name="upgrade_foss_sponsor_label">Поддржи CAPod</string>
<string name="settings_upgrade_status_description">Твојот статус на поддржувач.</string>
<string name="upgrade_screen_why_title">Предности на надградување</string>
<string name="upgrade_screen_how_title">Како да помогнете</string>
<string name="upgrade_screen_how_body">Станете покровител и спонзорирајте го развојот! Допрете го копчето подолу за да ги активирате сите дополнителни функции и да го отворите мојот GitHub Sponsors профил.</string>
<string name="upgrade_screen_status_free_title">Бесплатна верзија</string>
<string name="upgrade_screen_status_free_body">Користете ја бесплатната верзија на CAPod. Дополнителни функции може да ги отклучите со поддршка на развојот.</string>
<string name="upgrade_screen_status_free_action">Видете опции за надградување</string>
<string name="upgrade_screen_status_upgraded_title">Активна надградување</string>
<string name="upgrade_screen_recurring_title">Подржи развој</string>
<string name="upgrade_screen_recurring_body">CAPod постојано се развива преку ажурирања и поправки. Ако сакате да го поддржите тоа, размислувајте за повторена донација преку GitHub Sponsors.</string>
</resources>

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