R8 re-serializes .kotlin_module files with ':' in the name (Kotlin 2.4
module naming) and AGP 9.4.0's new zip-entry validation rejects colons,
failing buildFreeReleasePreBundle. Fixed upstream in AGP 9.5.0
(issuetracker 543685256); pin stays until then.
WorkManager's persisted work database and JobScheduler jobs survive an
upgrade from the free flavor (shared applicationId). On startup,
WorkManagerInitializer reschedules that stale network-constrained work,
and WorkManager 2.11.2 tracks it via
ConnectivityManager.registerDefaultNetworkCallback, which throws
SecurityException because the offline flavor removes
ACCESS_NETWORK_STATE. The flavor guards in Amber only prevent new
enqueues and cannot stop this.
WorkManager is unused in the offline flavor (all enqueues are
flavor-guarded and ConnectivityService never starts), so strip all of
its manifest components there: WorkManagerInitializer plus
SystemJobService, SystemForegroundService, ForceStopRunnable receiver,
RescheduleReceiver and DiagnosticsReceiver, which would otherwise crash
via stale jobs or BOOT_COMPLETED after initialization is disabled.
Also guard cancelBackupApplicationsAlarm (reachable from
ApplicationsBackupScreen) with isOfflineFlavor like its siblings, and
extend check-offline-permissions.yml to fail if WorkManagerInitializer
or SystemJobService reappear in the offline merged manifest.
feat(security): require biometric authentication to toggle the biometrics setting
nostr:nevent1qqs8av530rq448g5r8a7rajhn9r5g2zsvc34hr92jryv65g5deakvkcpz3mhxue69uhhyetvv9ujumn8d96zuer9wck9amw6
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
Toggling the 'Enable biometrics' slider in the Security screen now triggers
the biometric/keyguard prompt before the switch changes state. This verifies
the biometric sensors work before activation and prevents deactivating the
setting without authorization.
Closesgreenart7c3/Amber#519
Toggling the 'Enable biometrics' slider in the Security screen now triggers
the biometric/keyguard prompt before the switch changes state. This verifies
the biometric sensors work before activation and prevents deactivating the
setting without authorization.
Closesgreenart7c3/Amber#519
feat(permissions): add option to pre-approve permissions under manual approval
nostr:nevent1qqszjz353hwg2g3pcmf8h6qmc49af4ce0mvj057r9zxk6pffjh30s8gpz3mhxue69uhhyetvv9ujumn8d96zuer9wcxmzkuh
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
## What
When connecting a new app while the `Manually approve each permission` policy is selected, a new `Add permission` button lets the user pick extra permissions to be approved automatically 342200224 everything else keeps asking.
## Changes
- New `AddPermissionsSheet` bottom sheet: searchable, deduped list of the full supported permission catalog with checkbox multi-select
- Custom `sign_event` kind input (numeric-only) for kinds not in the catalog, with validation against existing permissions and in-session duplicates
- Wired into all three surfaces:
- `LoginWithPubKey` (nostrsigner:// intent connect flow)
- `BunkerConnectRequestScreen` (NIP-46 connect flow)
- `EditPermission` screen 342200224 persisted as Allow / Always rows, still tunable afterwards
- 6 new strings translated in all 14 locales
## Validation
- ktlintCheck 342234205
- lintFreeDebug 342234205
- testFreeDebugUnitTest 342234205
When connecting a new app with the 'Manually approve each permission'
policy selected, an 'Add permission' button now lets the user pick
permissions from the full supported catalog to be approved
automatically, while everything else still asks for confirmation.
- New AddPermissionsSheet bottom sheet with search, deduped catalog
list and an 'Add' confirmation bar
- Support for adding a custom sign_event kind not present in the
catalog, with duplicate/existing-permission validation
- Wired into LoginWithPubKey (intent path), BunkerConnectRequestScreen
(NIP-46 path) and the EditPermission screen (persisted as
Allow/Always rows)
- New strings translated in all 14 locales
feat(kill-switch): prompt to disable when a connect request arrives + settings toggle
nostr:nevent1qqsgsdplelp3l6zgh95dh0mzsnhggydcqhvjhttq6ns3fqjyf7tgy7spz3mhxue69uhhyetvv9ujumn8d96zuer9wc5u5k4h
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
## What
With the kill switch enabled Amber is disconnected from all relays, so a NIP-46 connect request arriving on screen can never be answered (the response must be published to a relay).
- When a connect request is displayed while the kill switch is enabled, ask the user once whether to disable it. Confirming reuses `KillSwitchReceiver` 342200224 the same toggle path as the Applications screen banner and the notification action (flips the setting, persists it, reconnects relays).
- Wired into both connect-request UIs: the single-request screen (`BunkerSingleEventHomeScreen`) and the grouped multi-request screen (`BunkerMultiEventHomeScreen`).
- New Kill switch item in the Settings network section: title flips between Enable/Disable kill switch, error tint while enabled. Hidden on the offline flavor since it has no network stack.
## Strings
3 new resources translated in all 13 shipped locales.
## Validation
`ktlintCheck`, `lint`, `test` (all flavors) and `compileFreeDebugKotlin` all pass locally.
A NIP-46 connect request can only be answered while Amber is connected to
its relays, but with the kill switch enabled every relay connection is
dropped. When a connect request is displayed in that state, ask the user
once whether to disable the kill switch.
- New KillSwitchConnectPrompt composable reuses KillSwitchReceiver (same
path as the Applications screen banner and the notification action) to
flip, persist and reconnect.
- Wired into BunkerSingleEventHomeScreen (single connect request) and
BunkerMultiEventHomeScreen (connect grouped with other requests).
- Kill switch item added to the Settings network section: title flips
between Enable/Disable, error tint while enabled; hidden on the offline
flavor (no network stack).
- New strings translated in all 13 locales.
- Drop the per-permission delete action; the remove-all-permissions
dialog remains the way to clear permissions
- Localize the CONNECT permission label via toLocalizedString
- Let permission descriptions wrap fully instead of truncating to one
line
Quartz 1.14.0 made WebSocketListener.onMessage and
RelayConnectionListener.onIncomingMessage suspend functions, which broke
compilation.
Bridge the suspend boundary in OkHttpWebSocket the same way Amethyst and
Quartz's own BasicOkHttpWebSocket do: pump incoming frames through an
unbounded Channel consumed by a Dispatchers.IO coroutine, reusing
BasicOkHttpWebSocket's exceptionHandler. Kept Amber's needsReconnect()
proxy/timeout diffing that the Quartz builtin lacks. Mark all
onIncomingMessage overrides suspend and wrap direct test invocations in
runBlocking.
Also bumps AGP to 9.3.2.
fix(profile-subscriptions): synchronize account map iteration against concurrent removal
nostr:nevent1qqsxuv9uf8nvwg5cdfln3r82g6jjqx47kfvpgz7yxcstj4sxf5d3jvqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcql9j3h
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
Fixes the NoSuchElementException reported in nevent1qqszh6nmwv9nl6wt6zg8qm8azhqlhk76hemgn5us0pg4cx7twmayv6cgn4dga
updateFilters() snapshotted the accounts map (accounts.values.toList()) while closeSub() and the EOSE path concurrently removed entries. A weakly-consistent ConcurrentHashMap iterator can run off the end when the map shrinks mid-iteration, throwing NoSuchElementException out of updateFilters().
Fix: iteration and mutation of the accounts map are now mutually exclusive 342200224 updateFilters() copies the value list under synchronized(accounts) and iterates the snapshot outside the lock; all mutation sites (put in updateFilter, remove in closeSub(account), clear in closeSub()) take the same monitor. Single-key lookups stay lock-free.
Verified: ktlintCheck, lint, full test suite (all variants) green; ProfileSubscriptionTest concurrency tests 10/10 clean repeated runs.
Coil 3 registers no ComponentCallbacks of its own, so its 32MB bitmap
memory cache stayed fully resident in this long-lived signer process
even with the UI invisible and under system memory pressure.
onTrimMemory now clears the Coil memory cache on TRIM_MEMORY_UI_HIDDEN
and, additionally, TrustScoreService's cache from
TRIM_MEMORY_BACKGROUND up. Since API 34 the deprecated RUNNING_*/
MODERATE/COMPLETE levels are never delivered, so onLowMemory is
overridden as the foreground-pressure stand-in (verified against the
SDK 36 android.jar: only UI_HIDDEN and BACKGROUND are non-deprecated).
Also ignores the local trace_processor wrapper downloaded for the
heapprofd profiling session.
heapprofd profiling showed NotificationSubscription.updateFilter()'s ~30s
relay-refresh cycle re-running ApplicationDao.getAll on the raw Room dao,
re-decrypting every application row through AndroidKeyStore (a keystore2
binder round-trip per encrypted field) forever. That path was the app's
dominant native allocator: ~15% of live native memory plus all periodic
~400KB allocation bursts (1.33MB/90s steady-state churn).
getAll is now a read-through cache: a second small LruCache keyed by
account pubKey, handing out defensive copies. Every application-table
mutation evicts the affected account's entry (or all entries when only
the app key is known); permission-only writes never do.
Call sites that could bypass the wrapper are routed through the cached
dao so the cache cannot go stale: updateFilter's read, HistoryDao's
updateLastUsed write, and ConnectivityService's reconnect write.
Re-profiled post-fix: decrypt-path stacks 0 rows/0 bytes (was 65 rows/
1.14MB per 90s); steady-state churn ~0.26MB/90s (was ~1.33MB); no
periodic bursts.
Replace delay/withTimeoutOrNull/debounce Long-millis calls with their
kotlin.time.Duration overloads, convert millis timeout constants to
Duration vals, and switch retryWithBackoff, the relay reconnect backoff
and the sensitive-clipboard clear delay to Duration parameters.
The first ZoneId.systemDefault() call lazily mmaps the tzdata file
(ZoneInfoDb class init), which tripped StrictMode DiskReadViolation
(~169 ms) on the main thread during LazyColumn composition in
ApplicationsScreen. Mirror the Coil ImageLoader pre-warm: force the
load on applicationIOScope in Amber.onCreate, and cache the two
DateTimeFormatter instances instead of re-parsing the pattern per item.
Single-shot wall-clock timing can be inverted by JIT compilation
variance and CPU scheduling noise. Retry the measurement up to
3 times (with JIT warmup before each attempt and a short cooldown
between failed attempts) and report per-attempt timings on failure.
Loading indicator + do-not-close warning for "require unlocked device" toggle
nostr:nevent1qqsv2hn9z9yyc9m7z49s9tjmd585u7apya29k6t6ac48h6ta9w42tkgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcpez6vu
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
Toggling "Require unlocked device for key access" rotates the AMBER_AES_KEY Keystore key and re-encrypts every stored secret, which can take a while. This PR makes that visible:
- While rotation runs, the Security screen shows a small spinner with "Re-encrypting all stored keys342200246" plus "Do not close the app or lock the screen until this finishes" in error color.
- The row and switch are disabled during rotation to prevent overlapping rotations.
- Rotation still runs on the application IOScope (completes even if the user leaves); on failure it logs and reverts the switch to the persisted setting.
- New strings translated into all 13 shipped locales.
- AGENTS.md now instructs agents to always translate new/changed strings into every values-*/strings.xml in the same change.
Validation: ktlintCheck, compileFreeDebugKotlin, lintFreeDebug, and resource processing for free+offline flavors all pass (lintOfflineDebug has pre-existing failures also present on master).
Toggling the setting rotates the AMBER_AES_KEY Keystore key and re-encrypts
every stored secret, which can take a while. While the rotation runs the
screen now shows a small progress spinner with "Re-encrypting all stored
keys…" and "Do not close the app or lock the screen until this finishes",
and the toggle/row are disabled to prevent overlapping rotations. The work
still runs on the application IOScope so it completes even if the user
leaves; on completion (or failure, which now logs and reverts the switch
to the persisted value) the indicator is dismissed.
The two new strings are translated into all 13 shipped locales, and
AGENTS.md now instructs agents to always translate new/changed strings
into every locale file in the same change.
Fix Applications screen slowness after envelope-encrypting secrets
nostr:nevent1qqsy48xjslclznph8hy78dyart9cwurd4v3lya9mycqv3r3822nth8qpz3mhxue69uhhyetvv9ujumn8d96zuer9wccae2pp
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
The GHSA-5fjp-ghh8-wch8 envelope-encryption change made every ApplicationDao read decrypt the secret/localKey columns, and each decryption re-fetched the AMBER_AES_KEY Keystore key (3 sequential binder IPCs per call). The Applications screen paid 2 x rows of those per page plus the TEE cipher op, fully serialized 342200224 several seconds on a populated account.
Two fixes:
1. SecureCryptoHelper caches the SecretKey handle (material never leaves the TEE/StrongBox). All entry points retry exactly once with a re-fetched handle on stale-handle errors (InvalidKeyException/KeyStoreException/UnrecoverableKeyException/ProviderException), so concurrent rotateKey or the unlocked-device policy cannot wedge reads. AEADBadTagException still propagates, preserving the decryptField failure contract.
2. The Applications list now uses a dedicated ApplicationListItem projection (key, name, relays, icon, lastUsed) that never selects the encrypted columns 342200224 the screen renders neither field, so the load performs zero Keystore operations. DecryptingPagingSource removed with its only caller. The Pager is also remember(account.hexKey)-ed instead of being rebuilt on every recomposition.
Also adapts ApplicationsScreen to the NavHostControllerWrapper stability pattern used by the other screens.
Verified: ktlintCheck, lint, test (all variants), assembleFreeDebug, assembleOfflineDebug.
The GHSA-5fjp-ghh8-wch8 change made every ApplicationDao read decrypt
the `secret`/`localKey` columns, and each decryption re-fetched the
AMBER_AES_KEY Keystore key: KeyStore.getInstance().load(null) +
containsAlias() + getEntry(), three sequential binder IPCs to the
keystore daemon, per call. The Applications screen paid 2 x rows of
those per page (Paging's initial load is 3 x pageSize rows), fully
serialized, plus the TEE cipher operation itself — several seconds on
a populated account.
Two independent fixes:
- SecureCryptoHelper: cache the SecretKey handle (the material never
leaves the TEE/StrongBox; the object is a lightweight reference).
Fast path is a volatile read; miss path double-checks under a
synchronized block. All four public entry points run through
withFreshKeyRetry, which retries exactly once with a re-fetched
handle on stale-handle errors (InvalidKeyException, KeyStoreException,
UnrecoverableKeyException, ProviderException) so concurrent key
rotation or the opt-in unlocked-device policy cannot wedge reads.
GCM AEADBadTagException (corrupt/wrong-key ciphertext) still
propagates immediately, preserving the decryptField failure contract.
rotateKey updates the cache in both of its branches.
- ApplicationDao: the Applications list now queries a dedicated
projection (ApplicationListItem: key, name, relays, icon, lastUsed)
that never selects the encrypted columns, so the screen performs
zero Keystore operations regardless of row count. The screen renders
neither secret nor localKey, so the SELECT * decrypts were pure
overhead. DecryptingPagingSource is removed with its only caller.
The Pager is also remember(account.hexKey)-ed: it was previously
rebuilt on every recomposition (killSwitch / backup-warning state
flips), re-running the initial load each time.
Also adapts ApplicationsScreen to the NavHostControllerWrapper
stability pattern used by the other screens.
Verified: ktlintCheck, lint, test (all variants), assembleFreeDebug,
assembleOfflineDebug.
Debounce relay-status counter updates (notification rate limit)
nostr:nevent1qqspa459s04qpe5h3k6m0dg2amne9fl247pxjk3w6ylnq7m4rw3pk2cpz3mhxue69uhhyetvv9ujumn8d96zuer9wc4mf4tk
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
Fixes the relay-status notification (id=2) hitting Android's per-app notification rate limit during relay activity bursts.
addSent/addFailed called notify(2) synchronously for every relay event (NostrClientLoggerListener.onSent), so bursts of 7-34 notifies/sec against the ~5/sec limit were shed by NotificationManagerService and the status notification froze on stale text.
The counter path now emits Unit ticks into a MutableSharedFlow (capacity 1, DROP_OLDEST) collected with debounce(300): a burst collapses into one notification update, the last tick always renders, and tryEmit never suspends or fails on Quartz IO threads. No numeric counter, so nothing overflows or wraps no matter how long the process runs. Connection-state updates keep their existing debounce so connect/disconnect still shows promptly.
Issue: nevent1qy28wumn8ghj7un9d3shjtnwva5hgtnyv4mqqg80gw6ushd8jyj6rt8qseup7hngj0xhqa2ms3dvam60lhc760zvkyq6uts9
Validated: ktlintCheck, lint, :app:testFreeDebugUnitTest.
AmberRelayStats.addSent/addFailed called notify(id=2) synchronously for
every relay event, driven by NostrClientLoggerListener.onSent. Bursts of
relay traffic (7-34 notifies/sec vs Android's ~5/sec per-app limit) made
NotificationManagerService shed the updates, freezing the status
notification on stale text, and wasted a full BigTextStyle build plus
Binder IPC per dropped event.
Route the counter path through a Unit event flow
(MutableSharedFlow, capacity 1, DROP_OLDEST) collected with
debounce(300): a burst collapses into one notification update and the
last tick always renders. No numeric counter, so nothing overflows or
wraps no matter how long the process runs; tryEmit never suspends or
fails on Quartz IO threads. Connection-state updates keep their
existing debounce so connect/disconnect still shows promptly.
Fixes ngit issue nevent1qy28wumn8ghj7un9d3shjtnwva5hgtnyv4mqqg80gw6ushd8jyj6rt8qseup7hngj0xhqa2ms3dvam60lhc760zvkyq6uts9
Warm account cache at app start to fix switch-accounts button
nostr:nevent1qqsvj8yz64w9mdtzee0kzrh6kr56c8fxw9jxgcrq24etkta5xtshstspz3mhxue69uhhyetvv9ujumn8d96zuer9wc8pjy2a
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
## Problem
Since 96ee4107 (GHSA-8844-q5vh-9j8f, I3) made `loadFromEncryptedStorageSync` lazy-load only the requested npub, the account switcher in approval screens only ever shows the current account.
The full-account cache warm was expected to happen in `reloadApp()`, but that runs from `ConnectivityService.onCreate()` which is started via an AlarmManager PendingIntent 342200224 a race with composition 342200224 and the service does not exist in the offline flavor at all. By the time `LoginWithPubKey` / `BunkerConnectRequestScreen` snapshot `allCachedAccounts()` inside `remember {}`, only one account is cached.
## Fix
- Extract the eager all-account load from `reloadApp()` into a new `warmAccountCache()`.
- Call it from `AccountStateViewModel.tryLoginExistingAccount()` before any UI state is emitted 342200224 the deterministic app start covering both entry activities and both flavors. On switch/logout it is a no-op (cache hits) or a reload of the remaining accounts.
- `reloadApp()` behavior unchanged; `loadFromEncryptedStorageSync` stays lazy for SignerProvider / NIP-46, so the I3 hardening is preserved.
## Validation
`ktlintCheck`, `test` (all variants) and `lint` all pass.
Fix LazyColumn duplicate-key crash in activity history screens
nostr:nevent1qqsrxc68vrmt2ypjyswald6ex53rcndanf60jtxqahy8ezqdv3y4wkcpz3mhxue69uhhyetvv9ujumn8d96zuer9wc6m5qe7
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
History rows are inserted continuously (every sign/encrypt, including auto-accepted NIP-46 requests). Room pages are independent LIMIT/OFFSET queries, so an insert between the initial load and a scroll-triggered append shifts the offsets and the same row can be returned in two loaded pages at once. Keying LazyColumn items by the database id then crashes with IllegalArgumentException: Key \"X\" was already used when scrolling down the activity screen.
Fall back to positional keys, which are always unique. Rows are stateless (ActivityRow produceState is keyed on the entity itself), so behavior is unchanged.
Fixes the reported crash: IllegalArgumentException: Key \"5329\" was already used (6.3.0-FREE, scrolling the activity screen).
Since 96ee4107 made loadFromEncryptedStorageSync lazy-load only the
requested npub, the full account cache was expected to be populated by
reloadApp(). But reloadApp() runs from ConnectivityService.onCreate(),
which is started via an AlarmManager PendingIntent — a race with the UI
— and the service doesn't exist in the offline flavor at all. By the
time the approval bottom sheet composes and snapshots
allCachedAccounts() inside remember {}, only the current account is
cached, so the switch-accounts button only ever offered one account.
Extract the eager all-account load from reloadApp() into
warmAccountCache() and call it from AccountStateViewModel before any UI
state is emitted — the deterministic app start covering both entry
activities and both flavors. reloadApp() behavior is unchanged, and
loadFromEncryptedStorageSync stays lazy for the SignerProvider / NIP-46
paths, preserving the I3 hardening from GHSA-8844-q5vh-9j8f.
Toggling "require unlocked device for key access" rotates the
AMBER_AES_KEY Keystore key, but rotateKey only re-encrypted the
DataStore secrets (account keys, PIN, WebDAV password). The
envelope-encrypted `secret`/`localKey` columns of the per-account
`application` Room tables were left under the old key, making every
NIP-46 connection row undecryptable after the toggle: getBySecret
lookups miss and localKey turns into undecryptable ciphertext.
rotateKey now stages those rows before deleting the old key and
rewrites them re-encrypted with the new key via the new
updateEncryptedColumnsRaw DAO query (ciphertext-in/ciphertext-out,
empty sentinel preserved, no schema change). Columns that fail
old-key decryption are written back verbatim so rotation never
destroys data it cannot recover, matching the decryptField failure
contract in ApplicationEntityCrypto.kt.
network_security_config.xml permits cleartext globally (deliberate, for
local/onion relays). EditRelaysDialog auto-prefixes ws:// only for .onion /
private IPs but accepts user-typed explicit ws:// URLs, which then carry
kind-24133 NIP-46 traffic (E2E ciphertext + metadata over cleartext). Add
an in-app warning below the relay text field when the user explicitly
types ws:// for a non-onion / non-private-network host, so cleartext relays
are opt-in and informed. Update the network_security_config comment to
document the deliberate global scope and the in-app warning.
Two defense-in-depth reductions in decrypted-key cache exposure:
1. loadFromEncryptedStorageSync previously warmed the ENTIRE account cache
(runBlocking over all saved accounts) on any cold cache / size mismatch,
decrypting every account's private key process-wide on the first
SignerProvider / NIP-46 lookup. Lazy-load only the requested npub; direct
lookups always know which account they want. The UI account switcher's
allCachedAccounts() path is still populated by reloadApp() at startup.
2. updatePrefsForLogout dropped the cache entry but left the Account's
KeyPair.privKey ByteArray intact in heap until GC. Best-effort zeroize
the key bytes in place before removing the reference.
reloadApp keeps its eager all-account load (the UI account switcher depends
on allCachedAccounts() being populated synchronously); this change targets
the other eager-decrypt-all surface and the logout zeroization gap.
parseRememberType mapped unknown screenCode values to RememberType.ALWAYS.
Enforcement never reparses the stored value (IntentUtils uses the
acceptUntil/rejectUntil computed at write time); the only caller is the
EditPermission settings dropdown default. Defense-in-depth: map unknown ->
NEVER so a corrupted/unrecognized stored policy cannot grant indefinite
auto-accept.
The V2/V3 DECRYPT path (SignerProviderQuery.kt:288-322) invoked the
private key to decrypt + classify plaintext BEFORE checking whether the
caller had any permission. Results were withheld on denial, so there was
no direct disclosure, but an unauthorized caller triggered key usage and
timing/error oracles.
Add a pre-authorization gate: for non-encrypt operations, look up the
signPolicy and ANY relevant permission row (generic type, V2 classified
content types, NIP-level grant, or V3 kind-scoped/all-kinds) before
touching the key. If none exists and signPolicy != 2 (accept-all),
short-circuit with the same null cursor the existing no-permission path
already returns — externally identical behavior, but the private key is
never invoked for a totally unknown caller.
Classified-specific grant semantics (e.g. auto-accept DECRYPT_EVENT only)
are preserved: ANY relevant row counts as pre-authorized, so we still
proceed to decrypt + classify and the final per-type check honors
accept/reject precisely. Auto-reject rows still pass the gate by design,
since the user has already made an explicit decision for that caller.
EventNotificationConsumer's pre-decrypt (:190-196) wraps the NIP-46
envelope and is protocol-forced (the request type lives inside it);
deferring its pre-sign (:231-232) preview requires refactoring the
approval UI's signedEvent contract and is left for a follow-up. The
advisary rates I1 as info; this change closes the primary surface it
calls out (the specific line range 288-322).
QrCodeDialog (raw nsec) and QrCodeScreen (Route.QrCode, ncryptsec/nsec)
render raw key material as a QR code but omitted FLAG_SECURE. Capture
requires a user-initiated, device-auth-gated reveal, so low severity, but
the protection exists elsewhere (SeedWordsPage, RandomPinInput) and was
omitted here. Set FLAG_SECURE on the activity window for the lifetime of
each composable, mirroring the existing screens, to block screenshots and
screen recording.
ReportAssembler previously appended e.toString()/cause.toString() and the
full stack trace verbatim. Parser/crypto exceptions can embed attacker-
controlled request data (hex keys, npub/nsec bech32, base64 ciphertexts).
Add a redaction step that replaces 64-char hex keys, Nostr bech32 prefixes
and long base64 blobs with placeholders before the report is written to
internal storage by UnexpectedCrashSaver.
Adds ReportAssemblerTest covering hex/bech32/base64/cause-chain redaction
and non-sensitive content preservation.
NostrClientLoggerListener.onSent/onIncomingMessage previously passed raw
cmdStr/msgStr to AmberLog.d (gated only by BuildConfig.DEBUG). Frames can
carry NIP-46 envelopes and DM/gift-wrap ciphertexts. Log only the command/
message type and the wire byte size, enough for debugging without leaking
payloads.
AMBER_AES_KEY (AES-256-GCM, optional StrongBox) was built without
setUnlockedDeviceRequired, so any code in Amber's process could decrypt
every account key while the screen was locked — the PIN/biometric lock
is UI-only. The advisory suggests an opt-in user-auth-bound key for at
least interactive signing, acknowledging the trade-off with background
NIP-46 signing.
Add an opt-in toggle on the Security screen: when enabled, the Keystore
key is generated with setUnlockedDeviceRequired(true) (API 28+), so the
TEE refuses key use while the device is locked. This does NOT prompt for
biometrics per operation — it only refuses use while locked, so
foreground NIP-46 signing keeps working.
The flag is set at key-generation time, so toggling requires key
rotation: decrypt all stored secrets (per-account DataStore
NOSTR_PRIVKEY/SEED_WORDS, app DataStore PIN, WebDAV password) with the
old key, delete it, generate a new one with the new policy, and
re-encrypt everything. The rotation holds SecureCryptoHelper's mutex for
the entire operation and uses internal non-locking cipher methods +
raw DataStore helpers to avoid mutex reentrancy (Kotlin's Mutex is not
reentrant). The rotation is safe-by-design: all secrets are decrypted
before the old key is deleted.
The per-account Room database (amber_db_<npub>) stored two NIP-46 secret
values as cleartext TEXT columns: the bunker connection `secret` and the
`localKey` — the latter being a full Nostr private key. Anyone with access
to the app's internal storage (rooted device, privilege-escalating malware,
or a future bug exporting the DB) could recover these values and bypass the
Keystore protection the main account nsec enjoys (CWE-312).
Fix: envelope-encrypt both columns with the existing Keystore-backed AES-256-GCM
key (SecureCryptoHelper) before Room persistence, and decrypt on read, so every
existing consumer continues to see the plaintext values it already expects.
- SecureCryptoHelper: add non-suspend encryptBlocking/decryptBlocking so
Migration.migrate() and getByKeySync() can call them without a runBlocking
bridge; suspend variants now delegate to the blocking implementations.
- ApplicationEntityCrypto.kt (new): encryptForStorage/decryptFromStorage
mappers + DecryptingPagingSource. Sentinel rule: empty values stay "" at
rest (matches the WebDAV password idiom in LocalPreferences.kt:661-681),
preserving `WHERE localKey != ''` enumeration in NotificationSubscription
and the `localPubKey` derivation on empty localKey.
- ApplicationDao: split methods touching `secret`/`localKey` into Room-
generated `*Raw` (encrypted columns) and default-method wrappers that
apply the mappers. `getBySecret` rewritten to decrypt and filter in Kotlin
(random GCM IV breaks `WHERE secret = :secret`).
- CachingApplicationDao: add delegating `*Raw` overrides so the decorator
still instantiates; cache logic unchanged.
- AppDatabase: add MIGRATION_18_19 (in-place envelope-encrypt of existing
plaintext rows via compiled statement + transaction; empty values stay
empty). Bump @Database version to 19.
- Backup/restore: no changes — ApplicationBackup.buildPayload reads via the
wrapped DAO (plaintext) and the JSON is already NIP-44 encrypted by the
account key; restore goes through the wrapped insert (auto-encrypts).
- Tests: new androidTest ApplicationEntityCryptoTest covers round-trip,
raw-column-ciphertext assertion, empty sentinel, localPubKey derivation,
getAllWithLocalKey filter, getBySecret (hit/miss/empty),
insertApplicationWithPermissions, getAll, and the MIGRATION_18_19 row
re-encryption. Requires a device/emulator (AndroidKeyStore unavailable
under JVM test).
- New room-testing androidTestImplementation dependency.
Verified: ktlintCheck, lint (no issues), testFreeDebugUnitTest (0 failures),
compileFreeDebugAndroidTestKotlin, assembleFreeDebug, assembleOfflineDebug,
and the offline merged manifest check (no INTERNET/ACCESS_NETWORK_STATE/
CHANGE_NETWORK_STATE permissions leaked).
The auth whitelist auto-accepted kind-22242 (NIP-42) signing for any
caller: whitelistAutoAccept bypassed the per-requester permission check
in the SignerProvider/NIP-46 query path, letting any installed app or
bunker client silently obtain relay-auth signatures for whitelisted
relays (confused deputy, CWE-863).
The whitelist is now only a relay constraint: non-whitelisted relays
still auto-reject, but membership no longer grants signing. Kind-22242
requests always require a requester-scoped SIGN_EVENT permission
(relay-specific or wildcard grant) or fall through to the user prompt,
matching the whitelist's documented behavior and the approval UIs.
Adds SignerProviderQueryTest covering: no silent signing without a
grant, silent signing with relay-scoped/wildcard grants, auto-reject of
non-whitelisted relays before any permission lookup, and unchanged
empty-whitelist behavior.
History rows are inserted continuously (every sign/encrypt, including
auto-accepted NIP-46 requests). Room pages are independent LIMIT/OFFSET
queries, so an insert between the initial load and a scroll-triggered
append shifts the offsets and the same row can be returned in two loaded
pages at once. Keying LazyColumn items by the database id then crashes
with 'IllegalArgumentException: Key X was already used' when scrolling
down the activity screen.
Fall back to positional keys, which are always unique. Rows are
stateless (ActivityRow's produceState is keyed on the entity itself),
so behavior is unchanged.
- Bump versionCode 197 -> 198, versionName 6.3.0 -> 6.4.0 in app/build.gradle.kts
- Add Amber 6.4.0 release notes to CHANGELOG.md (replaces 6.3.0 block, kept as latest only)
- Create docs/changelogs/6.4.0.md with the full release notes and verifying block
- Add 6.4.0 entry to docs/changelogs/README.md index
Ignore platform finalizer-watchdog crashes in crash reports
nostr:nevent1qqsptc5s9kvxe6ahr4yxj6cc7ur8sgg03tu7epn0hyz05s7m248uedqpz3mhxue69uhhyetvv9ujumn8d96zuer9wc5kul9c
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
BinderInternal$GcWatcher.finalize() TimeoutExceptions are a known
AOSP issue raised by the finalizer watchdog on the FinalizerDaemon
thread. They contain no app frames and cannot be caught or prevented
by app code, so the report only nags users with an unactionable
"send crash report?" prompt.
Extend the existing OOM junk filter in UnexpectedCrashSaver to drop
TimeoutExceptions whose message or stack matches the finalizer
watchdog path. App-thrown TimeoutExceptions and all other crashes
are still reported.
BinderInternal$GcWatcher.finalize() TimeoutExceptions are a known
AOSP issue raised by the finalizer watchdog on the FinalizerDaemon
thread. They contain no app frames and cannot be caught or prevented
by app code, so the report only nags users with an unactionable
"send crash report?" prompt.
Extend the existing OOM junk filter in UnexpectedCrashSaver to drop
TimeoutExceptions whose message or stack matches the finalizer
watchdog path. App-thrown TimeoutExceptions and all other crashes
are still reported.
nostr:nevent1qqsrcudwcn87h26c788r8zy48z23yd0629uaxcm9ejy69a6a5petdcspz3mhxue69uhhyetvv9ujumn8d96zuer9wcg8g99j
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
Sync the supported sign_event kinds with quartz 1.13.1's KindNames
registry: 108 kinds that had no localized label in Amber, plus the
Concord kinds (13302, 20013, 20014), NIP-51 git repository bookmarks
(10018) and NIP-53 room presence (10312).
Each kind now has:
- a localized label in all 13 shipped locales,
- a sign_event branch and supportedKindNumbers entry so it can be
approved and filtered in the activity screens,
- a kindsByNip mapping where the kind belongs to a numbered NIP,
enabling NIP-level permission grouping.
Sync the supported sign_event kinds with quartz 1.13.1's KindNames
registry: 108 kinds that had no localized label in Amber, plus the
Concord kinds (13302, 20013, 20014), NIP-51 git repository bookmarks
(10018) and NIP-53 room presence (10312).
Each kind now has:
- a localized label in all 13 shipped locales,
- a sign_event branch and supportedKindNumbers entry so it can be
approved and filtered in the activity screens,
- a kindsByNip mapping where the kind belongs to a numbered NIP,
enabling NIP-level permission grouping.
Fix NegativeArraySizeException crash in relay subscriptions
nostr:nevent1qqsq90jwwn6z4na0s9lmf694j08f72655p390u00z05uma3e9fjztxcpz3mhxue69uhhyetvv9ujumn8d96zuer9wc65tc6r
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
ProfileSubscription and NotificationSubscription kept their subscription
state in plain LinkedHashMaps that are mutated from UI/coroutine threads
(updateFilter, closeSub, updateFilters) while relay I/O threads iterate
them in onIncomingMessage. Iterating a LinkedHashMap's values/entries
while another thread structurally modifies it crashes with
NegativeArraySizeException on Android, e.g. from
ProfileSubscription.updateFilters via checkForNewRelaysAndUpdateAllFilters.
- Use ConcurrentHashMap for all shared maps in both subscriptions and
concurrent key sets for the per-subscription relay sets
- Replace containsKey + get(!!) with an atomic getOrPut in
NotificationSubscription.updateFilter
Add unit tests: functional coverage for the subscribe/unsubscribe
lifecycle and concurrency regression tests that reliably reproduce the
race on the pre-fix implementation.
Add Compose previews for the multi-event approval screens
nostr:nevent1qqsy8zn7vrj2slsmmx92pp4naumg6lq3z4gp52g4un2fcug3cyzu6gcpz3mhxue69uhhyetvv9ujumn8d96zuer9wcjughul
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
Add light/dark previews for BunkerMultiEventHomeScreen and
IntentMultiEventHomeScreen with sample request groups (connect,
sign event, NIP-44 decrypt) so group headers, options and cards
all render.
Guard side effects that reach the Amber singleton, which does not
exist in the preview renderer:
- ProfileSubscriptionEffect: skip the relay subscription in
inspection mode (also fixes every preview embedding SigningAs)
- BunkerMultiEventHomeScreen: skip the encrypted-storage/DB lookup
- rememberAppDisplayInfo: fall back to the package name when any
part of the PackageManager lookup fails instead of crashing
ProfileSubscription and NotificationSubscription kept their subscription
state in plain LinkedHashMaps that are mutated from UI/coroutine threads
(updateFilter, closeSub, updateFilters) while relay I/O threads iterate
them in onIncomingMessage. Iterating a LinkedHashMap's values/entries
while another thread structurally modifies it crashes with
NegativeArraySizeException on Android, e.g. from
ProfileSubscription.updateFilters via checkForNewRelaysAndUpdateAllFilters.
- Use ConcurrentHashMap for all shared maps in both subscriptions and
concurrent key sets for the per-subscription relay sets
- Replace containsKey + get(!!) with an atomic getOrPut in
NotificationSubscription.updateFilter
Add unit tests: functional coverage for the subscribe/unsubscribe
lifecycle and concurrency regression tests that reliably reproduce the
race on the pre-fix implementation.
Add light/dark previews for BunkerMultiEventHomeScreen and
IntentMultiEventHomeScreen with sample request groups (connect,
sign event, NIP-44 decrypt) so group headers, options and cards
all render.
Guard side effects that reach the Amber singleton, which does not
exist in the preview renderer:
- ProfileSubscriptionEffect: skip the relay subscription in
inspection mode (also fixes every preview embedding SigningAs)
- BunkerMultiEventHomeScreen: skip the encrypted-storage/DB lookup
- rememberAppDisplayInfo: fall back to the package name when any
part of the PackageManager lookup fails instead of crashing
Add the .ai-jail entry to the project's root .gitignore file. This ensures that local AI-related configuration or temporary metadata directories are not tracked by version control.
Redesign multi-request approval screen with Approve/Deny toggles (#497)
nostr:nevent1qqs8syvg3ddd7l3cwmfcuwy8f6hdags9usa706kprs506fm9cxsujjcpz3mhxue69uhhyetvv9ujumn8d96zuer9wc06qjx5
PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
PR description:
Replace the confusing "select + Approve/Discard" interaction on the
multi-event approval screen with an explicit Approve/Deny model:
- Each request card and each group header now has an Approve/Deny
segmented toggle (approve = primary, deny = error).
- Removed the top "Approve/Deny all" toggle and the per-group
TriStateCheckbox; group headers carry their own Approve/Deny toggle.
- Replaced the two bottom buttons (Approve selected / Discard selected)
with a single Confirm button. Confirm commits every per-request
decision: approve signs and (when remembered) persists an accept rule;
deny rejects and (when remembered) persists a deny rule, preserving
existing deny-always behavior.
- Bunker path now sends a proper bunker error response for denied
requests instead of leaving the client to time out.
- Relabeled the per-group remember control to "Remember my choice for"
since it now covers both approve and deny rule persistence.
- Fixed deny toggle contrast: selected text now uses onError for the
deny segment instead of hardcoded black on dark-red.
AmberToggles gains an optional indicatorColor and selectedTextColor;
ToggleOption takes a selectedTextColor param. Removed unused
discard_all/approve_all strings across all locales.
Closes#497
Add the three strings introduced by the Approve/Deny redesign to all
13 supported locales (de, es, fr, in, it-rIT, ja, ko-rKR, pt-rBR, ru,
th, tr, vi-rVN, zh), matching the base values/strings.xml ordering.
Replace the confusing "select + Approve/Discard" interaction on the
multi-event approval screen with an explicit Approve/Deny model:
- Each request card and each group header now has an Approve/Deny
segmented toggle (approve = primary, deny = error).
- Removed the top "Approve/Deny all" toggle and the per-group
TriStateCheckbox; group headers carry their own Approve/Deny toggle.
- Replaced the two bottom buttons (Approve selected / Discard selected)
with a single Confirm button. Confirm commits every per-request
decision: approve signs and (when remembered) persists an accept rule;
deny rejects and (when remembered) persists a deny rule, preserving
existing deny-always behavior.
- Bunker path now sends a proper bunker error response for denied
requests instead of leaving the client to time out.
- Relabeled the per-group remember control to "Remember my choice for"
since it now covers both approve and deny rule persistence.
- Fixed deny toggle contrast: selected text now uses onError for the
deny segment instead of hardcoded black on dark-red.
AmberToggles gains an optional indicatorColor and selectedTextColor;
ToggleOption takes a selectedTextColor param. Removed unused
discard_all/approve_all strings across all locales.
Closes#497
Adds the LeakCanary dependency to the version catalog and includes it as a debugImplementation in the app module to help detect memory leaks during development.
Updates the project build tools and configuration:
* Upgrade Gradle wrapper from 9.5.1 to 9.6.1.
* Upgrade Android Gradle Plugin (AGP) from 9.2.1 to 9.3.0.
* Enable `org.gradle.tooling.parallel` in `gradle.properties` for faster syncs in supported Gradle versions.
* Refresh `gradlew` and `gradlew.bat` scripts to align with modern Gradle templates, including switching to `-jar` execution for the wrapper and adding SPDX license identifiers.
* Add download retry settings to `gradle-wrapper.properties`.
Adds a privacy mode toggle in the Security screen that disables logging
and activity statistics. When enabled, existing logs and activity history
are wiped immediately and future writes are blocked at the DAO layer.
Translated into all 13 supported locales.
Wrap the bunker permission parsing logic in a `try-catch` block and use `mapNotNull` to prevent crashes when encountering malformed permission strings. Parsing errors are now logged via `AmberLog` and the invalid permissions are skipped.
This change removes the redundant `packageName` local variable and replaces its occurrences with `requesterId` throughout the `SignerProviderQuery` class.
The refactoring affects:
- Permission and sign policy lookups in `permDao`.
- History and log entry creation for `HistoryEntity` and `LogEntity`.
- Account existence checks and logging of operation errors.
Each incoming relay message was dispatched into its own coroutine, so
the release EOSE could run before the release EVENT had populated
pendingFileEventIds. onReleaseEose() then saw an empty list and called
finishCheck() instead of opening the file subscription, so no update was
ever found.
Route messages through a FIFO channel consumed by a single coroutine so
the release EVENT is always processed before its EOSE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getDatabase/getLogDatabase/getHistoryDatabase used a non-atomic
check-then-put on their ConcurrentHashMaps. Since AppDatabase.getDatabase
(and the Log/History equivalents) builds a fresh RoomDatabase on every
call, two racing callers could each build one; the loser of the put() was
dropped without close() and its SQLiteConnection was reported by
StrictMode's CloseGuard (LeakedClosableViolation) when finalized.
Switch the three getters to computeIfAbsent, matching dao(), so each npub
builds exactly one database.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fastFallback is an OkHttp 5 API, but the offline flavor compiles the
shared HttpClientManager against OkHttp 4 (pulled in transitively by
Coil, since okhttp itself is freeImplementation-scoped), which broke
:app:compileOfflineDebugKotlin.
Invoke fastFallback(false) via reflection and no-op when it's absent.
OkHttp 4 has no fast fallback and already connects sequentially, so
skipping it there is the correct behavior — not just a compile fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The EventListener approach didn't cover the relay path: relay connections
are WebSockets, and RealWebSocket.connect rebuilds the client with
eventListener(EventListener.NONE), so the listener never fired. With Tor
on, those WebSockets also use a SOCKS proxy, whose socket OkHttp builds
via Socket(proxy) — bypassing the SocketFactory too. So neither hook
tagged the relay sockets and the UntaggedSocketViolation persisted.
Application interceptors do survive the WebSocket client rebuild. Pair a
TaggingInterceptor with fastFallback(false) on proxied clients:
SequentialExchangeFinder then connects synchronously on the call thread —
the thread the interceptor already tagged — so the SOCKS socket fd is
tagged when it's created. fastFallback is left on for direct clients
(a single localhost SOCKS route gains nothing from Happy Eyeballs), which
keep relying on TaggedSocketFactory.
Restore TaggedSocketFactory (direct/HTTP routes) and TaggingInterceptor,
drop the WebSocket-blind TrafficStatsEventListener.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SocketFactory-based tagging missed SOCKS proxy (Tor) connections:
ConnectPlan.connectSocket builds those sockets with Socket(proxy)
directly, bypassing the configured SocketFactory, so the raw fd was
created untagged on the TaskRunner thread and tripped StrictMode's
UntaggedSocketViolation. The TaggingInterceptor didn't help either — it
runs on the call/dispatch thread, not the connect thread.
Replace TaggedSocketFactory, TaggedDns, and TaggingInterceptor with a
single TrafficStatsEventListener that sets the thread stats tag in
dnsStart/connectStart. Those callbacks fire on the exact thread that then
opens the socket, for every route type (direct, HTTP, and SOCKS), so one
listener covers them all. Wire it into the shared root client and the
WebDav client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OkHttp creates the default SSLSocketFactory eagerly in the OkHttpClient
constructor, which Android10Platform reports as a ~55ms StrictMode slow
call (newSSLContext). The first build was being forced onto the main
thread by setDefaultUserAgent (called from setContent), tripping the
detector.
Make the setDefault*/clearProxy mutators invalidate the cached clients
instead of eagerly rebuilding them, so the expensive build happens
lazily inside getHttpClient — only ever reached from network/background
threads. With no main-thread build, the buildSafe wrapper that
temporarily relaxed the StrictMode thread policy is no longer needed, so
remove it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route every OkHttp client through TaggedSocketFactory/TaggedDns and a
TaggingInterceptor so DNS lookups and socket creation set a thread stats
tag, avoiding StrictMode's untagged-socket violations. Tag the Tor proxy
probe sockets in Amber too, and build clients under a relaxed thread
policy. Warm up the HTTP clients at startup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This change adds a human-readable string for Nostr event kind 39701 and updates the `Permission` model to return this localized string when mapping event kinds to descriptions.