This repository contains the JavaScript/TypeScript app plus the JS-side native
configuration (metro.config.js, react-native.config.js, Gemfile,
babel.config.js, app.json, index.js). The platform projects (ios/,
android/) are generated by the React Native CLI (RN 0.76.5 template),
then patched with the app-specific changes below.
Status: the
android/project has been generated, patched, built and verified running on an Android emulator (API 35 arm64) and a physical Pixel device, including model download → llama.rn load → on-device generation. The dependency pins in "Version compatibility" below are required for the build to succeed against RN 0.76.5's toolchain. Theios/project is generated, patched (incl. the Share Extension), and builds and runs on the iOS simulator; on-device iOS QA (large-model memory entitlement) is the remaining item.
The app loads a ~1–2 GB GGUF model into memory via llama.rn, so the patches below mostly exist to raise memory limits and allow model downloads.
- Node.js ≥ 18, Watchman
- Ruby + Bundler (
gem install bundler), thenbundle install(usesGemfile) - iOS: Xcode 15+, CocoaPods (via Bundler), a real device recommended (the simulator cannot use the Metal GPU backend and large models are slow)
- Android: JDK 17, Android Studio, NDK, an arm64 device (llama.cpp needs 64-bit; many emulators are slow for inference)
The project name is AiNoteOfflineAiMemo (see app.json). Generate a throwaway
template with the same name and copy its platform folders in:
# From a scratch directory, NOT this repo:
npx @react-native-community/cli@0.76.5 init AiNoteOfflineAiMemo --version 0.76.5 --skip-install
# Then copy the platform folders into this repo:
cp -R AiNoteOfflineAiMemo/ios <this-repo>/ios
cp -R AiNoteOfflineAiMemo/android <this-repo>/androidKeep this repo's existing index.js, app.json, babel.config.js,
metro.config.js and package.json — do not overwrite them with the
template's copies.
Then install pods:
bundle install
cd ios && bundle exec pod install && cd ..A 1–2 GB model exceeds the default per-app memory limit on many devices. Add an
entitlements file (e.g. ios/AiNoteOfflineAiMemo/AiNoteOfflineAiMemo.entitlements)
and reference it in the target's Signing & Capabilities:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.kernel.increased-memory-limit</key>
<true/>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
</dict>
</plist>Status: the shipped
AiNoteOfflineAiMemo.entitlementscurrently contains only the App Group (used by the Share Extension). The memory entitlement above is not yet applied — add it when targeting real devices with large models (the simulator uses host RAM and does not need it). See "Remaining iOS work" at the end.
Model downloads use HTTPS (see src/core/models/catalog.ts), so App Transport
Security needs no exceptions. If you later add an HTTP mirror, add a scoped ATS
exception rather than disabling ATS globally.
Add the INTERNET permission (model download) and largeHeap to the application
tag:
<uses-permission android:name="android.permission.INTERNET" />
<application
android:largeHeap="true"
... >Restrict to 64-bit ABIs (llama.cpp is arm64/x86_64 only) to keep the APK small:
android {
defaultConfig {
ndk {
abiFilters "arm64-v8a", "x86_64"
}
}
}The app has no build-time secrets. .env / .env.example are placeholders for
any future configuration; nothing in src/ reads them today. Never commit a
populated .env (it is git-ignored).
npm start # Metro bundler
npm run ios # or: npx react-native run-ios --device
npm run androidOn first launch no model is downloaded yet — open Settings → AI model and download one (Wi-Fi recommended; ~1.1 GB for the default Qwen2.5 1.5B). Inference is unavailable until a model finishes downloading and loading.
Note:
npm run ios/npm run androidscripts are added by the CLI template in step 1. This repo'spackage.jsonalready definesstart/ios/android.
To verify the UI on an emulator without a Metro dev server (e.g. when port 8081 is taken, or for a quick smoke test), build the release variant — it embeds the JS bundle into the APK:
cd android && ./gradlew :app:installRelease
adb shell am start -n com.ainoteofflineaimemo/.MainActivityThe release buildType is signed with the debug keystore (template default), so
it installs without extra signing setup. AI features still require a downloaded
model; notes, navigation and settings work fully offline.
RN 0.76.5 ships Kotlin 1.9.24 and a codegen that several newer community packages have outgrown. These pins are required for the Android build to succeed (verified on the emulator):
| Package | Pin | Why |
|---|---|---|
react-native-screens |
~4.4.0 |
4.25+ declares a prop type RN 0.76 codegen can't parse (generateCodegenSchemaFromJavaScript fails on accessibilityContainerViewIsModal). |
@react-native-async-storage/async-storage |
^2.x |
3.x requires Kotlin ≥ 2.1.0; the template uses 1.9.24, so KSP fails. |
newArchEnabled=trueis required.llama.rn@0.12.4's Android module only compiles against the new architecture (Fabric/TurboModules). WithnewArchEnabled=falseit fails (RNLlamaModule cannot be converted to NativeModule). The app therefore runs in bridgeless/new-arch mode.- Model download uses
react-native-fs(RNFS.downloadFile), not a background-download library.react-native-background-downloader@2.3.4doesn't compile against RN 0.76, and the maintained@kesha-antonov/...fork's TurboModule does not register under bridgeless (its JS throws "doesn't seem to be linked"). RNFS works under the new architecture via the legacy-module interop layer and follows the Hugging Face redirect, so the download path lives insrc/app/services/modelFiles.ts(downloadModel). reactNativeArchitectures=arm64-v8ainandroid/gradle.propertieskeeps build times down (one ABI). Addx86_64if you need Intel emulators; arm64 covers all modern phones and Apple-silicon emulators.
Android SDK components used: NDK 26.1.10909125, CMake 3.22.1, platform
android-35, build-tools 35.0.0 (match android/build.gradle ext).
react-native-vector-icons is a real dependency (Paper's icons); its fonts are
copied into the APK via apply from: ".../react-native-vector-icons/fonts.gradle"
in android/app/build.gradle.
cd ios && RCT_NEW_ARCH_ENABLED=1 pod install && cd .. # new arch, matches llama.rn
# Build + run a release build on the simulator (embeds the JS bundle, no Metro):
xcodebuild -workspace ios/AiNoteOfflineAiMemo.xcworkspace -scheme AiNoteOfflineAiMemo \
-configuration Release -sdk iphonesimulator -derivedDataPath ios/build \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO build
xcrun simctl install booted ios/build/Build/Products/Release-iphonesimulator/AiNoteOfflineAiMemo.app
xcrun simctl launch booted org.reactjs.native.example.AiNoteOfflineAiMemoiOS-specific wiring already applied to ios/:
-
UIAppFonts(Info.plist) lists the vector-icons.ttfs. TheRNVectorIconspod copies the fonts into the bundle, but withoutUIAppFontsiOS won't register them and every icon renders as a "?" box. -
ainote://URL scheme (Info.plistCFBundleURLTypes) +RCTLinkingManagerinAppDelegate.mmopenURL— the deep-link half of the share flow. -
Share Extension (implemented) — receiving text from the system share sheet on iOS uses a separate Share Extension target (the Android
ACTION_SENDintent-filter has no direct iOS equivalent). It writes the shared text to a shared App Group container and dispatches into the app via theainote://scheme + Linking handler.
Remaining iOS work:
- Large-model memory entitlement — add
com.apple.developer.kernel.increased-memory-limit(+ extended VA) in an.entitlementsfile for real devices loading 1–2 GB models. Not needed for the simulator (it uses host RAM).
The default model is ~1.1 GB, so re-downloading it from Hugging Face on every fresh install is slow. Two ways to avoid that:
Cache the GGUF on your machine once and serve it over the LAN, then point the app at it (dev builds only — release always uses the real catalog):
make fetch-model # caches the default GGUF into .model-cache/ (once)
make serve-model # serves .model-cache/ over HTTP (set MODEL_PORT to taste)fetch-model uses the Hugging Face client (hf) because HF serves large files
via Xet (chunked) — plain curl/wget cannot reconstruct them (a full GET
truncates). Install it once and, for un-throttled speed, authenticate:
pipx install 'huggingface_hub[hf_xet]' # provides the `hf` command
hf auth login # optional; anonymous DLs are rate-limitedThen set DEV_MODEL_BASE_URL in src/app/devConfig.ts (pick a free port — the
default 8000 is commonly taken; make serve-model MODEL_PORT=8123 etc.):
- Android emulator:
http://10.0.2.2:<port> - iOS simulator:
http://localhost:<port> - physical device:
http://<your-mac-LAN-ip>:<port>(same Wi-Fi)
The app downloads ${DEV_MODEL_BASE_URL}/<original-gguf-filename> — a LAN
transfer instead of the public CDN. Keep your local URL out of git with
git update-index --skip-worktree src/app/devConfig.ts. This also makes an
interrupted download cheap to retry, so byte-range resume isn't needed in dev.
The downloaded model lives in the app's data container, and a reinstall keeps that container, so you rarely need to download twice:
- Android:
adb install -r app-release.apk(ornpx react-native run-android) preserves/data/data/<pkg>/files/models/. A full uninstall wipes it. - iOS simulator:
xcrun simctl install/npx react-native run-iosover the same bundle id preserves the container. Erasing the simulator or deleting the app wipes it.
So during iteration, prefer reinstalling over uninstalling, and the ~1.1 GB
download (plus the persisted ModelManager "downloaded" state) carries over.