Mobile Development Guide¶
This document covers everything you need to know about building, running, and testing Stratum on mobile platforms. Stratum uses Tauri v2's mobile support to target both Android and iOS from a shared Rust + TypeScript codebase.
Build Targets¶
Stratum currently supports the following mobile targets:
Android¶
| Architecture | Rust Target | Status |
|---|---|---|
| ARM64 (most devices) | aarch64-linux-android |
Active |
| ARMv7 (older devices) | armv7-linux-androideabi |
CI only |
| x86_64 (emulator, Chromebook) | x86_64-linux-android |
Active |
| x86 (emulator, older) | i686-linux-android |
CI only |
The Android build targets API 24 (Android 7.0, Nougat) as the minimum SDK version, configured in src-tauri/tauri.conf.json:
iOS¶
| Architecture | Rust Target | Status |
|---|---|---|
| ARM64 (physical device) | aarch64-apple-ios |
CI only |
| ARM64 Simulator (Apple Silicon Macs) | aarch64-apple-ios-sim |
Active |
| x86_64 Simulator (Intel Macs) | x86_64-apple-ios |
CI only |
iOS device builds
iOS device builds require a paid Apple Developer account, signing certificates, and a provisioning profile. The CI pipeline builds for simulator only. Physical device testing is done locally by developers with Apple Developer accounts.
Setup¶
Android SDK / NDK¶
Building for Android requires the Android SDK and NDK. The CI pipeline (.github/workflows/ci.yml) documents the exact setup.
Prerequisites:
- Java 17 (Temurin recommended)
- Android SDK (command line tools)
- NDK 27.0.12077973
Quick setup:
# Install Java
sudo apt install openjdk-17-jdk
# Install Android command-line tools
# Download from https://developer.android.com/studio#command-line-tools-only
ANDROID_HOME="$HOME/Android/Sdk"
mkdir -p "$ANDROID_HOME"
# Install required SDK packages
sdkmanager "platforms;android-34" \
"build-tools;34.0.0" \
"ndk;27.0.12077973" \
"cmake;3.22.1"
# Export environment variables
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.0.12077973"
export PATH="$ANDROID_HOME/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
Set up cross-compilation environment variables:
The CI configures these for each architecture. For local development you'll need the NDK toolchain on your PATH and the following environment variables set:
# ARM64 (most devices)
export CC_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang"
export AR_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
# x86_64 (emulator)
export CC_x86_64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android21-clang"
export AR_x86_64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
Rust targets:
The project's rust-toolchain.toml includes Android targets. Make sure they are installed:
On first build, cargo tauri android init generates the Android project under src-tauri/gen/android/. This directory is gitignored but cached in CI.
iOS / Xcode¶
Building for iOS requires macOS and Xcode.
Prerequisites:
- macOS (latest version recommended)
- Xcode 16+ (from the Mac App Store)
- Xcode Command Line Tools:
xcode-select --install - CocoaPods (for iOS dependencies):
sudo gem install cocoapods
Rust targets:
On first build, cargo tauri ios init generates the Xcode project under src-tauri/gen/apple/. You can then open the project in Xcode for signing configuration:
Android Patches¶
Stratum applies Android patches after tauri android init by copying files from src-tauri/android-patches/ into the generated project. The CI pipeline does this explicitly:
These patches provide:
- Custom
MainActivity.ktthat enables edge-to-edge and injects system-bar insets as CSS custom properties (see Safe Area Handling) - Themed launcher icons
- Custom
AndroidManifest.xmlwith storage permissions - No-action-bar theme
Debugging¶
Android¶
Physical device (recommended):
# Enable USB debugging on your device
# Connect via USB and verify
adb devices
# Run in development mode
cargo tauri android dev
This builds the Rust code for ARM64, packages the APK, installs it on the connected device, and launches it. Hot-reload of the frontend is supported via the Vite dev server on port 5173. The Rust backend must be rebuilt manually on changes (cargo build -p src-tauri and reinstall).
Emulator:
# List available avd images
emulator -list-avds
# Start an emulator
emulator -avd Pixel_6_API_34
# Run Tauri with x86_64 target
cargo tauri android dev --target x86_64
Logs:
# Filter Tauri/Stratum logs
adb logcat | grep -E '(stratum|libstratum|tauri|RustError)'
# Full logs with timestamps
adb logcat -v time | grep stratum
On Android, the Rust eprintln! calls that appear in the terminal on desktop are routed through android_logger. You can view them via adb logcat.
iOS¶
Simulator:
This builds the Rust code, launches the iOS Simulator, and runs the app with Vite hot-reload for the frontend.
Physical device:
Then open the Xcode project, select your team under Signing & Capabilities, and run on your device.
Logs:
Use the Xcode console (View > Debug Area > Activate Console) or the simctl tool:
Responsive Design¶
Stratum uses a two-tier responsive design approach: a useResponsive hook for runtime adaptation and a *.mobile.tsx / *.shared.tsx file pattern for platform-specific component variants.
The useResponsive Hook¶
Defined in src/lib/hooks/useResponsive.ts:
import { useState, useEffect } from 'react';
const MOBILE_BREAKPOINT = 768;
export function useResponsive() {
const [width, setWidth] = useState(
typeof window !== 'undefined' ? window.innerWidth : 1200
);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return {
isMobile: width < MOBILE_BREAKPOINT,
isDesktop: width >= MOBILE_BREAKPOINT,
width,
};
}
Usage:
import { useResponsive } from '../lib/hooks/useResponsive';
function MyPanel() {
const { isMobile, isDesktop } = useResponsive();
if (isMobile) {
return <MobileVariant />;
}
return <DesktopVariant />;
}
The breakpoint is 768px (tablet portrait width). Below that, the app renders mobile layouts. Above that, desktop layouts. This is a width-based check so it adapts to both mobile phones and resized desktop windows.
The *.mobile.tsx Component Pattern¶
For complex panels that need significantly different mobile and desktop implementations, use the three-file pattern:
src/components/FeaturePanel/
├── index.tsx # Desktop/web implementation (imports .shared)
├── FeaturePanel.mobile.tsx # Mobile variant (imports .shared)
├── FeaturePanel.shared.tsx # Shared logic, hooks, types
└── FeaturePanel.test.tsx # Tests
The index.tsx uses useResponsive to conditionally render the correct variant:
import { useResponsive } from '../../lib/hooks/useResponsive';
import { FeaturePanelDesktop } from './index';
import { FeaturePanelMobile } from './FeaturePanel.mobile';
export default function FeaturePanel() {
const { isMobile } = useResponsive();
if (isMobile) return <FeaturePanelMobile />;
return <FeaturePanelDesktop />;
}
The .shared.tsx file holds code that both variants use: types, hooks, utility functions, and pure rendering helpers that don't depend on layout.
The GraphPanel is a worked example of this pattern: GraphPanel/index.tsx gates on useResponsive().isMobile, the mobile UI lives in GraphPanel.mobile.tsx, and the shared data-loading, filtering, and capability logic lives in GraphPanel.shared.tsx / useGraphPanel.ts.
Mobile 3D Graph View¶
On mobile the graph defaults to a 2D force layout (GraphCanvas2D) for performance. The mobile variant exposes an opt-in 3D view toggle ("3D view" checkbox) in the graph settings bottom sheet, which switches the renderer to the 3D GraphCanvas. The toggle is gated on device capability, and the feature is disabled (and the 2D layout used) on devices that cannot render 3D.
The capability check lives in src/lib/graph3d.ts:
detectGraph3dSupport()returns aGraph3dSupportverdict (ok/no-webgl/low-end/device-unknown) by probing for a WebGL context (webgl2 / webgl / experimental-webgl) and checking hardware constraints (≤2 GB of RAM or ≤2 CPU cores).- It is computed once when the graph panel mounts and is shared with both the desktop and mobile variants through
useGraphPanel(which re-exports agraph3dSupportvalue fromuseGraphPanel.ts). - Desktop always renders 3D and never consults this module; the mobile variant is the only consumer.
Behavior on mobile:
- Supported device — the "3D view" toggle is enabled. Turning it on renders the 3D graph; the preference (
use_3d) is persisted in graph settings (.pkm/config.toml) for the next session. - Unsupported device — the "3D view" toggle is disabled. If the user had previously enabled 3D, the app keeps the saved preference but renders the 2D force layout and shows a dismissible notice explaining that 3D isn't supported on the device (WebGL unavailable or hardware too constrained). If they later open the same vault on a capable device, 3D is used.
- This is the graceful fallback: a constrained device never crashes or blanks out — it renders 2D with an explanation.
Guidelines for platforms/features that are GPU- or memory-heavy:
- Follow the
FeaturePanel.mobile.tsx/FeaturePanel.shared.tsxconvention so the capability check and fallback decision live in shared logic, not duplicated per variant. - Gate hardware-sensitive features on an explicit capability module (like
graph3d.ts) rather than assuming all mobile devices can render them. - Never remove the fallback path: opt-in GPU-heavy rendering must always have a usable 2D/reduced path for constrained devices.
CSS for Mobile¶
The src/global.css file includes mobile-specific touch and safe-area handling. The safe-area values are injected by the Android MainActivity.kt and the index.html touch-device fallback, then mapped to the --safe-area-* custom properties used by components (see Safe Area Handling):
:root {
--safe-area-top: var(--safe-area-inset-top, 0px);
--safe-area-bottom: var(--safe-area-inset-bottom, 0px);
--safe-area-left: var(--safe-area-inset-left, 0px);
--safe-area-right: var(--safe-area-inset-right, 0px);
}
/* Touch devices: fall back to CSS env() for system bar insets */
@media (pointer: coarse) {
:root {
--safe-area-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
--safe-area-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
--safe-area-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
--safe-area-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
}
.safe-area-container {
padding-top: max(var(--safe-area-top, 0px), var(--safe-area-fallback-top, 0px));
padding-bottom: max(var(--safe-area-bottom, 0px), var(--safe-area-fallback-bottom, 0px));
}
.safe-area-main {
padding-top: var(--safe-area-top, 0px);
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
}
Use the .safe-area-container class on a desktop panel's root element to avoid notches and system bars. The mobile shell (MobileLayout.tsx) does not use this class — it offsets its absolutely-positioned elements inline (see Safe Area Handling for why):
Platform-Specific Code¶
Rust: #[cfg()] Attributes¶
Stratum uses Rust's conditional compilation to handle platform-specific behavior. The main patterns are:
#[cfg(target_os = "android")]: Code that only runs on Android.
Example from src-tauri/src/commands/vault.rs (Android content URI resolution):
/// Resolve a user-picked path to a real filesystem path.
/// On Android, converts SAF content URI to a real path.
#[cfg(target_os = "android")]
fn resolve_picked_path(picked: &str) -> Result<PathBuf, String> {
let path_encoded = picked
.split("/tree/")
.nth(1)
.ok_or_else(|| format!("Could not parse Android content URI: {}", picked))?;
let path_decoded = percent_decode(path_encoded);
if let Some(subpath) = path_decoded.strip_prefix("primary:") {
Ok(PathBuf::from("/storage/emulated/0").join(subpath))
} else if let Some((volume, subpath)) = path_decoded.split_once(':') {
Ok(PathBuf::from("/storage").join(volume).join(subpath))
} else {
Err(format!("Unrecognized content URI format: {}", picked))
}
}
#[cfg(not(target_os = "android"))]
fn resolve_picked_path(picked: &str) -> Result<PathBuf, String> {
Ok(PathBuf::from(picked))
}
#[cfg(desktop)]: Commands or code that should only be registered on desktop.
Example from src-tauri/src/lib.rs:
The pick_vault_directory command uses tauri_plugin_dialog for a native folder picker, which isn't available on mobile. On mobile, the frontend uses the File System Access API or Android's Storage Access Framework directly.
#[cfg(not(target_os = "android"))]: The inverse. Code for all platforms except Android.
#[cfg_attr(mobile, tauri::mobile_entry_point)]: The mobile entry point attribute.
This attribute marks the run() function as the entry point Tauri calls on mobile platforms.
Default vault path resolution differs by platform:
fn resolve_default_vault_path(_app: &tauri::AppHandle) -> PathBuf {
#[cfg(target_os = "android")]
{
_app.path()
.app_data_dir()
.unwrap_or_else(|_| PathBuf::from("vault"))
.join("StratumVault")
}
#[cfg(not(target_os = "android"))]
{
dirs::home_dir()
.map(|h| h.join("StratumVault"))
.or_else(|| std::env::current_dir().ok().map(|d| d.join("vault")))
.unwrap_or_else(|| PathBuf::from("vault"))
}
}
On Android, the vault lives in the app's private data directory. On desktop, it defaults to ~/StratumVault.
TypeScript: Platform Detection¶
For frontend platform detection, use Tauri's platform API rather than user-agent sniffing:
import { platform } from '@tauri-apps/plugin-platform';
// In an async context
const os = await platform.os(); // 'android', 'ios', 'linux', 'macos', 'windows'
For simple responsive layout decisions, prefer useResponsive over platform checks. Platform checks are only needed when the behavior difference is fundamental (e.g., file picker API differences), not layout.
Android Content URI Handling¶
Android's Storage Access Framework (SAF) returns content URIs like content://com.android.externalstorage.documents/tree/primary%3ADocuments. These need special handling:
- The frontend invokes
init_vaultwith the path string from the SAF picker - The Rust command calls
resolve_picked_path()which parses the URI - On Android, it decodes percent-encoding and converts
primary:paths to/storage/emulated/0/ - On desktop, it passes the path through unchanged
Lifecycle¶
Save on Suspend¶
Mobile operating systems can kill your app at any time when it is in the background. Stratum handles this with a save-on-suspend pattern:
- The editor auto-saves content to the SQLite block store on every change (debounced at 500ms)
- On Android, the
MainActivityreceives the system'sonPause/onStoplifecycle events - Before the app goes to the background, the frontend must flush any pending saves
The Tauri v2 mobile runtime emits the tauri://close-requested event when the app is being suspended. The frontend listens for this:
import { getCurrentWindow } from '@tauri-apps/api/window';
// In App.tsx or editor container
const appWindow = getCurrentWindow();
appWindow.onCloseRequested(async () => {
await flushPendingSaves();
});
Restore on Resume¶
When the app returns to the foreground:
- The SQLite database is reopened (it persists across background/foreground cycles on most devices)
- The index engine reinitializes from the existing database. No rebuild needed.
- The last-opened page is restored from
appStore(which persists state inlocalStorage) - Sync state is rechecked
Android-Specific Lifecycle¶
The MainActivity.kt in src-tauri/android-patches/ handles Android-specific lifecycle needs. On startup it enables edge-to-edge, registers that runtime edge-to-edge listeners report the system-bar insets, and schedules safe-area injection retries (100/500/1500/5000 ms) to cover the Activity-start / WebView-init race:
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets ->
injectSafeArea(insets)
insets
}
scheduleSafeAreaInjection()
}
}
The safe area injection reads the system-bar + display-cutout insets and sets --safe-area-inset-top / --safe-area-inset-bottom (in CSS pixels) on the webview document so the frontend can account for status bars, notches, and navigation bars. See Safe Area Handling for the full implementation and consumption.
Microphone Permission (Android)¶
The E9.F4 one-tap voice-capture feature needs the RECORD_AUDIO permission on
Android. Tauri v2 does not forward onRequestPermissionsResult to Rust, so the
permission dialog is driven entirely from the patched MainActivity.kt:
src-tauri/android-patches/app/src/main/AndroidManifest.xmldeclares<uses-permission android:name="android.permission.RECORD_AUDIO" />.MainActivity.ktregisters anActivityResultLauncher<String>withActivityResultContracts.RequestPermission()and, once the WebView exists (same 100/500/1500/5000 ms retry loop as the safe-area injection), injects aStratumMicJS bridge viaaddJavascriptInterfaceexposingisMicGranted()andrequestMicPermission().- The frontend (
src/components/VoiceCaptureButton.shared.tsx) callswindow.StratumMicon Android.requestMicPermission()launches the system dialog; the Kotlin result handler writes the outcome back by callingwindow.__stratumMicPermissionResolve(granted), resolving the awaiting promise. On desktop/iOS the permission is treated as granted.
iOS Microphone Usage Description¶
iOS requires NSMicrophoneUsageDescription in Info.plist, which lives in the
generated src-tauri/gen/apple project (created by tauri ios init, not
checked in). The CI workflow inserts the key after init via PlistBuddy. See
.github/workflows/ci.yml — the ios build job runs the patch step after the
init step. Without this key the app would crash on the first audio stream.
Known Issues¶
Android¶
| Issue | Description | Workaround |
|---|---|---|
| Content URI parsing | SAF content URI format varies by manufacturer (Samsung, Xiaomi, etc. may differ from stock Android) | The resolve_picked_path function handles standard formats. File bugs for manufacturer-specific URI patterns. |
| File watcher disabled | pkm-watcher (inotify-based) does not work on Android's filesystem due to permission restrictions |
Rely on manual reindex (reindex_vault command) or periodic polling. |
| Git sync limitations | SSH key storage on Android is not fully supported. HTTPS sync with credential helpers works but is untested. | Use manual sync mode. Auto-sync may not be reliable. |
| Large vaults on low-RAM devices | Vaults with 10k+ blocks may cause memory pressure on devices with less than 4GB RAM | The Tantivy index is memory-mapped. SQLite works well under constraints. Most issues come from the frontend rendering large graphs. On constrained devices the graph defaults to the lighter 2D layout; the mobile 3D view is opt-in and falls back to 2D when the device cannot render WebGL (see Mobile 3D Graph View). |
| Soft keyboard overlap | The editor may not always adjust correctly when the soft keyboard appears | android:windowSoftInputMode="adjustResize" is set in the manifest. Use touch-action: pan-y to allow scroll while editing. |
iOS¶
| Issue | Description | Workaround |
|---|---|---|
| Simulator only | CI builds only produce simulator binaries | Local builds with a developer account can produce device binaries. |
| WebKit limits | iOS WebKit may impose stricter memory limits on web content than desktop | Keep DOM size reasonable. Lazy-render large lists. |
| Keyboard handling | Hardware keyboard support on iPad is limited | Software keyboard mode is the primary input method. |
| Background execution | iOS aggressively suspends background apps | Save-on-suspend handling is critical. Test thoroughly. |
Cross-Platform¶
| Issue | Description | Workaround |
|---|---|---|
| Window count | Tauri mobile only supports single-window mode | The app uses one window. Desktop features that rely on multiple windows are unavailable on mobile. |
| Plugin availability | Not all Tauri plugins support mobile targets | Check plugin documentation before adding new plugins. tauri-plugin-dialog is desktop-only. Use platform-detection to conditionally register commands. |
| File system access | SAF on Android vs POSIX paths on desktop are fundamentally different | Always use resolve_picked_path() or abstract file access behind a command wrapper. |
Testing¶
On a Physical Device (Android)¶
Development build:
This builds, installs, and launches the app. Use adb logcat to view Rust logs.
Release APK:
The APK is output to src-tauri/gen/android/app/build/outputs/apk/. Install it with:
On an Emulator (Android)¶
# Start emulator first
emulator -avd Pixel_6_API_34 -no-snapshot
# Build and deploy for x86_64 (much faster for emulator)
cargo tauri android dev --target x86_64
Emulators with x86_64 targets are significantly faster for Rust compilation because they avoid ARM cross-compilation. Use this for rapid iteration on the Rust backend.
Verifying Safe Area Handling (Android)¶
The status-bar offset is not exercised by npm run test (which runs in a plain browser layout). To verify a safe-area change against the real Android WebView:
- Build and deploy a debug APK to an emulator booted with a status bar / gesture-nav (e.g.
Pixel_6_API_34):
-
Confirm the app draws edge-to-edge and the mobile top bar is not under the status bar. The easiest live check is a CDP probe over the adb-forwarded WebView debugging socket (a working example lives in
verification/cdp_probe.mjsfrom the status-bar fix). It asserts thatgetBoundingClientRect().topof the top bar equals the injected--safe-area-inset-topand that the bottom nav fits within the viewport. -
The expected values on the reference emulator are:
{
"safeAreaTop": "48.761906px",
"safeAreaBottom": "24.0px",
"topBarTopPx": 48.761905670166016,
"bottomNavFitsViewport": true
}
The exact pixel values vary by device/emulator; the invariant is that topBarTop == safeAreaTop (top bar offset by the status-bar inset, nothing drawn behind it) and bottom == safeAreaBottom (bottom nav sits above the gesture/navigation bar).
- Record a
screencapand the probe result as review evidence, matching the pattern inverification/(see thec5f3d7ccommit for the recorded evidence).
On the Simulator (iOS)¶
# Apple Silicon Macs (default)
cargo tauri ios dev
# Intel Macs (specify x86_64 simulator)
cargo tauri ios dev --target x86_64-apple-ios
Automated Testing¶
The CI pipeline builds Android on every pull request and push to master (a debug APK, uploaded as the android-debug-apk artifact for the smoke-test gate) and on every tagged release (v*, see .github/workflows/ci.yml). The android job:
- Sets up Android SDK and NDK
- Adds all Android Rust targets
- Runs
tauri android init - Applies Android patches
- On tags: runs
tauri android build --target aarch64 --apk --aabto produce a release APK and AAB signed with the release keystore from CI secrets - On PRs/pushes: runs
tauri android build --debug --target aarch64 x86_64 --apkto produce a debug APK for the CI gate and on-device smoke test (the emulator is x86_64, so the debug APK must ship the x86_64 ABI) - Uploads the APK/AAB (tags) or debug APK (PRs/pushes) as build artifacts
A separate android-smoke job (PRs / pushes to master) then boots a headless x86_64 emulator (API 34), installs the debug APK, launches the app and waits for the first rendered frame. GitHub-hosted Linux runners do not expose a working /dev/kvm to the runner user, so the emulator runs in software-emulation mode (-accel off); the smoke script is deliberately robust to that (see smoke-android.sh header) and treats the Android platform's own Displayed first-frame signal as the pass criterion, retrying the launch when the unaccelerated system force-finishes a healthy activity. It archives a screenshot (screencap) and a logcat dump as android-smoke-evidence so reviewers have proof the app starts, and it fails the run if the app crashes (process death) or never reaches a first frame within the timeout. No physical device is required — the emulator runs on the same GitHub-hosted runner.
The ios job:
- Installs iOS Rust targets
- Runs
tauri ios init(generatessrc-tauri/gen/appleon first run) - Patches the Xcode project for Tauri compatibility
- Runs
tauri ios build --debug --target aarch64-sim --ci - Zips the built app
- Uploads as a build artifact
iOS is built for the simulator only (aarch64-sim) — this requires no Apple
Developer signing certificate or provisioning profile, so it runs on every
pull request and push to master as a CI gate (debug build, artifact
ios-sim-app) as well as on tagged releases (v*, release build, artifact
ios). The test job must pass first. Physical-device builds still require a
paid Apple Developer account and are done locally by developers.
The Android release-signing keystore secrets (ANDROID_KEYSTORE,
ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD) must
be set on the repository to sign release artifacts; the debug APK does not need
them.
Manual Test Checklist¶
Before shipping a mobile change, verify:
- [x] App loads and draws its first frame on Android — covered automatically by the
android-smokeCI job (headless emulator) - [ ] App launches on iOS simulator
- [ ] Vault creation and opening works
- [ ] Block editor loads and saves content
- [ ] Wiki-link autocomplete works
- [ ] Search returns results
- [ ] Graph view renders (may be slow on low-end devices)
- [ ] Mobile graph 3D toggle: enabling 3D on a supported device renders
GraphCanvas; on an unsupported device the toggle is disabled and the app falls back to 2D with the dismissible notice - [ ] Back button / gesture navigation works correctly
- [ ] Keyboard does not obscure the editor
- [ ] App recovers from backgrounding (save + restore)
- [ ] Orientation changes don't break layout
- [ ] Safe area insets are respected on notched devices
Vault Storage on Android¶
On Android, the vault is stored in the app's private internal storage at /data/user/0/app.stratum/StratumVault/. This is necessary because:
- SQLite (
rusqlite) requires a real filesystem path — it cannot work with Android SAFcontent://URIs - git (
gix/gitoxide) also requires a real filesystem path for the.git/directory - Android's scoped storage (API 30+) blocks raw filesystem writes to
/storage/emulated/0/
The vault directory contains:
/data/user/0/app.stratum/StratumVault/
├── .pkm/
│ ├── blocks.db # SQLite block store
│ ├── config.toml # App configuration
│ └── search.idx # Tantivy search index
├── journals/
│ └── 2025-01-01.md # Daily notes
├── pages/
│ └── My Note.md # User pages
└── .git/ # Git repository (via gix)
Why not external/SD storage?¶
Android's scoped storage (API 30+, ~95% of active devices) prevents apps from writing to arbitrary external storage paths using std::fs. The Storage Access Framework (SAF) provides content:// URIs, but:
- rusqlite cannot open databases from SAF URIs
- gix cannot initialize git repositories on SAF URIs
Export/import commands use the tauri-plugin-android-fs SAF APIs to let users back up or restore their vault to/from external storage.
Backup¶
- Android Auto Backup: The vault is automatically backed up to Google Drive (when enabled on the device). This is controlled by
android:allowBackup="true"inAndroidManifest.xml. - Git remote: Configure a git remote in Settings → Sync to push your vault to GitHub/GitLab. Clone on desktop for full
.mdfile access. - Manual export: Use the export command (planned) to copy vault files to a user-selected SAF location.
Building APKs¶
Debug APK (signed, for testing)¶
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.1.12297006"
export NDK="$ANDROID_NDK_HOME"
export PATH="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
# Set cross-compilation environment variables for all targets
export CC_aarch64_linux_android="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang"
export AR_aarch64_linux_android="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
export CC_x86_64_linux_android="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android21-clang"
export AR_x86_64_linux_android="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
export CC_armv7_linux_androideabi="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang"
export AR_armv7_linux_androideabi="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
export CC_i686_linux_android="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android21-clang"
export AR_i686_linux_android="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
export JAVA_HOME="/usr/lib/jvm/java-21-openjdk"
# Copy frontend assets and Android patches
cp -r dist/. src-tauri/gen/android/app/src/main/assets/
cp -r src-tauri/android-patches/app/src/main/* src-tauri/gen/android/app/src/main/
# Build signed debug APK
npx tauri android build --debug --target aarch64 --apk
The debug APK is output at:
Important: The debug APK uses applicationIdSuffix = ".debug" so it installs as app.stratum.debug alongside any release build. It is auto-signed with the debug keystore at ~/.android/debug.keystore.
Release APK (signed, for distribution)¶
Release APKs and AABs are signed at build time by Gradle using the release signing config in src-tauri/android-patches/app/build.gradle.kts, which reads the keystore credentials from the environment (set by CI from GitHub secrets). For local release builds, set these before building:
export ANDROID_KEYSTORE=/path/to/release.keystore
export ANDROID_KEYSTORE_PASSWORD=...
export ANDROID_KEY_ALIAS=...
export ANDROID_KEY_PASSWORD=...
If these are not set, the signing config falls back to the checked-in debug keystore (src-tauri/android-patches/debug.keystore) so local/CI builds still succeed.
APK Signing¶
Release signing happens inside the Gradle build (see signingConfigs.release in src-tauri/android-patches/app/build.gradle.kts). The CI workflow decodes the base64 ANDROID_KEYSTORE secret to a keystore file and exports the signing env vars, so release artifacts are signed with the production keystore. The signing config enables V1, V2, and V3 signing:
- V2 signing is required for Android 11+ (API 30+) when
targetSdkVersion >= 30. - V1 provides backward compatibility for older Android versions.
- V3 supports key rotation.
Safe Area Handling¶
Android's edge-to-edge display mode renders the WebView behind the system bars (status bar and gesture/navigation bar). Stratum handles the resulting screen insets entirely in the Android patch layer and the frontend. There is no edge-to-edge or safe-area switch in tauri.conf.json; edge-to-edge is enabled programmatically in the patched MainActivity.kt.
1. Required configuration (src-tauri/tauri.conf.json)¶
The bundle.android section only pins the SDK floor and the debug applicationId suffix:
minSdkVersion: 26is required because the audio backend uses AAudio (API 26+). Do not lower it.debugApplicationIdSuffix: ".debug"makes debug builds install asapp.stratum.debug, alongside any release build.- The release
applicationIdis derived from theidentifier(app.stratum). - Edge-to-edge is not a config key. It is enabled in the patched
MainActivity.kt(below). The Android patches undersrc-tauri/android-patches/are copied over the generatedsrc-tauri/gen/androidproject before building — both locally and by CI.
2. MainActivity.kt — system-inset injection¶
The patched activity at src-tauri/android-patches/app/src/main/java/app/stratum/MainActivity.kt:
- Calls
enableEdgeToEdge()so the WebView draws behind the system bars. - Registers an
OnApplyWindowInsetsListeneron the decor view that capturessystemBars() + displayCutout()insets and injects them. - Re-injects on a timer via
scheduleSafeAreaInjection()(delays of 100, 500, 1500, and 5000 ms) to survive the race between Activity start and WebView initialization. injectSafeArea()walks the view tree to find theWebView, converts the raw pixel insets to CSS pixels using the display density, and sets the CSS custom properties--safe-area-inset-topand--safe-area-inset-bottomondocument.documentElementviaevaluateJavascript().
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets ->
injectSafeArea(insets)
insets
}
scheduleSafeAreaInjection()
}
private fun injectSafeArea(insets: WindowInsetsCompat) {
val wv = findWebView() ?: return
val sb = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or
WindowInsetsCompat.Type.displayCutout()
)
val topDp = sb.top / resources.displayMetrics.density
val bottomDp = sb.bottom / resources.displayMetrics.density
val js = "(function(){" +
"var s=document.documentElement.style;" +
"s.setProperty('--safe-area-inset-top','${topDp}px');" +
"s.setProperty('--safe-area-inset-bottom','${bottomDp}px');" +
"})()"
wv.evaluateJavascript(js, null)
}
3. index.html — viewport meta + touch-device fallback¶
The viewport meta is set to viewport-fit=cover so the WebView extends into the safe areas:
An inline script in index.html provides a fallback for when the Kotlin injection has not run. It only acts on touch-capable clients (ontouchstart in window or navigator.maxTouchPoints), does nothing if --safe-area-inset-* is already set (so it never clobbers the Kotlin values), and otherwise derives the insets from visualViewport (capped to 60 CSS px, ignoring browser-chrome offsets on desktop).
4. CSS variables (src/global.css)¶
src/global.css maps the injected/derived insets to the --safe-area-* custom properties the components consume:
:root {
--safe-area-top: var(--safe-area-inset-top, 0px);
--safe-area-bottom: var(--safe-area-inset-bottom, 0px);
--safe-area-left: var(--safe-area-inset-left, 0px);
--safe-area-right: var(--safe-area-inset-right, 0px);
}
/* Touch devices: fall back to env() when the injected variable is absent */
@media (pointer: coarse) {
:root {
--safe-area-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
--safe-area-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
--safe-area-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
--safe-area-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
}
}
- On touch devices,
.safe-area-containerusespadding-top/bottom: max(var(--safe-area-top), var(--safe-area-fallback-top))and.safe-area-mainusespadding-top: var(--safe-area-top, 0px). The--safe-area-fallback-*andenv()fallback only apply inside thepointer: coarsemedia query — desktop components never see them. - Desktop (pointer: fine) deliberately avoids
env()because WebKitGTK / some compositors report non-zero safe-area values on desktop, which would break layouts.
5. Component offsets¶
- Desktop (
App.tsx): the root Box usesclassName="safe-area-container"and the<main>element usesclassName="safe-area-main". - Mobile shell (
MobileLayout.tsx): does not use the padding class. Its absolutely-positioned top bar, error banner, and content area are offset with inline styles usingvar(--safe-area-top, 0px)(via theSAFE_AREA_TOPconstant), because a padding-based container class cannot offset absolutely-positioned children. On desktop this variable resolves to0, so it is a no-op outside devices with system bars. - Landing / empty state (
VaultPicker.tsx): reserves the insets withpaddingTop: 'var(--safe-area-top, 0px)'andpaddingBottom: 'var(--safe-area-bottom, 0px)'. - Bottom navigation (
MobileNav.tsx): the fixed bottomPapersetspaddingBottom: 'var(--safe-area-bottom)'so the gesture/navigation bar is not overlapped.
Troubleshooting¶
| Problem | Likely Cause | Fix |
|---|---|---|
Operation not permitted writing to vault |
Android scoped storage — vault must be in private data dir | Vault auto-creates in /data/user/0/app.stratum/. Do NOT use folder picker for vault location. |
package invalid on APK install |
APK is unsigned or uses wrong signature scheme | Build with --debug flag, or sign with apksigner --v2-signing-enabled true |
INSTALL_FAILED_INVALID_APK |
Architecture mismatch | Build with correct --target for your device (aarch64 for most phones) |
| App content behind system bars | A component draws over the status bar / gesture bar on Android | Components must consume the safe-area insets. The mobile shell, landing view, and bottom nav already offset via inline styles using --safe-area-top / --safe-area-bottom (see Safe Area Handling). New full-screen mobile components must do the same. |
| WebView safe area values are 0px | The activity's inset injection or the index.html fallback did not run before the frame captured |
Rely on the Kotlin MainActivity injection (retries at 100/500/1500/5000 ms) plus the index.html touch-device visualViewport fallback. Desktop should report 0px by design — the env() fallback is gated to pointer: coarse so compositor-reported inset values do not break desktop layouts. |