Skip to content

feat: add EleBook offline individual browser - #33

Merged
JasonWildMe merged 5 commits into
WildMeOrg:wildlife-reidfrom
BMichaelJ:feat/pack-local-browser
Sep 9, 2026
Merged

feat: add EleBook offline individual browser#33
JasonWildMe merged 5 commits into
WildMeOrg:wildlife-reidfrom
BMichaelJ:feat/pack-local-browser

Conversation

@BMichaelJ

@BMichaelJ BMichaelJ commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add a pack-local, offline individual browser to EleBook. This is a scoped new feature, not full PWA parity.

  • Browse a selected ready pack with thumbnails, name/ID/available-alias search, deterministic name sorting, and supported sex filters.
  • Read-only details show populated allow-listed pack fields and up to three available reference images, separately from locally reviewed source photos and detection crops.
  • Local evidence requires an approved decision, exact stable individual ID, and exact recorded project ID. Candidates, scores, upload receipts, and provisional FIELD identities are not authoritative links.
  • New single-pack captures retain project provenance in the existing SQLite field. Ambiguous legacy and grouped multi-pack provenance remains unlinked, without migration or inference changes.
  • Add full-screen inspection, zoom/pan, predictable back navigation, and scoped entry points from pack, review, and observation contexts.
  • Cancel obsolete loads; invalidate profiles/images after pack replacement/removal and review corrections. Reuse canonical pack containment and restrict local photos to private observation storage.

No backend endpoint, model, pack publishing, profile editing, location history, PWA enrichment, or WhiskerBook reconciliation is added.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Refactor
  • Chore

Screenshots / Screen Recordings

Android

Signed field.3 screenshots from the Pixel 9a. Device chrome, pack labels, names, and IDs are redacted. These are attachments, not files committed to the repository.

Before After
Previous release home Offline search retains duplicate-name records
Catalog Pack detail Local evidence
Offline individual catalog Read-only detail and three pack references Separate locally reviewed source photo and crop

iOS

The CI native suite passed 45 tests on an iOS simulator. The new browser UI and a physical iPhone have not been field-validated; simulator unit results do not establish field parity.

Checklist

General

  • Code follows repository conventions and existing React Native/Feather/theme patterns
  • Self-review completed
  • Essential architecture documentation updated
  • No new ESLint errors or warnings; two unchanged warnings remain

Testing

  • Physical Android: Pixel 9a, final signed release APK
  • iOS native unit suite on CI simulator (45 tests)
  • iOS browser UI and physical-device field validation
  • Light and dark modes inspected on Android
  • Full npm test including iOS on macOS CI
  • Full Jest, TypeScript, ESLint, Android tests, and Android lint passed separately
  • Behavior-focused unit, screen, and actual-navigation-stack tests added

React Native Specific

  • No new native module or native dependency
  • Uses existing spacing, typography, and themed style patterns
  • Virtualized catalog and local evidence lists with stable keys
  • Metadata-only browsing; no embedding binary or inference initialization
  • Gestures validated on both platforms (Android zoom/pan checked; pinch/iOS remain open)

Performance & Models

  • Loading, missing/quarantined pack, error, missing image, and unavailable-individual states
  • Canonical filesystem containment; no unsafe reference fallback
  • No model, APK, field dataset, or local configuration committed

Security

  • No secrets, signing material, tokens, or deployment-specific settings in Git
  • Local image and pack paths validated
  • Stable-ID/project joins; no precise GPS/history exposure in the browser

Related Issues

New PR against wildlife-reid, based on 83c80149325e2670eb1bfae494db23a116da3d4b. PR #32 was merged by JasonWildMe and is not reused.

Additional Notes

Review follow-up (9 September)

Jason/Astra's catalog-scroll finding is addressed in 7909eeb. A same-pack return refresh preserves the ready catalog and its mounted FlatList; pack identity changes and refresh errors still hide stale data. The actual-navigation regression asserts that the same list instance survives a delayed refresh and covers both success and failure.

Local commit gates passed: TypeScript, lint, and 1,008 tests across 67 Jest suites. Follow-up CI is green on attempt 2: typecheck, lint, test, and Android debug/release builds passed. The first test-job attempt passed Jest and Android tests, then reported an unexpected iOS test-process exit during the existing DownloadManagerModuleTests.testCompletedDownloadEntryPersistsUntilMoved test. Retrying only that failed job succeeded with no source or native-test changes. Reply to Jason.

Field.4 tester build

The field.4 version-only commit 919c516de9a1e207966a07dc93f99d9555a2bd76 is pushed and is now the PR head. It matches the source used for the prepared signed 0.1.0-field.4 / 1787551545 ARM64 APK, which includes the scroll fix. Version-commit CI passed all four jobs on the first attempt: typecheck, lint, test, and android-build.

APK SHA-256: 6b202a4e2ee361a4689f724cdadee78531a2728e7148585516801d6c52dfe9ed. Signature/certificate compatibility, version, architecture, non-debuggable status, alignment, bundled JavaScript/fixed hook, and approved deployment configuration were verified. The APK, checksum, TXT guide, and ZIP remain local and are not distributed.

Field.4 has not been installed or device-tested. The field.3 bundle and historical device evidence below remain unchanged; field.3 does not contain 7909eeb.

Existing tester APK

APK/device-tested commit: 3fec5243ca74de9265130b2eea9081156ace1b73.

Signed APK: 0.1.0-field.3 / 1787551544, org.ganesha.elebook, ARM64, non-debuggable, bundled JavaScript; signing certificate matches field.2.

APK SHA-256: 21f2a737b7abe1522d66f27e7ea5af2a7efee4ae8dfdc16fd2eeee1cdb0ee22f.

Validation of the existing field.3 APK commit:

  • npm ci: all three version-pinned patch-package patches applied.
  • npm run typecheck: passed with explicit configuration; commit hook also passed.
  • npx eslint .: passed with two pre-existing warnings.
  • npx jest --coverage --forceExit --maxWorkers=50%: 67 suites, 1,007 tests passed; 89.26% statements, 82.99% branches, 92.36% functions, 89.45% lines.
  • npm run test:android: 18 tests, 3 suites, zero failures/errors/skips.
  • npm run lint:android: passed, no new issues; 42 warnings filtered by baseline.
  • GitHub CI run: all four jobs passed (typecheck, test, lint, android-build), including debug/release Android builds and 45 iOS simulator native tests. The CI release uses an ephemeral CI key; the field.3 tester APK uses the established release key.
  • Signed ARM64 release build, signature/certificate, package/version, bundled JavaScript, no embedded models/field data, and standalone no-Metro startup verified.
  • Field.2 to field.3 in-place update retained installation/data-directory identity and onboarding; the pre-upgrade release app had no packs or observations, so existing-observation upgrade retention is NOT proven.
  • Signed app sign-in and initial pack/model download succeeded. Airplane mode on and Wi-Fi off: 66-record browsing, duplicate-name handling, exact-ID search, sorting, sex/unknown filters, empty search, three available references, zoom/pan, and back navigation passed.
  • One owner-approved test image produced a detection offline. Candidate-profile viewing remained read-only; fixture-only reassignment removed the old link and added the corrected source/crop links before upload. Both local images rendered full-screen and survived cold offline restart.
  • The corrected, explicitly test-only observation had no location and uploaded once through its individual action. UI reported one receipt as Received by EleBook, not WhiskerBook confirmation.

Remaining validation: iOS browser UI/physical field checks; observation-bearing legacy upgrade; exhaustive 189-reference image traversal; large-text/landscape and pinch gesture checks; physical pack replacement/removal, missing-file and multi-project matrices (automated coverage exists); failure/retry upload and other devices. This is not fleet acceptance or an identification-accuracy claim.

JasonWildMe is requested as reviewer. His scroll-retention finding is addressed as noted above; review approval remains pending. Gemini review was requested, but has not responded at the latest check; no review approval is implied.

npm ci reported 30 existing dependency vulnerabilities (2 low, 18 moderate, 9 high, 1 critical). Dependency remediation is outside this feature and remains a separate approval decision.

APKs, bundle, raw device evidence, and test fixtures remain ignored/local. Only the reviewed screenshots are attached above. This PR is a draft for Jason's review; no release publication, merge, auto-merge, force-push, or history rewrite is authorized.

@BMichaelJ

Copy link
Copy Markdown
Collaborator Author

/gemini review

@JasonWildMe

Copy link
Copy Markdown

@BMichaelJ Feedback from Astra. Just one issue:

Returning from a profile resets catalog scroll position.
Every focus change reloads the pack
(

useEffect(() => {
if (!pack || pack.status !== 'ready' || !focused) return;
let cancelled = false;
setResult({ identity, status: 'loading', individuals: [] });
loadBrowserIndividuals(pack).then(individuals => {
if (!cancelled) setResult({ identity, status: 'ready', individuals });
}).catch(() => {
if (!cancelled) setResult({ identity, status: 'error', individuals: [] });
});
return () => { cancelled = true; };
}, [pack, identity, focused, attempt]);
),
clears its individuals, and switches to loading. This unmounts the catalog’s FlatList; returning from an individual
therefore creates a fresh list at the top. Keep the list mounted while refreshing an unchanged pack, or restore its
position. A regression test confirms the unmount.

@BMichaelJ

Copy link
Copy Markdown
Collaborator Author

@JasonWildMe Thanks to you and Astra for catching this. Fixed in 7909eeb. A same-pack focus refresh now keeps the ready catalog and its existing FlatList mounted instead of clearing it, preserving native scroll state. Pack identity changes and failed refreshes still hide stale data. The real-navigation regression pauses the return refresh and asserts that the identical catalog instance survives, then covers both success and failure. It reproduced the unmount before the fix. Commit hooks passed: TypeScript, lint, and 1,008 tests across 67 Jest suites. The updated PR is running CI. The previously tested field.3 APK is unchanged and does not contain this follow-up; a new signed tester APK will be needed to validate this fix on device.

@BMichaelJ

Copy link
Copy Markdown
Collaborator Author

/gemini review

@BMichaelJ

Copy link
Copy Markdown
Collaborator Author

/gemini review

@BMichaelJ
BMichaelJ marked this pull request as ready for review September 9, 2026 14:04
Sign-in completed at Microsoft but storing the tokens failed on Android 9
with "Wrapped error: Keystore operation failed". The keychain service name
embedded all six URL-encoded deployment fields (246 chars for the live
config). Android 7-11 keep each Keystore key in a file named
<uid>_USRPKEY_<alias> with most punctuation escaped to two characters, so
the blob filename reached 289 bytes, over Linux NAME_MAX (255), and key
generation failed with "could not rename blob". Sign-in could never
complete on those devices.

Digest the deployment key with SHA-256 instead. The alias is now a fixed
90 characters, tokens stay isolated per deployment, and the same
deployment still maps to a stable service name. Devices that never
stored a token (all Android <= 11) are unaffected; iOS and Android 12+
testers sign in once more after upgrading, matching the documented
"legacy tokens are not reused" behaviour.

Adds a regression test that models the Android keystore filename
encoding and asserts a long deployment stays within 255 bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@JasonWildMe

Copy link
Copy Markdown

Pushed a fix for a sign-in blocker found while testing field.4 on Android 9 (BlueStacks "Pie64", live deployment config).

Symptom. Microsoft sign-in completes, then the app shows "Sign-in failed: Wrapped error: Keystore operation failed". Logcat from the keystore daemon:

W keystore: could not rename blob to user_0/10067_USRPKEY_org+^ganesha+^elebook+^entra+^https+U3A+U2F+U2Fganesha+]dev+]func+^azurewebsites+^net+U2Fapi|proj_kariega|...
E keystore: Failed to generate key -> falling back to software keymaster
E RNKeychainManager: Caused by: android.security.KeyStoreException: System error

Root cause. getTokenStorageService() embedded all six URL-encoded deployment fields in the keychain service name (246 chars for the live config). Android 7-11 store each Keystore key as a file named <uid>_USRPKEY_<alias>, escaping most punctuation to two characters, so the blob filename came to 289 bytes, over Linux NAME_MAX (255). Key generation failed and no token could ever be stored on those Android versions. Android 12+ (keystore2, SQLite-backed) and iOS were unaffected, which is why it did not show up on newer devices.

Fix (src/config/deployment.ts). Digest the deployment key with SHA-256 (hex) instead of embedding it. The alias is a fixed 90 characters (107-byte blob filename), tokens remain isolated per deployment, and the derivation is deterministic across platforms. @noble/hashes was already a dependency.

Compatibility. Nothing changes for Android <= 11 (no token was ever stored there). iOS and Android 12+ testers who signed in on an earlier build get one fresh sign-in after upgrading; their previous entry stays in secure storage, matching the existing "legacy tokens are not reused" note. docs/setup.md updated accordingly.

Tests. New regression test models the Android keystore filename encoding and asserts a long deployment stays within 255 bytes (fails at 338 bytes on the previous code), plus a pinned service-name fixture for the example deployment so the derivation cannot drift silently. Existing isolation/stability tests unchanged and passing. Reviewed independently with Codex (no Critical/Major findings; its two Minor notes on the test helper and docs wording are included).

Version left at 0.1.0-field.4 for you to bump as you see fit for the next tester build.

On BlueStacks (Android 9 on an x86_64 host, running the arm64 app through
its ARM translation layer) detection failed with ONNX Runtime reporting
"Load model from <path> failed. File doesn't exist", although the same
file passed react-native-fs exists/stat checks and pack validation. The
detector path was 290 bytes. The identical commit built for x86_64, which
needs no translation, loads the model, so the translation layer fails
native open() on that long path where the Java file APIs succeed.

The length came from the pack directory keys: the project ID and pack
version were UTF-16 hex encoded (4 characters per code unit, up to 40 code
units each) and the full 64-digit artifact SHA was appended, so a single
version folder was 147 characters here and could reach 227, close to the
255-byte component limit as well.

Use a 16-hex-digit SHA-256 prefix per level instead, computed over the
lossless UTF-16 encoding of the identity (plus the artifact SHA for the
release level): `p-<digest>` and `v-<digest>`. Isolation per project and
per (version, artifact) is unchanged, the identity length limits are kept,
and the detector path is now about 100 bytes plus the model filename on
Android and 150 plus the filename on iOS. Installed packs keep working
because records store absolute paths; new downloads use the shorter
directories. The pack format documents a 64-character detector filename
budget.

Tests: maximal 40-code-unit identities yield directory segments of at
most 34 characters; look-alike labels and a different artifact of the
same label get distinct unsuffixed directories; the derived names for the
example inputs are pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@JasonWildMe

Copy link
Copy Markdown

Pushed a second fix from field.4 testing on BlueStacks (Android 9 on x86_64, arm64 app running through the ARM translation layer).

Symptom. After sign-in and a successful pack download and validation, the first detection failed:

Detection Failed
Loxodonta africana: Load model from /data/data/org.ganesha.elebook/files/embedding_packs/p-0070…/v-0032…-0b11…/models/elephant_head_only_yolov11.onnx failed: … File doesn't exist

The pack still re-validated as ready after an app restart, so the file existed and the Java file APIs saw it. Only ONNX Runtime, native arm64 code running through the translation layer, could not open it. The path was 290 bytes. Building the identical commit for x86_64 (no translation) made detection work, which pins the failure to native open() of that long path under translation.

Cause. The extract directory keys encoded the project ID and pack version as UTF-16 hex (4 characters per code unit, identities up to 40 code units) and appended the full artifact SHA: the version folder was 147 characters for a 20-character version and could reach 227, close to the 255-byte component limit regardless of emulator.

Fix (packDownloadService/candidate.ts). Each level is now a 16-hex-digit SHA-256 prefix over the lossless UTF-16 encoding of the identity: p-<digest(projectId)> and v-<digest(version, artifactSha)>. Isolation per project and per (version, artifact) is unchanged, the 1-40 code unit identity limits are kept, and the detector path is now about 100 bytes plus the model filename on Android and 150 plus the filename on iOS. Nothing about the pack format, manifest, or backend changes. Installed packs keep working because records store absolute paths; new downloads land in the shorter directories. docs/api.md, docs/setup.md, and the pack format doc (64-character detector filename budget) updated.

Tests. New: maximal 40-code-unit identities yield directory segments of at most 34 characters (was 162 for the project level); look-alike labels and a different artifact of the same label get three distinct unsuffixed directories; the derived names for the example inputs are pinned so the derivation cannot drift silently. Reviewed independently with Codex; its Minor notes (lossless encoding for malformed UTF-16, repair-suffix masking in the test, filename budget, claim wording) are included.

CI note for maintainers. The test job failed three times on the previous commit for reasons unrelated to the code. The green run at 12:37 logged [Hermes] Using release tarball from URL: https://repo1.maven.org/…/hermes-ios-0.14.0-hermes-ios-debug.tar.gz; all three failing attempts logged [Hermes] Using the latest commit from main and then died in [RN] [2] Build Hermes with fatal error: 'jsi/hermes-interfaces.h' file not found. React Native's hermes-engine podspec falls back to building Hermes from its main branch when the Maven availability check fails, and current Hermes main no longer matches 0.83.1's headers. Jest (1010 tests) and the Android unit tests passed in every attempt. Pinning the Hermes source (for example HERMES_ENGINE_TARBALL_PATH with a cached tarball, or failing fast when the prebuilt is unavailable) would make the job deterministic.

@JasonWildMe JasonWildMe left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests clean on Android

@JasonWildMe
JasonWildMe merged commit a2e47f7 into WildMeOrg:wildlife-reid Sep 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants