Follow-up to #457. Swept the em-dash character (U+2014) out of the test
tree (test descriptions and comments) and cleaned 8 em-dashes that
landed in lib comments via the #456 refactor after #457 merged, so the
tree is back to zero. Same rules: replaced with commas/colons/periods,
preserved the lone "no data" glyph placeholders, left non-English ARB
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Profile is now a container of capability-scoped sections (schema_version 2):
- wifi (WifiConfig) — any wifi device
- mqtt (MqttSection = region + status_interval + brokers) — observer capability
so MQTT is defined once and shared by observer / observer-repeater /
observer-companion, never redone per device. Future radio/repeater/companion/
display sections slot in alongside.
Parser reads the sectioned YAML and rejects the old flat v1 layout with a clear
message. Enumerator reads from sections but emits the SAME firmware keys, so
apply/diff/screens are unchanged. 45 config-profile tests updated + green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ben's style rule: no em-dash character in copy, docs, or code comments
(it reads as an AI tell), and the repo is going public. Replaced the
em-dashes in code comments and English UI strings with commas, colons,
or periods, whichever reads best. User-facing strings were hand-tuned
for natural punctuation rather than a blanket comma.
Preserved the lone "no data" glyph placeholders (a standalone dash used
as a not-available indicator in status displays); those are a design
element, not prose.
Regenerated app_localizations*.dart from app_en.arb (the English
fallback for untranslated keys propagates to every locale's generated
file). Non-English ARB translations left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini pass 2 flagged that the banner shrinks the viewport while the "no
channels" / "no results" empty-states used a fixed MediaQuery.height - N
SizedBox, pushing the message below the fold. Replace both with
LayoutBuilder + SingleChildScrollView + ConstrainedBox(minHeight:
constraints.maxHeight) so they center in the actual available space (banner
or not) and stay pull-to-refresh scrollable. Removes the fragile -200/-300
magic numbers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini flagged two unhandled PlatformException paths (SAFELANE §6):
- isIgnoringBatteryOptimizations could throw -> banner silently never shows;
now caught + logged, leaving the banner hidden on an unknown status.
- openIgnoreBatteryOptimizationSettings could throw -> button did nothing with
no feedback; now caught + logged + a snackbar tells the user it failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the warning on the connection/device-selection screen so the user
can fix battery settings before connecting, not only after landing on the
channels screen. Reuses BatteryOptimizationBanner (Android-only, self-checking).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On aggressive OEMs (Samsung One UI) a backgrounded app that is not exempt
from battery optimization is slept on screen-off and drops the radio
connection, with no warning. Add a persistent banner on the channels screen
that appears (Android only) when the app is not exempt, explains the risk,
and deep-links to the battery settings via
openIgnoreBatteryOptimizationSettings(). Re-checks on resume so it clears
once the user applies the change; dismissible for the session.
No restricted permission: reads own status and opens the settings screen
(ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); the Play-restricted
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS path is deliberately avoided.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Firmware #465 now writes RX RSSI into reserved2 (byte[3]) of the v3
contact-msg-recv frame as a clamped int8 dBm, 0 when unset
(MyMesh.cpp:550-551). Read it in the parse instead of skipping: gate on
!= 0 (RSSI is always negative for a real RX, so 0 = no data), null for
device-composed outgoing messages.
reserved1 (byte[2]) is untouched — that's #429's outgoing flag; RSSI
lives in byte[3] after the res1/res2 collision fix (#464/#465).
The Message.rssi field, persistence, param-passing, and the (hidden-
while-null) RSSI row on the Packet Path screen were all staged in #438,
so this is just the wire read + 3 gate tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Top-level tabs (Contacts/Channels/Map) no longer auto-imply an AppBar
leading. They build via appBarBuilder(pinned): no leading when the nav
panel is pinned (desktop), the drawer hamburger when transient (mobile).
Detail screens are untouched and keep their working back button.
Removes the unused hideBackButton constructor param (declared, passed
true at quick-switch call sites, never read) and all its call sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Smaz is a client-side text convention with no MeshCore protocol or firmware
support (findings: GH-315). When enabled it compressed ordinary prose into
`s:`+base64, which any non-lineage client renders as garbage — the same
cross-client interop failure as the old `g:<code>` GIF token.
Phase 1 of the Smaz-removal epic (GH-314): stop sending Smaz and remove the
user-facing surface, while KEEPING decode so messages from lineage peers still
on Smaz, and any legacy compressed rows, keep rendering. Decode removal is
deferred to Phase 2 (GH-421).
Removed: the Smaz branch in prepareContact/ChannelOutboundText (Cyr2Lat branch
and the structured-payload guard preserved); connector state/API (the enabled
maps, is*/set*SmazEnabled, ensureContactSmazSettingLoaded, the warm-up and
channel loaders); the per-channel and per-contact toggles plus their
mutual-exclusion; loadSmazEnabled/saveSmazEnabled and the `*_smaz_` key prefixes
(stores kept, they also hold Cyr2Lat); l10n `channels_smazCompression` and the
orphaned `chat_compressOutgoingMessages` across 18 locales (+ regenerated
app_localizations).
Retained for Phase 2: the 5 Smaz.tryDecodePrefixed decode sites and
helpers/smaz.dart.
Safety (verified): no storage migration, the app already persists plaintext
(receive decodes before store; send stores the pre-compression text), so the
`s:` form was wire-only. ACK matching is unaffected, the expected hash derives
from prepare*'s output, so dropping compression keeps both sides hashing
plaintext.
Behavior change: the composer byte-counter now reflects raw size, so anyone who
had Smaz on can type slightly fewer chars per message.
Tests: gif_url_outbound_guard_test rewritten to pin plaintext passthrough and
decode retention. Full suite green, analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_confirmDanger awaited showDialog then called _apply unconditionally; if the
screen was disposed while the dialog was open, _apply's setState would throw.
Guard with && mounted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cache-bust helps federated catalogs on normal servers but does NOT defeat
raw.githubusercontent's CDN (ignores query + no-cache; ~5min TTL, verified).
Comment no longer overclaims. Known issue tracked in #452.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
raw.githubusercontent sits behind a CDN with a multi-minute TTL; a catalog edit
followed by a quick re-import returned the OLD profile (reported: a removed
region still showing as a change). Append a unique query param + no-cache
headers so every catalog/profile fetch is fresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ConfigProfileImportScreen: browse the curated catalog (default OffbandMesh
profiles.json) or enter any source URL (tail-detection routes catalog vs
single profile), then open the #406 preview/apply screen with the fetched
profile. Surfaces skipped-entry counts and fetch/parse errors inline.
Observer settings gains an 'Import config profile' entry that opens it with the
live ObserverConfigService. Completes the observer chain (#404-407); ready for
the #408 hardware test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- config_profile_diff.dart: pure current->new diff builder (add/change,
drops no-ops, flags danger + secret rows). 4 tests.
- splitProfileWrites: partitions writes into safe vs credential/identity for
the two-tier apply; a mixed broker splits, enabled rides the safe half only.
- ConfigProfilePreviewScreen: full sub-screen — reads current state, renders
the diff (amber overwrites, red danger section), normal Apply for plain
config + a separate red gate (with confirm dialog listing exactly which
credential/identity values change) for the danger set. Secrets masked.
Re-diffs after apply for partial-save recovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device-agnostic write enumerator (config_profile_writes.dart): a profile ->
ordered flat writes + per-broker field maps. Skips null/empty (never clobbers),
skips jwt_token (live-minted), holds enabled out for last-write, flags the
danger set (username/password/jwt_owner/jwt_email + wifi.pwd) for #406's gate.
Observer executor (observer_apply_service.dart): flats via setFlat, brokers via
the existing saveBroker (disable-first, fields, enabled LAST, stop-on-error
partial-safe #80); reads current enabled to preserve it when a profile omits it.
Result labels name keys/slots only, never values (no secret leak).
8 enumerator tests. Executor is thin orchestration over the tested service;
end-to-end covered by #408 hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a DM opens the Path screen; it now shows the RF details the app
was already receiving but discarding:
- SNR: captured from the v3 contact-msg-recv frame (was skipBytes(1)).
Firmware sends (int8)(snr_dB*4), so dB = byte/4.0 (MyMesh.cpp:512) —
the old commented-out code multiplied by 4, which was 16x wrong.
- Path type: firmware sends path_len 0xFF for a direct/routed frame and
the hop count for a flood (MyMesh.cpp:545). The app collapsed 0xFF->0,
colliding "direct" with "flood, 0 hops". Capture the distinction into
Message.isFloodRoute so the Path row reads "direct (routed)" vs
"flood, N hops".
- RSSI: row wired to Message.rssi but left null — the wire byte is a
hardcoded reserved 0 today; populated once firmware ships it (#439).
Message gains snr/rssi/isFloodRoute (nullable, additive, persisted like
rxTime). 10 tests cover scaling, the 0xFF discriminator, and copyWith.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini review (standards#145) flagged that a burst of 0x91 pushes (e.g. a
bulk channel edit on the device) would each trigger a full getChannels
re-sync. getChannels already guards concurrent syncs (_isSyncingChannels),
so there is no request flooding, but sequential re-syncs after each completes
would repeatedly clear+repopulate the channel list (UI flicker). Coalesce the
burst with a 400ms debounce so it settles into a single re-sync. Timer is
cancelled in dispose().
Citadel: meshcore-open-p12
Client half of the coordinated firmware+client change (firmware wadamesh#50,
verified against source).
Part A: handle push code 0x91 (pushCodeChannelsChanged) by re-polling
getChannels(force: true), so channels added/removed on the device appear in the
client without a reconnect.
Part B: read reserved1 bit0 of the V3 contact and channel message frames as an
outgoing flag. A message composed on the device (DM or channel) now renders as
sent-by-me (isOutgoing: true) instead of received. The channel self-echo guard
is bypassed for outgoing so device-composed channel messages are not dropped.
Removed the dead upstream hasPath/path-bytes branch in ChannelMessage.fromFrame
(no firmware, stock or wadamesh, appends path bytes to this frame; verified).
Backward-compatible: old firmware sends 0 (received, as today); an old client
ignores the bit.
Citadel: meshcore-open-p12
Gemini adversarial review flagged the getter's clamp: the floor used the
ATT_MTU minimum (23) where it should use the writable minimum (20 = 23-3),
overstating the cap by 3 for tiny MTUs; and an unknown MTU defaulted to
maxFrameSize (172), the wrong direction for a safety cap. Floor at 20 and
default an unknown MTU conservatively. No behaviour change on radios that
grant MTU >= 175 (they hit the maxFrameSize short-circuit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A max-length DM built a 170-byte frame but this BLE link only writes
ATT_MTU-3 (=169) bytes, so writeCharacteristic threw. The DM path swallowed
the throw and never resolved the message, leaving it "active" forever and
silently blocking every later DM to that contact until force-stop+reconnect.
- Size: maxContact/ChannelMessageBytes take an MTU-aware frame budget
(BLE = mtuNow-3, USB/TCP = maxFrameSize); composers pass
connector.effectiveMaxFrameSize. The channel cap now also subtracts the
"Name: " prefix so a small-MTU link can't overflow.
- DM wedge: _sendMessageDirect propagates the failure and the retry callback
is awaited, so a failed send marks the message failed and drains the
per-contact queue. Added a RESP_CODE_SENT safety timeout.
- Channel wedge: sendChannelMessage clears the stuck queue id and marks the
message failed on a failed write.
- Tests: MTU-aware caps + failed-send-does-not-wedge regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini review finding (medium): _handleErrorFrame only failed the caplog
download on RESP_CODE_ERR; control ops (enable/disable/erase) also get
RESP_CODE_ERR when the device is busy (firmware rejects non-STATUS ops while a
stream is in flight), so their completers hung 5s then threw a misleading
TimeoutException. Now fail the pending ack/status completers fast with
CaplogBusyException. analyze clean; full suite 643 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the received CHUNK-frame count to CaplogTruncatedException and the
on-screen error ("received X of Y bytes in N chunks"). This diagnostic
pinpointed the near-full BLE truncation: 94 chunks (all frames arrived) but
each full frame 3 bytes short = BLE MTU clipping 176-byte caplog frames to
173. Firmware chunk-size cap for BLE tracked with TopazHill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From Ben's testing:
- The buffer readout went stale while idle (STATUS poll only ran during
capture). Split the poll from the elapsed tick: the STATUS poll now runs
continuously while the screen is on a connected caplog device (stops only on
disconnect/dispose); the 1s elapsed tick stays capture-only.
- An empty download now shows "Buffer is empty - nothing to download" instead
of saving a 0-byte file.
analyze clean; full suite 643 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame decoder capped companion frames at 172, but the firmware's real
MAX_FRAME_SIZE is 176 (BaseSerialInterface.h, "+4 for transport codes").
Full-size caplog CHUNK frames (176 B) were silently rejected; only the final
sub-172 partial chunk survived, so a download reassembled just the last chunk
(e.g. "received 95 of 5141"). Caplog is the first feature to use full frames,
so nothing exposed this before.
- usb_serial_frame_codec.dart: usbSerialMaxPayloadLength 172 -> 176.
- serial_capture_screen.dart: erase-on-start (Start / Start&Reboot) for a clean
session ("didn't start at 0").
- 3 decoder regression tests; 113 tests total green; analyze clean.
Root cause confirmed with firmware (TopazHill): firmware streams the full
buffer correctly (wire trace 5175/5175); the client decoder dropped oversized
chunks. My earlier firmware-ring hypothesis was wrong.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the reboot-test failure (support latched "unsupported" after the
reconnect window) and reworks the boot-log UX per Ben.
- meshcore_protocol.dart: offbandCapCaplog (0x20) + firmwareSupportsOffbandCaplog
(0x20 bit AND FIRMWARE_VER_CODE >= 17), mirroring the block-cap pattern.
- meshcore_connector.dart: supportsOffbandCaplog getter.
- serial_capture_screen.dart: gate support on the static cap bit (reactive via
the connector), not a one-shot STATUS probe, so it never latches "unsupported"
after a reboot. Derive capturing state from device STATUS so an auto-resumed
capture (post firmware #428) shows STOP not START. Cancel timers on disconnect,
re-query STATUS on reconnect. New red "Start & Reboot" (no timer) boot-log flow.
- 4 cap-gate unit tests; 18 caplog tests total green; analyze clean.
Root cause confirmed with firmware (TopazHill): caplog cap bit (0x20) is
advertised statically across reboots; the client's probe raced the reconnect
window and latched. Firmware #428 (persist flag + boot capture) is the other half.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Confirmation-gated Reboot action on the serial-capture screen so an operator
can enable capture, reboot the radio, and record the boot log for retrieval
(the #428 boot-log flow). Available while capturing; uses the existing
connector.rebootDevice(). Full boot-log capture needs firmware retained-enable
(#428); the client affordance lands now.
flutter analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2b UI. SerialCaptureScreen drives the 0xC4 caplog control + download
from the prior commits: probe support (STATUS) on open, start/stop capture
(default 5-min window or until stopped) with live elapsed + auto-stop +
buffer fill, download to a file handed to LogExport.shareFile, and erase.
Reached from Settings > Debug.
English-only strings for now (localization follow-up, mirrors LogExport
#427); capture duration is screen-local (persisting it is a small follow-up).
flutter analyze clean on all touched files; 14 connector unit tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the 0xC4 caplog protocol with the companion control sub-codes the
firmware exposes (the companion has no CLI; #395 CLI verbs are repeater-
only). Firmware #417/#408.
- meshcore_protocol.dart: request sub-codes ENABLE/DISABLE/ERASE/STATUS +
builders; ACK / STATUS parsers (CaplogAck, CaplogDeviceStatus).
- meshcore_connector.dart: setDeviceCaplogEnabled / eraseDeviceCaplog /
getDeviceCaplogStatus; 0xC4 response routing split so ACK (0x10) and
STATUS (0x11) dispatch to own completers, download stream unchanged.
- 5 unit tests for builders + parsers; analyze clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 1 of the client half of serial-capture (#430): the protocol layer
to download the device's serial-capture buffer over the companion link.
- meshcore_protocol.dart: cmdOffbandCaplog / respCodeOffbandCaplog = 0xC4
(NOT 0xC3, which collides with cmdOffbandFemLna; see firmware #406),
START/CHUNK/END sub-codes + request builder.
- caplog_reassembler.dart: pure START/CHUNK*/END reassembly state machine
with truncation detection, unit-tested in isolation.
- meshcore_connector.dart: downloadCaplog() + 0xC4 frame dispatch + fast
busy-reject on RESP_CODE_ERR while awaiting START.
- 9 unit tests passing; flutter analyze clean.
Integration test gated on the firmware 0xC4 fix merging. Not pushed
(human-test gate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- LogExport.shareLogs/shareFile now resolve ScaffoldMessenger and l10n strings
off the context BEFORE the flush await, and _exportFile takes them as values
(no BuildContext use across the async gap; avoids a deactivated-ancestor crash
on Back-during-flush).
- Web download: append the anchor to the document before click() (Firefox needs
a connected anchor) and revoke the object URL on a delay (synchronous revoke
can abort the download in Safari/iOS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The share helper only invoked the OS share sheet on mobile; on all desktop it
opened the containing folder, which for a temp-dir capture dumped the user into
TEMP amid unrelated files. Now:
- Android/iOS: OS share sheet (unchanged).
- Windows/macOS/Linux: native Save As dialog (file_selector) writing the file
to a user-chosen location.
- Web: browser download of the log text (no on-disk file on web).
Adds file_selector; web download via a js_interop helper behind a conditional
import. Folds in #427 (localized share strings). LogExport.shareFile keeps a
compatible signature for the serial-capture screen (#430).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route LogExport's Share tooltip, share subject, and file-logging-unavailable
snackbar through context.l10n instead of hardcoded English. Adds four keys to
app_en.arb (debugLog_shareLog, debugLog_openLogsFolder, debugLog_shareSubject,
debugLog_fileLoggingUnavailable) and regenerates all locales (English fallback
until translated). Resolves the deferred Gemini finding from #393.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the BLE screen's on-disk log export into lib/utils/log_export.dart and
reuse it on the App-log screen, so both log screens have one consistent Share
action: OS share sheet on mobile, open logs folder on desktop. The shared file
already holds the app log and BLE frames combined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address 3 review findings (all confirmed real): thread section context through
the optional-field accessors so nested errors name the broker (brokers[i]."port"),
reject broker ports outside 1..65535, and reject negative integer fields
(status_interval, jwt_refresh) via a shared _optUint. +3 tests (14 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the yaml dependency and parseConfigProfile(): YAML -> ConfigProfile
(#402). Strict by design since profiles are untrusted input (#139) — unknown
keys, wrong types, out-of-range/duplicate broker slots, and unknown
transport/auth values all throw ConfigProfileFormatException with a
user-facing message. Only keys present populate the model. 11 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Typed model for importable device config profiles (feature #136): WifiConfig,
BrokerConfig (6 slots), region/status-interval, and a ConfigProfile container.
All fields nullable so apply engines write only the keys a profile sets.
Key names + wire encoding mirror the firmware ConfigSchema (transport/auth as
string names, wifi.pwd write-only). ConfigKeys centralizes the key strings so
the parser (#403) and per-device apply engines never hard-code them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No in-app signal of which binary is running has caused repeated confusion
(a debug-signed APK silently not installing over the release app, old layout
persisting with nothing to indicate the new build never landed).
BuildInfo reads GIT_SHA/GIT_BRANCH/BUILD_TIME from --dart-define with dev
fallbacks, so every build carries its own identity independent of the pubspec
version. Surfaced as its own copyable Build row in Device Info.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stores are keyed by the first 10 hex of the connected radio's public key, so
connecting a different radio silently swaps which contacts, channels, and
history you are viewing with nothing in the UI saying so. Device Info showed
the full public key but never tied it to storage.
Adds a Data scope row with the key actually in effect, plus a one-line
explanation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wadamesh keys its history watermark (last_delivered_seq) by client_id. We
sent none, so we shared the empty-string slot with every other MeshCore
client on the machine: whichever connected first drained the device history
ring and the next app got NO_MORE_MESSAGES for frames it never received.
cid_len is 6 by necessity, not preference. Stock reads cmd_frame[1..7] as
reserved with the app name at a fixed offset 8; Wadamesh reads the name at
2 + cid_len. Only 6 puts the name at 8 on both, so one frame serves both
firmwares with no firmware change. Covered by app_start_frame_test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Groups the list by kind: configuration (Node, Radio, Radio Stats, Privacy,
Contacts, Blocked, Messages, Observer, App Settings), then readout (Device
Info), then rarely-used Actions, then Debug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Advertisement Notifications" reads as ads. Retitled to "New node discovered"
/ "Notify when new repeaters or contacts are heard" across all 18 locales so
the overnight-ping setting is findable without mesh jargon.
Coverage verified, no gap found: showAdvertNotification is the only discovery
notification entry point and all three of its callers
(meshcore_connector.dart:4961, 5047, 7226) are guarded by
notificationsEnabled && notifyOnNewAdvert && !isBlocked. The batch summary is
fed solely from enqueued adverts, so it cannot fire with the toggle off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts the #262 notify-mode selector out of channels_screen into a shared
widgets/channel_notify_mode.dart (key, icon, label, dialog) and points both
entry points at it, so the Channels list and the in-channel menu cannot drift.
Same PSK-keyed storage; no new settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a KeepScreenAwake controller that holds a wakelock_plus lock while the
setting is on and the app is foregrounded, releasing on background, on toggle
off, and on dispose. Toggle lives in App Settings > Battery (power tradeoff),
defaults OFF, persisted as keep_screen_awake. Strings added for all 18 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The App Settings pane carried a Debug card holding only the app-debug-logging
switch, while the Debug category already owned the log viewers. Moved the
switch next to the App debug log viewer it controls and dropped the now-empty
Debug card from App Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notification settings moved to the Messages category, but the App Settings
tile subtitle still advertised them. Retitled across all 18 locales to match
what the pane actually renders (appearance, translation, battery, map, Cyr2Lat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review caught a data-loss defect in the reactor-name
fallback. For a reaction in a room from an author not in the local
contacts, _resolveContactSenderName returns null and the chain fell
through to reactionContact?.name. But reactionContact is the room server,
not the reactor, so every unknown-author reaction was attributed to the
room's display name. Two distinct unknown authors then both resolved to
that one name, so applyReaction's per-reactor dedup treated the second as
a duplicate and dropped it, count and all.
Fall back to the per-author hex (from the frame's own fourByteRoomContact
Key) instead, which is unique per reactor. In a true 1:1 that hex is
empty and the contact genuinely is the reactor, so its name stays the
correct fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>