Skip to content

Latest commit

 

History

History
707 lines (581 loc) · 22.1 KB

ANDROID_IMPLEMENTATION.md

File metadata and controls

707 lines (581 loc) · 22.1 KB

Android App Implementation Guide

Complete guide to building the Android version of Stash


📱 Project Setup

Tech Stack Recommendation

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

🏗️ Architecture

Hybrid Architecture (WebView + Native)

┌─────────────────────────────────────┐
│         MainActivity.kt             │
│  ┌───────────────────────────────┐ │
│  │         WebView               │ │
│  │  ┌─────────────────────────┐ │ │
│  │  │    Next.js App          │ │ │
│  │  │    (React)              │ │ │
│  │  │                         │ │ │
│  │  │  ↕️ JavaScript Bridge   │ │ │
│  │  └─────────────────────────┘ │ │
│  └───────────────────────────────┘ │
│              ↕️                     │
│  ┌───────────────────────────────┐ │
│  │    Native Features            │ │
│  │  • Share Intent               │ │
│  │  • Back Button                │ │
│  │  • Status Bar                 │ │
│  │  • Haptics                    │ │
│  │  • Biometric Auth             │ │
│  │  • Notifications              │ │
│  └───────────────────────────────┘ │
└─────────────────────────────────────┘

📦 Dependencies

build.gradle (Project)

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"
    }
}

build.gradle (App)

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'
}

🔧 Core Files

1. MainActivity.kt

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()
            }
        }
    }
}

2. WebAppInterface.kt

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")
    }
}

3. AndroidManifest.xml

<?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>

4. res/xml/shortcuts.xml

<?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>

🚀 Build, Sync & Asset Recreation Process (Capacitor Workflow)

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/.

🔄 The Asset Sync & Recreation Pipeline

┌─────────────────────────┐
│     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.) │
└──────────────────────────────────────────┘

⚡ Quick Commands (NPM & Bun)

We provide preconfigured scripts in package.json for automated asset rebuilding and syncing:

Using NPM:

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.

Using Bun:

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.

🛠️ Step-by-Step Manual Workflow

Step 1: Recreate Web Assets (Static Export)

Run the mobile build script. This script automatically handles Next.js App Router dynamic route isolation during static generation:

node scripts/build-mobile.js

Output: Statically rendered HTML pages (index.html, about.html, folder.html), stylesheets, fonts, and client bundles in /out.

Step 2: Sync to Android Assets

Sync the compiled static files and Capacitor configuration into the native Android assets directory:

npx cap sync android

What happens under the hood:

  • Web assets from /out are copied to android/app/src/main/assets/public/.
  • capacitor.config.json is generated in android/app/src/main/assets/.
  • Android plugins (@capacitor/app, @capacitor/haptics, @capacitor/share, @capacitor/status-bar) are updated in android/capacitor-cordova-android-plugins/.

Step 3: Verify Android Assets

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/public

Step 4: Build or Run the Android App

Option A: Command-Line Debug APK Build

cd android
./gradlew assembleDebug
# Generated APK: android/app/build/outputs/apk/debug/app-debug.apk

Option B: Live Run on Connected Device or Emulator

npx cap run android

Option 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.apk

💡 Troubleshooting Asset Syncing

Tip

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:sync

📱 Testing Checklist

FUNCTIONALITY:
[ ] 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)

📊 File Structure

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! 🎉