Skip to content

fix(ios): match Android's preprocessing, version, and permissions - #40

Open
blclo wants to merge 1 commit into
wildlife-reidfrom
fix/ios-android-parity
Open

blclo wants to merge 1 commit into
wildlife-reidfrom
fix/ios-android-parity

Conversation

@blclo

@blclo blclo commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Brings the iOS target back in line with Android. The screens, stores, and services are shared TypeScript and were already identical on both platforms, so nothing here is a ported screen — everything that had drifted sits in the native layer and in app identity.

Preprocessing

ImageTensorModule.kt reduces toward the target in repeated 2x steps so a large downscale is antialiased rather than aliased — the chain pinned by Project Ganesha's golden on-device parity test (E13-4). iOS resized in one shot, and did it through UIGraphicsImageRenderer's default format, whose scale is the screen scale. So a 440x440 request allocated a 1320x1320 buffer on a 3x device and 880x880 on a 2x device, which extractNchw then resampled a second time.

The consequence is worse than "iOS differs from Android": the tensor depended on which iPhone computed it. Measured on an iPhone 17 Pro simulator against identical fixtures, the same photo through the old path differed between a 2x and a 3x device by up to 49/255 on a channel for a small crop. progressiveResize now mirrors the Kotlin algorithm and draws into an explicit pixel-sized CGContext, so output is a function of the image alone.

Full before/after numbers across four source sizes are in kb/wildlife-reid-mobile/outputs/reports/2026-09-13-ios-resize-parity-measurement.md.

Two things this does not claim. It is not byte parity with Android — the chain structure matches but the per-step filter does not, since Android resamples bilinearly through createScaledBitmap while Core Graphics resamples at .high. And no MiewID impact was measured: differences of a few units out of 255 may or may not reorder candidates, and settling that needs an iOS counterpart to the E13-4 golden test, which does not exist yet. What is established is that the algorithm now matches and the device dependence is gone.

Both bridge methods also decode through one loadUprightImage helper now, mirroring Android's loadBitmap, so the tensor and the crop cannot disagree about EXIF orientation.

Identity

MARKETING_VERSION was 0.0.58 while Android shipped 0.1.0-field.6 — the field.4, field.5 and field.6 bumps each touched only android/app/build.gradle. Version and build number now match Android, so the Settings screen reports the same string on both platforms. Debug builds take a .dev bundle suffix the way Android already does, so a development build no longer replaces a tester's field build. The launch screen no longer reads "Off Grid", and CFBundleName is EleBook rather than the Xcode target name.

Permissions

Info.plist still asked for the microphone "for voice-to-text transcription using Whisper" and for speech recognition, and described the photo library as being "to attach images to conversations". No audio dependency remains in package.json and the picker is photo-only (useCaptureFlow.ts:140-148), so both stale requests are removed and the photo-library string now describes what the app does. Android's manifest asks only for INTERNET, location, and VIBRATE.

One unrelated fix, because it blocked the commit

The pre-commit hook pipes staged paths straight into swiftlint lint, and passing explicit paths overrides the excluded: list in .swiftlint.yml. So staging any Swift file linted ios/OffgridMobileTests/OffgridMobileTests.swift too and failed on 8 violations that already exist on wildlife-reid (force_try, identifier_name, file_length) — none of which any merge gate enforces, because npm run lint:ios and CI honour the exclude list and skip that file. It had not fired before because the recent iOS work was done on hosts without SwiftLint installed, where the hook prints a warning and skips. --force-exclude makes the hook agree with CI. Reverting that one line reproduces the failure.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Chore (build process, CI, dependency updates, etc.)

Screenshots / Screen Recordings

No screens changed — the shared TypeScript UI is untouched. The only visual difference is the iOS launch screen, which drops the "Off Grid" wordmark and centres the logo, matching splash_background.xml on Android.

Checklist

General

  • My code follows the project's coding style and conventions
  • I have performed a self-review of my code
  • I have added/updated comments where the logic isn't self-evident
  • My changes generate no new warnings or errors

Testing

  • I have tested on Android (physical device or emulator)
  • I have tested on iOS (physical device or simulator)
  • I have tested in light mode and dark mode
  • Existing tests pass locally (npm test)
  • I have added tests that prove my fix is effective or my feature works

Left unchecked deliberately. npm test chains three suites and only two of them could run on this host: the JS suite (67 files, 1031 tests) and the iOS XCTest suite on an iPhone 17 Pro simulator, both green, plus npx tsc --noEmit and SwiftLint. The Android suite needs a JDK and an Android SDK that this machine does not have, so CI is the first place it runs — no Kotlin changed. Neither app was launched on a device or an emulator, and no light/dark pass was done, because no shared TypeScript changed.

React Native Specific

  • No new native module without corresponding platform implementation (Android + iOS)
  • New native modules are added to the Xcode project build target (project.pbxproj)
  • No hardcoded pixel values — uses SPACING / TYPOGRAPHY constants from the theme
  • Styles use useThemedStyles pattern (not inline or static StyleSheet.create)
  • Animations/gestures work smoothly on both platforms
  • Large lists use FlatList / FlashList (not .map() inside ScrollView)
  • No unnecessary re-renders introduced (check with React DevTools Profiler if unsure)

No new native modules and no TypeScript touched, so everything below the first line is N/A rather than done.

Performance & Models

  • File paths are resolved correctly on both platforms (no hardcoded / vs \)
  • Large files (models, assets) are not committed to the repository
  • Downloads / long-running tasks report progress to the UI

N/A — no download or long-running task code changed.

Security

  • No secrets, API keys, or credentials are included in the code
  • User input is validated/sanitized where applicable

Additional Notes

Not verified on a device. Simulator unit tests and SwiftLint were run on macOS; nothing here was exercised on a physical iPhone, and no field capture was compared end to end against an Android capture of the same animal. A same-photo comparison across the two platforms is the check that would actually confirm parity, and it still needs doing.

The embedding change is behavioural. iOS tensors computed before this commit do not match tensors computed after it, and pre-existing iOS embeddings were themselves device-dependent. Any iOS-side candidate scores or cached crops from an earlier build should be regenerated rather than compared against new ones.

MARKETING_VERSION now carries a pre-release string. 0.1.0-field.6 is not a valid CFBundleShortVersionString for App Store submission, which accepts at most three dot-separated integers. Ad-hoc and AltStore distribution are unaffected, and release-ios.yml already copied this same string straight out of package.json, so this matches what the release path was always going to produce. Worth resolving before any TestFlight or App Store attempt.

Nothing stops this drifting again. CI has an android-build job that compiles Android debug and release, and no iOS equivalent — the iOS app target is only ever built as a side effect of the unit-test job. An ios-build job would be the durable fix; it is out of scope here because it costs macOS CI minutes on every PR.

Deliberately left alone

  • Crop quantization still differs. Android truncates origin and extent to integers independently; iOS passes fractional bounds to Core Graphics, which expands them to integral bounds, so x=1.5, width=2.5 selects [1,3) on Android and [1,4) on iOS. Finding 4 of kb/wildlife-reid-mobile/outputs/reports/2026-09-09-codex-exif-orientation-review.md deferred this as pre-existing and wanting a shared integer-bound calculation with its own tests. It is a real remaining mismatch, roughly a pixel on a crop of several hundred, and folding it in here would have made the preprocessing change harder to read.
  • CoreMLDiffusionModule (Stable Diffusion) and PDFExtractorModule are still compiled into the iOS target with no Android counterpart and no remaining product use. ml-stable-diffusion is still a resolved Swift Package, so every iOS build downloads Apple's diffusion package to compile a module nothing calls. Removing them touches project.pbxproj and drops roughly 30 XCTest cases, which belongs in its own PR.
  • DownloadManagerModule is compiled but has no JS binding at all (noted in fileDownloadService/index.ts:18).
  • ios/OffgridMobile/{CoreMLDiffusion,Download,ImageTensor,PDFExtractor}/ are orphaned duplicates that the Xcode project does not reference.
  • The Podfile still justifies its iOS 17.0 floor as being "for ml-stable-diffusion (Core ML image gen)".
  • TODO.md still describes the replaced chat and image-generation product.

The shared TypeScript already ran identically on both platforms; the drift
was all in the native layer.

Preprocessing. Android reduces toward the target in repeated 2x steps
(progressiveResize, pinned by the E13-4 golden on-device parity test).
iOS resized in one shot through UIGraphicsImageRenderer's default format,
whose scale is the screen scale, so a 440x440 request allocated a
1320x1320 buffer on a 3x device and 880x880 on a 2x one -- which
extractNchw then resampled again. The tensor depended on which iPhone
computed it: measured up to 49/255 on a channel between a 2x and a 3x
device for a small crop. progressiveResize now mirrors the Kotlin
algorithm and draws into an explicit pixel-sized CGContext.

This buys a shared algorithm, not byte parity -- Android resamples
bilinearly via createScaledBitmap, Core Graphics at .high -- and no MiewID
candidate-ordering impact was measured. Numbers in
kb/wildlife-reid-mobile/outputs/reports/2026-09-13-ios-resize-parity-measurement.md.

Both bridge methods now decode through one loadUprightImage helper,
mirroring Android's loadBitmap, so tensor and crop cannot disagree about
EXIF orientation.

Identity. MARKETING_VERSION was 0.0.58 while Android shipped
0.1.0-field.6; the field.4/5/6 bumps each touched only build.gradle.
Version and build number now match Android. Debug takes the .dev bundle
suffix Android already uses, so a dev build no longer replaces a tester's
field build. The launch screen no longer reads "Off Grid", and
CFBundleName is EleBook rather than the Xcode target name.

Permissions. Info.plist asked for the microphone "using Whisper" and for
speech recognition, and described the photo library as being for
conversations. No audio dependency remains and the picker is photo-only,
so both stale requests are gone and the photo string describes the app.

Hook. The pre-commit SwiftLint step passed explicit paths, which overrides
.swiftlint.yml's `excluded` list, so it failed on 8 pre-existing violations
in ios/OffgridMobileTests that no merge gate enforces. It never fired
before because the recent iOS work was done on hosts without SwiftLint
installed. --force-exclude makes the hook agree with `npm run lint:ios`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant