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.ktwith edge-to-edge display and safe area injection - 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.
CSS for Mobile¶
The src/global.css file includes mobile-specific touch handling:
/* Safe area integration (injected by Android MainActivity / iOS WebKit) */
: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));
}
/* Coarse pointer = touch device — prevent scroll conflicts with editor */
@media (pointer: coarse) {
.blocknote-editor-container {
touch-action: pan-y;
}
.bn-editor {
touch-action: pan-y;
}
}
Use the .safe-area-container class on your panel's root element to avoid notches and system bars:
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:
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
scheduleSafeAreaInjection()
}
private fun scheduleSafeAreaInjection() {
// Injects safe area insets as CSS custom properties
// Retries at 100ms, 500ms, and 1500ms to handle race conditions
}
private fun injectSafeArea() {
// Reads system bar insets and sets CSS variables:
// --safe-area-inset-top, --safe-area-inset-bottom
}
}
The safe area injection sets CSS custom properties on the webview document so the frontend can account for status bars, notches, and navigation bars.
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. |
| 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.
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 runs Android and iOS builds on every tagged release (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
- Runs
tauri android build --target aarch64 --apk - Signs the APK (if keystore is configured)
- Uploads the APK and AAB as build artifacts
The ios job:
- Installs iOS Rust targets
- Runs
tauri ios init(if not already initialized) - Patches the Xcode project for Tauri compatibility
- Runs
tauri ios build --target aarch64-sim --ci - Zips the built app
- Uploads as a build artifact
Both jobs run on tag pushes (v*) and require the test job to pass first.
Manual Test Checklist¶
Before shipping a mobile change, verify:
- [ ] App launches on Android (physical device or 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)
- [ ] 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 (unsigned, for distribution)¶
Release APKs are unsigned and must be signed before installation. The CI pipeline signs them using apksigner with a keystore from GitHub secrets.
APK Signing¶
The CI workflow (.github/workflows/ci.yml) signs release APKs with:
apksigner sign \
--v1-signing-enabled true \
--v2-signing-enabled true \
--v3-signing-enabled true \
--ks "$KEYSTORE_PATH" \
--ks-type PKCS12 \
--ks-pass "pass:$KEYSTORE_PASSWORD" \
--ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASSWORD" \
"$APK"
V2 signing is required for Android 11+ (API 30+) when targetSdkVersion >= 30. V1 provides backward compatibility.
Safe Area Handling¶
Android's edge-to-edge display mode renders the WebView behind system bars (status bar, navigation bar). Stratum handles this with:
1. MainActivity.kt — System insets injection¶
The patched MainActivity.kt at src-tauri/android-patches/:
- Calls
enableEdgeToEdge()to draw behind system bars - Registers an
OnApplyWindowInsetsListenerthat captures system bar and display cutout insets - Injects the inset values as CSS custom properties on the
<html>element: --safe-area-inset-top--safe-area-inset-bottom
2. CSS variables (src/global.css)¶
: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-container {
padding-top: var(--safe-area-top);
padding-bottom: var(--safe-area-bottom);
}
3. React layout classes¶
- Desktop layout (
App.tsx): UsesclassName="safe-area-container"on the root Box - Mobile layout (
MobileLayout.tsx): Also usesclassName="safe-area-container"on the root Box
4. viewport-fit=cover meta tag¶
The index.html includes <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> which tells the WebView to extend into the safe areas.
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 | Missing safe area padding | Ensure MobileLayout.tsx has className="safe-area-container" |
| WebView safe area values are 0px | Chromium < 140 has a bug with env(safe-area-inset-*) |
Rely on Kotlin injection (works on all versions) |