Complete guide to building the Android version of Stash
OPTION A: Native Android (Kotlin)
✅ Best performance
✅ Full platform integration
✅ Native UI components
❌ More development time
OPTION B: WebView Wrapper (Current)
✅ Quick deployment
✅ Code reuse from web
✅ Easy updates
❌ Less native feel
OPTION C: React Native
✅ Cross-platform (iOS + Android)
✅ Fast development
✅ Hot reload
❌ Bridge overhead
OPTION D: Flutter
✅ Beautiful UI
✅ Fast performance
✅ Single codebase
❌ Learning curve
RECOMMENDED: Start with WebView (B), migrate to Native (A) later
┌─────────────────────────────────────┐
│ MainActivity.kt │
│ ┌───────────────────────────────┐ │
│ │ WebView │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ Next.js App │ │ │
│ │ │ (React) │ │ │
│ │ │ │ │ │
│ │ │ ↕️ JavaScript Bridge │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
│ ↕️ │
│ ┌───────────────────────────────┐ │
│ │ Native Features │ │
│ │ • Share Intent │ │
│ │ • Back Button │ │
│ │ • Status Bar │ │
│ │ • Haptics │ │
│ │ • Biometric Auth │ │
│ │ • Notifications │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
buildscript {
ext.kotlin_version = "1.9.0"
dependencies {
classpath "com.android.tools.build:gradle:8.1.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
}
android {
compileSdk 34
defaultConfig {
applicationId "com.stash.app"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.0"
}
buildFeatures {
viewBinding true
}
}
dependencies {
// AndroidX
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.webkit:webkit:1.8.0'
// Material Design
implementation 'com.google.android.material:material:1.10.0'
// WebView
implementation 'androidx.webkit:webkit:1.8.0'
// Biometric
implementation 'androidx.biometric:biometric:1.1.0'
// WorkManager
implementation 'androidx.work:work-runtime-ktx:2.9.0'
// Image Loading (optional)
implementation 'com.github.bumptech.glide:glide:4.16.0'
kapt 'com.github.bumptech.glide:compiler:4.16.0'
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
}package com.stash.app
import android.content.Intent
import android.os.Bundle
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import com.stash.app.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var webView: WebView
private lateinit var webAppInterface: WebAppInterface
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupWebView()
handleIntent(intent)
}
private fun setupWebView() {
webView = binding.webView
// Enable JavaScript
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
// JavaScript Interface
webAppInterface = WebAppInterface(this, webView)
webView.addJavascriptInterface(webAppInterface, "Android")
// Chrome Client
webView.webChromeClient = WebChromeClient()
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// Handle external links
val url = request?.url.toString()
if (url.startsWith("stash://")) {
handleDeepLink(url)
return true
}
return false
}
}
// Load app
webView.loadUrl("file:///android_asset/index.html")
}
private fun handleIntent(intent: Intent?) {
if (intent?.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
val sharedUrl = intent.getStringExtra(Intent.EXTRA_TEXT)
sharedUrl?.let {
// Wait for WebView to load
webView.post {
webAppInterface.receiveSharedUrl(it)
}
}
}
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onBackPressed() {
// Ask WebView if it handled back
webView.evaluateJavascript(
"(function() { return window.stash?.handleBack?.(); })()"
) { result ->
if (result != "true") {
super.onBackPressed()
}
}
}
private fun handleDeepLink(url: String) {
when {
url.startsWith("stash://add") -> {
webAppInterface.openAddLink()
}
url.startsWith("stash://search") -> {
webAppInterface.openSearch()
}
}
}
}package com.stash.app
import android.app.Activity
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView
import org.json.JSONArray
class WebAppInterface(
private val activity: Activity,
private val webView: WebView
) {
// ═══════════════════════════════════════
// Share Intent
// ═══════════════════════════════════════
fun receiveSharedUrl(url: String) {
webView.evaluateJavascript("""
window.dispatchEvent(new CustomEvent('stash:share', {
detail: { url: '${escapeJs(url)}' }
}));
""".trimIndent(), null)
}
@JavascriptInterface
fun openAddLink() {
webView.post {
webView.evaluateJavascript(
"window.stash?.openAddLink?.()",
null
)
}
}
// ═══════════════════════════════════════
// Status Bar
// ═══════════════════════════════════════
@JavascriptInterface
fun setStatusBar(color: String, style: String) {
activity.runOnUiThread {
try {
val window = activity.window
window.statusBarColor = Color.parseColor(color)
val decorView = window.decorView
var flags = decorView.systemUiVisibility
flags = if (style == "light") {
flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
} else {
flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
}
decorView.systemUiVisibility = flags
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// ═══════════════════════════════════════
// Haptic Feedback
// ═══════════════════════════════════════
@JavascriptInterface
fun vibrate(duration: Int) {
activity.runOnUiThread {
val vibrator = activity.getSystemService(
Context.VIBRATOR_SERVICE
) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createOneShot(
duration.toLong(),
VibrationEffect.DEFAULT_AMPLITUDE
)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(duration.toLong())
}
}
}
@JavascriptInterface
fun vibratePattern(patternJson: String) {
activity.runOnUiThread {
try {
val jsonArray = JSONArray(patternJson)
val timings = LongArray(jsonArray.length()) {
jsonArray.getLong(it)
}
val vibrator = activity.getSystemService(
Context.VIBRATOR_SERVICE
) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createWaveform(timings, -1)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(timings, -1)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// ═══════════════════════════════════════
// Toast Messages
// ═══════════════════════════════════════
@JavascriptInterface
fun showToast(message: String) {
activity.runOnUiThread {
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
}
}
// ═══════════════════════════════════════
// Search
// ═══════════════════════════════════════
@JavascriptInterface
fun openSearch() {
webView.post {
webView.evaluateJavascript(
"window.stash?.openCommand?.()",
null
)
}
}
// ═══════════════════════════════════════
// Utilities
// ═══════════════════════════════════════
private fun escapeJs(str: String): String {
return str.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
}
}<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.stash.app">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".StashApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Stash"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<!-- Main launcher -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share Intent -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Deep Links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="stash" />
</intent-filter>
</activity>
<!-- App Shortcuts -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</application>
</manifest><?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="add_link"
android:enabled="true"
android:icon="@drawable/ic_add"
android:shortcutShortLabel="@string/shortcut_add"
android:shortcutLongLabel="@string/shortcut_add_long">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.stash.app.MainActivity"
android:data="stash://add" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="search"
android:enabled="true"
android:icon="@drawable/ic_search"
android:shortcutShortLabel="@string/shortcut_search">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.stash.app.MainActivity"
android:data="stash://search" />
</shortcut>
</shortcuts>Stash uses Capacitor 8 with Next.js static HTML export (output: 'export'). The web application is compiled into static assets and synced directly into android/app/src/main/assets/public/.
┌─────────────────────────┐
│ Next.js Source │
│ (React 19 + App Router)│
└────────────┬────────────┘
│ node scripts/build-mobile.js
│ • Temporarily isolates dynamic API routes (app/api -> app/_api_disabled)
│ • Builds static bundles & HTML pages
▼
┌─────────────────────────┐
│ /out Folder │
│ (Static HTML/CSS/JS) │
└────────────┬────────────┘
│ npx cap sync android
│ • Copies /out -> android/app/src/main/assets/public/
│ • Generates android/app/src/main/assets/capacitor.config.json
│ • Updates native Capacitor Android plugins & gradle bindings
▼
┌──────────────────────────────────────────┐
│ Android Native App Assets │
│ android/app/src/main/assets/public/ │
│ (index.html, about.html, _next/, etc.) │
└──────────────────────────────────────────┘
We provide preconfigured scripts in package.json for automated asset rebuilding and syncing:
| Action | Command | What It Does |
|---|---|---|
| Recreate Assets | npm run ncap:build |
Cleans cache, exports Next.js static HTML to /out. |
| Sync Assets | npm run ncap:sync |
Syncs /out to Android assets & updates plugins. |
| Build + Sync + Run | npm run ncap:run |
Rebuilds, syncs, and launches app on connected device/emulator. |
| Open Android Studio | npm run ncap:open |
Opens the android/ project in Android Studio. |
| Action | Command | What It Does |
|---|---|---|
| Recreate Assets | bun run bcap:build |
Fast static export build to /out. |
| Sync Assets | bun run bcap:sync |
Syncs assets to Android project via bunx. |
| Build + Sync + Run | bun run bcap:run |
Full automated rebuild, sync, and launch. |
| Open Android Studio | bun run bcap:open |
Opens Android Studio directly. |
Run the mobile build script. This script automatically handles Next.js App Router dynamic route isolation during static generation:
node scripts/build-mobile.jsOutput: Statically rendered HTML pages (index.html, about.html, folder.html), stylesheets, fonts, and client bundles in /out.
Sync the compiled static files and Capacitor configuration into the native Android assets directory:
npx cap sync androidWhat happens under the hood:
- Web assets from
/outare copied toandroid/app/src/main/assets/public/. capacitor.config.jsonis generated inandroid/app/src/main/assets/.- Android plugins (
@capacitor/app,@capacitor/haptics,@capacitor/share,@capacitor/status-bar) are updated inandroid/capacitor-cordova-android-plugins/.
Ensure that the assets exist in the destination path:
# Windows PowerShell
Get-ChildItem android/app/src/main/assets/public
# macOS / Linux
ls -la android/app/src/main/assets/publicOption A: Command-Line Debug APK Build
cd android
./gradlew assembleDebug
# Generated APK: android/app/build/outputs/apk/debug/app-debug.apkOption B: Live Run on Connected Device or Emulator
npx cap run androidOption C: Release Build & Signing
cd android
./gradlew assembleRelease
# Sign APK with your release key:
jarsigner -verbose -sigalg SHA256withRSA \
-digestalg SHA-256 -keystore stash-key.jks \
app/build/outputs/apk/release/app-release-unsigned.apk stash
# Align and optimize APK:
zipalign -v 4 app/build/outputs/apk/release/app-release-unsigned.apk stash-release.apkTip
Why do we use scripts/build-mobile.js instead of standard next build?
Next.js static exports (output: 'export') reject dynamic Route Handlers (like /api/scrape and /api/proxy-image). The build-mobile.js script temporarily renames app/api to app/_api_disabled during the build and restores it immediately in a finally block, ensuring web server routes remain intact while allowing zero-error static exports for Android.
Warning
Assets not reflecting recent UI changes?
Run a clean rebuild:
# Remove stale output and re-sync
rm -rf .next out android/app/src/main/assets/public
npm run ncap:build
npm run ncap:syncFUNCTIONALITY:
[ ] App launches
[ ] WebView loads
[ ] Share intent works
[ ] Back button works
[ ] Status bar syncs
[ ] Haptics work
[ ] Add link works
[ ] Search works
[ ] Data persists
PERFORMANCE:
[ ] Smooth scrolling
[ ] No lag on interactions
[ ] Fast startup time
[ ] Memory usage acceptable
UI/UX:
[ ] Status bar color correct
[ ] Fullscreen mode
[ ] Proper safe areas
[ ] Portrait lock
[ ] Loading screen
PLATFORM:
[ ] Works on Android 7+ (API 24+)
[ ] Works on various screen sizes
[ ] Works on tablets
[ ] RTL layout support (if needed)
android/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/com/stash/app/
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── WebAppInterface.kt
│ │ │ │ └── StashApplication.kt
│ │ │ ├── res/
│ │ │ │ ├── layout/
│ │ │ │ │ └── activity_main.xml
│ │ │ │ ├── values/
│ │ │ │ │ ├── strings.xml
│ │ │ │ │ ├── themes.xml
│ │ │ │ │ └── colors.xml
│ │ │ │ ├── drawable/
│ │ │ │ ├── mipmap/
│ │ │ │ └── xml/
│ │ │ │ └── shortcuts.xml
│ │ │ ├── assets/
│ │ │ │ └── [Web app files]
│ │ │ └── AndroidManifest.xml
│ │ └── debug/
│ └── build.gradle
├── gradle/
├── build.gradle
└── settings.gradle
Ready for Android development! 🎉