Repository navigation
feat(recorder): round 5 core — Live Activity widget, STT model registry, whisper eviction fix - #619
Conversation
On-device STT engine test-bed dependency (cactus parakeet/moonshine/whisper). Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
A local or BrowserStack build run drops the artifacts in the repo root, where they were untracked but NOT ignored - one `git add .` away from being committed into a .git that is already about 9 GB. The APK from the last run was 803 MB and the IPA 75 MB. Also ignores the two large sherpa binaries that are placed at build time rather than tracked. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
… stay Three packaging changes and two notes that exist to stop someone undoing them. - arm64-only: drops the x86, x86_64 and armeabi-v7a copies of every native lib, including those from dependency AARs that ndk.abiFilters cannot reach. Saves roughly 255 MB. Every flagship and every BrowserStack real device is arm64-v8a. NOTE: this strips x86 from debug too, so x86 EMULATORS will not run this build - use a real arm64 device, or comment the excludes out for emulator work. - pickFirst on libc++_shared.so, shipped by about nine native modules (reanimated, llama.rn, whisper.rn, audio-api, executorch, gesture-handler, screens, worklets, op-sqlite). Resolves the duplicate at merge time. - noCompress 'zip' for the bundled Cactus STT model zips: already compressed, so storing them keeps the build fast and lets unzipAssets read them directly. The two notes are the point of this commit as much as the code. Do NOT re-add an exclude for com.microsoft.onnxruntime:onnxruntime-android - it existed only while the -qnn AAR (a superset) was on the classpath for the dropped Whisper-NPU PoC. With that gone this artifact is the ONLY thing providing ai.onnxruntime.*, which pro's SileroVad needs for the recorder's live VAD gate. Excluding it fails at RUNTIME with NoClassDefFoundError, not at build time - which is exactly the kind of thing a future cleanup pass would delete confidently. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
…al one Testing the recorder meant replacing the build already on the device, so the tester lost whatever state they had. `assembleReleaseLocket` produces a release-configured build - JS bundled, minified, production-like - with an `.locket` applicationId suffix and its own launcher name, so it sits next to ai.offgridmobile instead of over it. The launcher label goes through a manifest placeholder rather than a second strings resource: the default keeps the real name, and only this build type overrides it. Falls back to the debug signing config when no release keystore is present, so it builds on a machine without the signing secrets. npm scripts for build / install / launch, since the gradle task name and the suffixed applicationId are both easy to get wrong by hand. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
A recording now shows on the Lock Screen (and the Dynamic Island on 14 Pro and up) with an elapsed clock and a Stop button that stops the recorder WITHOUT opening the app. Plus a Home/Lock Screen tile that reflects real state: emerald circle idle, red square recording, tapping it starts or stops in the background. Why this Swift lives in core rather than pro/ios: the bridge has to construct Activity<RecorderActivityAttributes>, and that type must be a member of the widget extension target. A CocoaPods module cannot join an app extension without dragging React in. The pro side still owns every product decision - when to start, what the status line says. This is a deliberate placement, not an oversight. The three pieces must ship together and they do: project.pbxproj references the files, the entitlements declare the group.ai.offgridmobile App Group that RecorderSnapshot writes through, and Info.plist declares NSSupportsLiveActivities. Any one without the others is a broken iOS build. Two findings recorded in the code because they cost real time: - A plain AppIntent with openAppWhenRun=false runs in the WIDGET EXTENSION's process, so its NotificationCenter post never reaches the app - tested on device, completely silent, no error. StopRecordingIntent works only because LiveActivityIntent is documented to run in the app's process. The comment in StartRecordingIntent.swift exists so the next person does not retry the flag. - The notification NAME is the cross-target contract. The intents are here in the app target; the observer is in pro's ContinuousRecorderModule, which cannot import app-target Swift. If the two strings drift, the buttons silently do nothing. RecorderLiveActivityPreviews.swift is wholly inside #if DEBUG - preview scaffolding, ships nothing. The NSLog calls are permanent: NSLog was the only way to observe ActivityKit, since it does not reach offgrid-debug.log. Android parity is a declared gap, not a missing implementation: these are iOS-only native modules and the JS services guard on Platform.OS, following the existing recordingNotification pattern. Device-verified on an iPhone XS: started, Stop from the Lock Screen ended the recording without opening the app, tile state flipped. Dynamic Island layouts are NOT verified - an XS has no pill; they render in the Xcode canvas only. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
…sh script On a physical iPhone, Metro would not connect. The cause is that iOS refuses to present the Local Network permission alert during didFinishLaunchingWithOptions - which is exactly when React Native issues its packager probe. So the probe fails silently, the user is never asked, and the bundler looks unreachable. warmUpPackagerLocalNetwork re-issues the probe once a foreground UI exists, which is when iOS will actually show the prompt. Dev + iOS only. scripts/ios-refresh-js.sh rebuilds just the JS bundle into an installed app, so iterating on JS does not need a full Xcode build. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Follows the dependency additions: Cactus + react-native-nitro-modules, and ZIPFoundation for reading the bundled model zips. Also picks up the OffgridPro pod's own change. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
The store kept the last 500 lines by copying the whole array on every append - so every logger call allocated a 500-element array. With the state-machine traces on, that is thousands of copies a minute, on the JS thread. It now appends in place and trims only when the cap is exceeded. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
…udio boundary Adding cactus-react-native broke every core test that imports @offgrid/pro: requiring it throws "Failed to get NitroModules" at import time, so the suites crashed on collection rather than failing a test. jest.setup mocks nitro-modules and cactus-react-native, and jest.config adds cactus-react-native to transformIgnorePatterns. Both are needed - half of it still crashes. These are stubs for external npm packages, not for our own code, so they are not a mockist test and not a pro-into-core leak. nativeBoundary gains ggml Silero VAD scripting and an AudioNormalizer fake (WAV slice/concat/compress), plus a transcribe hold/release so a test can hold a transcription open and assert what happens meanwhile. Fakes at the device boundary, which is the only place we put them. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
The Models screen and the Download Manager only knew about whisper. Anything else that transcribes - pro's Parakeet - had its own private download path, so cancel, retry and delete were routed to whisper instead, and its progress never appeared in the shared UI. sttModelRegistry is the seam: a model registers itself with an id, a size, a download and a delete, and the app-wide surfaces treat it like any other. Pro registers itself at activation, exactly as ttsProvider already does, so CORE STILL NEVER IMPORTS PRO. Adding a third engine needs no change here. sttProvider routes by owner rather than assuming whisper, which is what fixes cancel/retry/delete acting on the wrong model. TranscriptionModelsTab renders registered models alongside the whisper sizes. The new sttModelPrompt slot lets a service raise the download consent sheet from the app root. appRoot already holds the TTS engine bridge and a slot maps to one component, hence a second slot rather than overloading it. Its pro half is already committed there; the submodule pointer must not move before this is in. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
An unattended summary that quietly falls back to the on-device model is a jetsam risk on iOS: the recorder's mic assertion keeps the app alive, so a background generation holds around a gigabyte and the OS kills the process. The manual path is fine, because the user is watching it. `remoteOnly` makes that explicit. When set, a remote failure rethrows instead of retrying locally, and reaching the on-device path at all throws rather than silently running. The flag threads through every chunk and the combine pass, so a long transcript cannot fall back partway. Default is unchanged, so every existing caller behaves exactly as before. This is the core half of pro's remote-aware auto analysis. Both must be in before the submodule pointer moves. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
An iOS memory warning evicts the least-recently-used model. If that was whisper while a file transcription was running, unloadModel cancelled the job to avoid a use-after-free - whisper.rn reports the cancellation as `Code: -999` - and the clip surfaced as "Failed to transcribe" through no fault of its own. The model was loaded FOR that job. isFileTranscribing lets model residency veto the eviction instead, so the warning frees something else. Ships with the rendered test that fails without it. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
A background consumer of the single whisper context needs to yield to a foreground one - locket's queue pausing for voice mode. subscribeRealtime is that signal. Watching isCurrentlyTranscribing() would be wrong: it is also true while a background FILE transcription runs, so a listener would pause itself. This tracks the realtime session specifically (stopFn), which is what makes it safe to react to. Mirrors generationService.subscribe and fires immediately with the current value, so a late subscriber is never out of step. A throwing listener is swallowed - nothing a listener does may break transcription. Pairs with pro's foregroundYield, already committed there. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Two repo-scoped agents: - pro-feature: keeps a Pro change inside the pro/ submodule and out of the public core repo, which is the rule most easily broken by accident. - device-verifier: build, install, pull the debug log, grep the state-machine traces. Runs one at a time, since there is one device. Also ignores skills-lock.json - local machine state, not project config. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Moves the pointer from 6b91f7b7 to 11a6db0, picking up 76 pro commits: the recorder becoming a projection of native truth, Parakeet as a managed STT model, speaker identification, the day feed rework, and the autonomous processing loop. Deliberately last. Three of those pro commits import core APIs added in this same branch - the STT model registry, the sttModelPrompt slot, remoteOnly summarization, and whisper's realtime subscription. Verified before bumping: all 60 core symbols pro HEAD imports resolve against core HEAD. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
The comment claimed "pro/ ships its own suite run in the pro repo's CI". It does not. Pro's CI checks out core and runs THIS config, which ignores <rootDir>/pro/ - so nothing under pro/__tests__ has ever run there. That sentence is why eight VAD-declutter integration tests were moved out of core into pro (pro@87dd505, "de-leak from core") and silently stopped running. The comment now says where pro's suite actually runs, and warns that moving a test into pro/ takes it out of CI until pro's workflow invokes pro/jest.config.js. Comment only - no behaviour change. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Picks up pro@26d88b5, which adds pro/jest.config.js so pro's own 13 suites can run at all. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
The test-only dynamic-import transform was scoped to pro/locket/screens/, so
`await import()` inside pro/locket/stores/recordingsStore.analyseDay still threw
"A dynamic import callback was invoked without --experimental-vm-modules" and took
down any rendered test that touched Analyse.
Widened to pro/locket/(screens|stores)/ - still narrow, for the reason the
original comment gives: a GLOBAL transform changes behaviour elsewhere and broke
loadProFeatures' `await import('@offgrid/pro')`.
Removes one whole error class. locketProcessingCard and locketSheetBusyGate were
each failing on two distinct errors; now only the pre-existing stale
`today-switcher` expectation remains, which is a test to update, not a crash.
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Four suites that mount the day feed. Two are green again; two are improved but still red, and the remaining cause is named below rather than left to be rediscovered. Three causes, all found by probing locketProcessingCard: 1. They navigated through a chooser that no longer exists - press `today-switcher`, wait for `switcher-recordings`, press it. pro@f8de024 deleted that pill when the feed became one timeline, so there is no step to perform. Removed. 2. No transcription model was seeded, so the feed rendered the first-run setup card instead of the timeline (LocketFeedScreen gates on `!!downloadedModelId`). 3. autoAnalyse now defaults ON. With an un-analysed clip on today it fires on mount, fails with 'no-model' because no LLM is seeded, and useLocketFeed routes to LocketTranscriptionSetup - which leaves the feed mounted but under aria-hidden, so the clip is in the tree and unreachable by query. That default flip is from this branch, so this one is ours. Both settings are now pinned in the fixtures rather than inherited: a test about card styling should not change meaning because a product default moved. locketProcessingCard also asserted a green left border (borderLeftWidth 3) that e60e565 replaced with a faint primary-tinted background, so that assertion never matched the code it was checking. Still red: locketAnalysisSync (5 cases) and locketRepairState (2 cases, a 10s waitFor hang with no missing-element error - a different failure class). Both are strictly closer than they were; neither was passing before this commit. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
The eight rendered locket tests now live in pro/__tests__/integration/, where the feature they describe lives and where pro's own runner executes them. They were tracked here, and this repo is public: they walked through the feed card states, the pipeline percent, the busy gate, speaker turns and repair progress of an unshipped paid feature, screen by screen. Deleted here, added there in the same submodule bump. Around 40 pro-owned suites remain under __tests__/pro/ - the audio and MCP sets, covering features that have already shipped. Those are a separate migration and lower stakes. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Removing 179 KB of real speaker embeddings from pro's history rewrote every commit that carried them, so the SHA this pointer held no longer exists. Points at pro's rebuilt tip. The fixture was 48 genuine 512-d voiceprints from a real conversation, including the enrolled user's. A deletion commit would have left the blob in history and still pushed it, so it was removed from every commit instead - the object is gone from the repository. The clustering test now generates its vectors. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
|
ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
One conflict, in ios/OffgridMobile.xcodeproj/project.pbxproj: the beta release d032056 stamped CURRENT_PROJECT_VERSION 1784286784 in the same region where this branch added the widget extension target. Took the release's build number - a release stamp should win over our bump - and kept the widget target intact (24 OffgridRecorderWidget references still present, plutil clean). Also aligned the widget extension's own CURRENT_PROJECT_VERSION, which did not conflict and so was left at the older 1784267770. App Store Connect rejects a submission whose extension CFBundleVersion differs from the app's, so all four build configs now read 1784286784. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Resume was fully implemented and unreachable. `WorkerDownload.kt:127` already
measures the bytes at `destination` and sends `Range: bytes=$existingBytes-`, but
the staging path was `"${randomUUID}_$fileName"` - a fresh UUID per attempt. So a
retry never found the previous partial, the measured length was always 0, and every
attempt restarted from byte zero. A 652 MB Parakeet encoder failing at 600 MB threw
away all 600 MB.
The staging name is now derived from the URL, which buys both properties at once:
- the SAME url resolves to the same staging file, so a retry finds the partial
and resumes;
- a DIFFERENT url resolves elsewhere, so a partial from an earlier model version
is never resumed into. That is a real case, not a hypothetical - Parakeet v2 to
v3 changed the URL and kept the filename.
The filename stays on as a readable suffix, sanitised because it reaches the
filesystem.
Five tests cover it. Verified with `./gradlew :app:testDebugUnitTest --rerun-tasks`
(the task reports UP-TO-DATE otherwise and runs nothing): BUILD SUCCESSFUL.
Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
A model shipped as several loose files declares `aggregated: true` in its transfer metadata and drives ONE user-visible row itself. Launch hydration did not know that, so after a restart every part came back as its own Download Manager entry: four rows titled by filename, quantization "Unknown", each with its own byte total, sitting beside the aggregate row that is supposed to be the single source of truth. `isAggregatedPart()` filters them in `getParentRows`, beside the existing mmproj-sidecar exclusion. Generalised rather than keyed to one model, because "several native transfers, one user-visible model" is not specific to Parakeet. Unparseable metadata is not treated as a reason to hide a download. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
The scale had nothing between `body` (14) and `h2` (16, the screen-title token), so every list row sat at 14 whether it was a settings toggle or a paragraph someone actually reads - and reaching for `h2` would have given list rows the weight of a heading. `bodyLarge` is 15/21 for reading text: follow-ups, key points, the rows you read rather than scan past. The lineHeight matters more than the size here, because the body tokens carry none, and that is what made those lists feel cramped. Lands in core ahead of the pro screens that use it - they do not typecheck until the token exists. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Pro code was never typechecked on push. The root `tsconfig` EXCLUDES `pro/**` - correctly, because the public repo's CI does not check the submodule out - so the existing check only ever saw a pro file when a core file imported one. Pro screens are reached through the registry at runtime, not by an import, so every pro-only screen, hook and service was invisible to the gate. Two real type errors were sitting in the tree because of it. Pro has its own tsconfig, so run that too when the submodule is actually present. Guarded on `[ -f pro/tsconfig.json ]`, so a checkout without the submodule is unaffected. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Renaming a source left its knowledge-base document under the old title, so search hits and citations kept naming something the user had already renamed. A title change does not touch the chunks or their embeddings - only the display name has to follow. `renameDocumentByPath` updates that one column, found by the document's path, and no-ops when there is no such document. Re-embedding an hour-long transcript to change a title would be absurd. Core-only, no pro dependency. It lands ahead of the pro caller that needs it. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
`probe()` returned a bare boolean, so every failure looked identical - a denied iOS Local Network permission, a refused connection, a wrong probe path, and a host that simply is not there all collapsed to `false`. That is what made "the scan finds nothing" undiagnosable. It now reports a failure class alongside the latency, which separates those cases: a timeout at the full budget means nothing answered (or our own JS thread was too busy to service the socket), while a fast refusal means something is there and saying no. The scan's log line lifts two nested template literals into locals - same output, and it satisfies the lint rule that blocks the commit otherwise. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
The `<domain-config>` listing localhost, 127.0.0.1 and 10.0.2.2 was redundant - the base-config already permits cleartext to every host, so Metro and LocalDream keep working without it. It was also harmful. The mere PRESENCE of any `<domain-config>` puts Android into strict hostname-aware TrustManager mode app-wide, which broke the WhisperKit SDK's model download because its ktor TLS validation is not hostname-aware. Removing the block is behaviour-neutral for cleartext and lets non-hostname-aware clients connect. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
`isFileTranscribing` is part of the eviction contract, and these cases are about scenarios where no file transcription is in flight - so the fake states that explicitly rather than leaving the veto undefined and letting the outcome depend on a default. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
…it natively The native RAM heuristic was a redundant second guard, and it mis-fired. The UI slider already caps the budget to a per-device ceiling (12K on <=8GB RAM, 32K above) and warns past a safe threshold, and the JS load path refuses a load up front when the model will not fit free RAM - the same contract the llama.rn path runs under. On top of that, the native clamp crushed valid budgets to its 1024 floor on ordinary devices - an 8GB phone with a 3GB model, for instance - so a direct question or an attached transcript overflowed a context far smaller than the one the user had asked for. The configured budget is reported back to JS so compaction thresholds and the context-usage bar read the real value rather than the requested one. `LiteRTTokenBudgetTest` goes with it: it tested only the removed clamp, and nothing else references `clampTokenBudget`, `TOKEN_BUDGET_HEADROOM_MB` or `KV_MB_PER_TOKEN`. Verified `./gradlew :app:compileReleaseKotlin` clean. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Points core at pro's `feat/recorder-v3`: speaker roster and the picker sheet, one identify path, the parakeet download surface, share-as-text, the recorder tile, the liveness heartbeat, progressive transcription, and an interruptible clean-up. Gated before bumping, as in the previous round: every `@offgrid/core` symbol pro HEAD imports was checked against core HEAD - 167 locket files, zero missing. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
Picks up the fix that wires `buildSpeechWindows` into the transcribe path. The helper had landed without its caller, so whisper was still tiling the whole file and the perf change was inert. Co-Authored-By: Dishit Karia <hanmadishit74@gmail.com>
feat(recorder): round 6 core — download resume, LiteRT token budget, TLS fix, pro typecheck gate
|



Core half of recorder round 5, on top of
fix/locket-round-3. 26 commits.Pairs with off-grid-ai/mobile-pro#44 — three commits here exist only to satisfy pro's half,
and the submodule bump is deliberately last.
iOS Live Activity + recorder tile (widget extension)
A recording shows on the Lock Screen and Dynamic Island with an elapsed clock and a Stop button
that ends it without opening the app; the Home Screen tile flips emerald/red and starts or
stops in the background.
The ActivityKit Swift is here rather than in
pro/ios/on purpose: the bridge must constructActivity<RecorderActivityAttributes>, and that type has to be a member of the widget extensiontarget — a CocoaPods module cannot join an app extension without pulling React in. The pro side
still owns every product decision. The
project.pbxproj, the App Group entitlement andNSSupportsLiveActivitiesmust land together or the iOS build is broken.Device-verified on an iPhone XS: Stop from the Lock Screen ends the recording without the app
opening. Dynamic Island layouts are not verified — an XS has no pill.
An STT model registry, so a pro model can be app-managed
The Models screen and Download Manager only knew about whisper, so Parakeet's cancel/retry/delete
were routed to whisper and its progress never appeared. A model now registers itself with an id,
size, download and delete; pro registers itself exactly as
ttsProvideralready does, so corestill never imports pro. Adding a third engine needs no change here.
Three fixes worth reading on their own
that was whisper while a file transcription ran, the job was cancelled to avoid a
use-after-free — whisper.rn reports it as
Code: -999— and the clip surfaced as "Failed totranscribe" through no fault of its own. The model was loaded for that job.
remoteOnlysummarization. An unattended summary that quietly falls back to on-device is ajetsam risk while the mic assertion keeps the app alive. It now runs remote or fails.
show the permission alert during
didFinishLaunchingWithOptions, which is exactly when RNissues its packager probe. Re-issued once a foreground UI exists.
Android build
assembleReleaseLocketproduces a production-like build with a.locketapplicationId suffix soit installs alongside the normal app instead of replacing it. Plus arm64-only packaging
(~255 MB saved) and a note recording why
onnxruntime-androidmust not be excluded — removing itfails at runtime with
NoClassDefFoundError, not at build time, which is exactly what a futurecleanup would delete confidently.
Tests
15 pro-owned locket suites were removed from this public repo and moved into pro, where the
feature lives and where pro's new runner executes them. They described an unshipped paid feature
screen by screen. Around 32 pro-owned suites remain here (audio and MCP — shipped features); that
is a separate migration.
babel.config.js's test-only dynamic-import transform was widened frompro/locket/screens/toalso cover
stores/—await import()inrecordingsStore.analyseDaywas throwing and takingdown any rendered test that touched Analyse. Still narrow, for the reason the original comment
gives.
Deliberately NOT included
Four release landmines are held out of this branch and remain uncommitted locally:
App.tsxanddebugLogFile.tsun-gated from__DEV__,SettingsScreen.tsx's dev button groupexposed in release, and
DEV_UNLOCK_PRO = truewhich defeats the paywall. Also held:LiteRTModule.kt(unclaimed, and it deletes a test) andnetwork_security_config.xml.Ordering
Pro must merge first — the submodule bump at the end of this branch points at pro#44's tip.