Skip to content

Commit 7a97cf9

Browse files
Kotlin Android scaffold: Gradle wrapper, AGP 8.7 + Kotlin 2.0, MainActivity + JssService + NodeBridge + AssetCopier
Validated end-to-end on this dev box (Gradle 8.10.2, JDK 21, Android SDK 35) up to where libnode is the only missing piece: ./gradlew :app:tasks -> BUILD SUCCESSFUL (project parses) ./gradlew :app:processDebugResources -> BUILD SUCCESSFUL (manifest + icons + theme) ./gradlew :app:compileDebugKotlin -> BUILD SUCCESSFUL (no warnings) CMake/native build is wired up but will only succeed once scripts/fetch-libnode.sh has populated app/libnode/. Same for scripts/bundle-jss.sh -> app/src/main/assets/jss/. Both scripts are .gitignored on output; the build chain reads from those paths. Components: - gradle/wrapper/{gradle-wrapper.jar, gradle-wrapper.properties} + gradlew (Gradle 8.10.2 from services.gradle.org) - settings.gradle.kts, build.gradle.kts (root, AGP 8.7.3 + Kotlin 2.0.21) - app/build.gradle.kts: compileSdk 35, minSdk 24, ABI splits for arm64-v8a + armeabi-v7a (drops x86_64 to halve APK size), CMake externalNativeBuild - AndroidManifest.xml: INTERNET, FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC (Android 14+), POST_NOTIFICATIONS (Android 13+); JssService declared as foregroundServiceType=dataSync - res/xml/network_security_config.xml: cleartext allowed only for 127.0.0.1 + localhost; everything else stays HTTPS-only - res/mipmap-anydpi-v26 + drawable: placeholder JSS-wordmark adaptive icon (TODO: real launcher icon before public release) Source files: - NodeBridge.kt: thin JNI wrapper, loads libnode + native-lib once per process, exposes startNodeWithArguments(args) - AssetCopier.kt: stamp-aware asset copy from APK assets/jss/ to filesDir/jss/ (skips on cached lastUpdateTime); pattern lifted with attribution from nodejs-mobile-react-native (MIT) - JssService.kt: foreground service, persistent notification with Stop action, polls 127.0.0.1:4443 with 150ms backoff and 30s deadline, broadcasts ACTION_READY when JSS responds - MainActivity.kt: WebView shell, loads URL only after ACTION_READY, modern OnBackPressedCallback for in-WebView back navigation, external links bounce to the system browser (avoids in-WebView OAuth capture) - native-lib.cpp: ~95 LOC JNI shim adapted from JaneaSystems samples (MIT) — pipes stdout/stderr to logcat, calls node::Start The launch args mirror the spike-verified CLI surface: node bin/jss.js start --single-user --port 4443 --host 127.0.0.1 \ --root <filesDir>/data --idp
1 parent 764d012 commit 7a97cf9

23 files changed

Lines changed: 1055 additions & 0 deletions

app/build.gradle.kts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
plugins {
2+
id("com.android.application")
3+
id("org.jetbrains.kotlin.android")
4+
}
5+
6+
android {
7+
namespace = "live.jss.jss_android"
8+
compileSdk = 35
9+
ndkVersion = "27.2.12479018"
10+
11+
defaultConfig {
12+
applicationId = "live.jss.jss_android"
13+
minSdk = 24
14+
targetSdk = 35
15+
versionCode = 1
16+
versionName = "0.1.0"
17+
18+
externalNativeBuild {
19+
cmake {
20+
cppFlags("-std=c++17", "-fexceptions", "-frtti")
21+
arguments(
22+
"-DANDROID_STL=c++_shared",
23+
"-DANDROID_PLATFORM=android-24"
24+
)
25+
}
26+
}
27+
}
28+
29+
// Drop x86_64 unless you need emulator builds; arm + arm64 cover all
30+
// physical devices since 2020. splits.abi produces one APK slice per
31+
// ABI; the AAB picks the right one per device.
32+
splits {
33+
abi {
34+
isEnable = true
35+
reset()
36+
include("arm64-v8a", "armeabi-v7a")
37+
isUniversalApk = false
38+
}
39+
}
40+
41+
buildTypes {
42+
release {
43+
isMinifyEnabled = false
44+
proguardFiles(
45+
getDefaultProguardFile("proguard-android-optimize.txt"),
46+
"proguard-rules.pro"
47+
)
48+
}
49+
debug {
50+
isDebuggable = true
51+
}
52+
}
53+
54+
compileOptions {
55+
sourceCompatibility = JavaVersion.VERSION_17
56+
targetCompatibility = JavaVersion.VERSION_17
57+
}
58+
59+
kotlinOptions {
60+
jvmTarget = "17"
61+
}
62+
63+
externalNativeBuild {
64+
cmake {
65+
path = file("src/main/cpp/CMakeLists.txt")
66+
version = "3.22.1"
67+
}
68+
}
69+
70+
sourceSets {
71+
getByName("main") {
72+
// libnode.so + headers vendored by scripts/fetch-libnode.sh.
73+
// Both paths are .gitignored; CI populates them before build.
74+
jniLibs.srcDirs("../app/libnode/bin")
75+
}
76+
}
77+
78+
packaging {
79+
jniLibs {
80+
useLegacyPackaging = false
81+
}
82+
}
83+
}
84+
85+
dependencies {
86+
implementation("androidx.core:core-ktx:1.13.1")
87+
implementation("androidx.appcompat:appcompat:1.7.0")
88+
implementation("androidx.activity:activity-ktx:1.9.3")
89+
implementation("androidx.webkit:webkit:1.12.1")
90+
implementation("com.google.android.material:material:1.12.0")
91+
}

app/proguard-rules.pro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Empty for now. JSS itself is JavaScript loaded at runtime; the Kotlin/Java
2+
# surface is small enough that R8 default rules suffice. Add app-specific
3+
# `-keep` rules here if a release build starts stripping JNI symbols.

app/src/main/AndroidManifest.xml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
<uses-permission android:name="android.permission.INTERNET" />
5+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
6+
<!-- Android 14+: typed foreground-service permission. dataSync covers
7+
"running a server that the user is interacting with via the WebView." -->
8+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
9+
<!-- Android 13+: ask before posting the persistent notification. -->
10+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
11+
12+
<application
13+
android:label="@string/app_name"
14+
android:icon="@mipmap/ic_launcher"
15+
android:roundIcon="@mipmap/ic_launcher_round"
16+
android:theme="@style/Theme.JssAndroid"
17+
android:allowBackup="true"
18+
android:fullBackupContent="true"
19+
android:networkSecurityConfig="@xml/network_security_config"
20+
android:supportsRtl="true">
21+
22+
<activity
23+
android:name=".MainActivity"
24+
android:exported="true"
25+
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
26+
android:launchMode="singleTask">
27+
<intent-filter>
28+
<action android:name="android.intent.action.MAIN" />
29+
<category android:name="android.intent.category.LAUNCHER" />
30+
</intent-filter>
31+
</activity>
32+
33+
<service
34+
android:name=".JssService"
35+
android:exported="false"
36+
android:foregroundServiceType="dataSync" />
37+
38+
</application>
39+
40+
</manifest>

app/src/main/cpp/CMakeLists.txt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
cmake_minimum_required(VERSION 3.22.1)
2+
project(jss_android)
3+
4+
# libnode.so + headers are vendored at build time by scripts/fetch-libnode.sh.
5+
# We expect them at:
6+
# ../libnode/include # public Node headers
7+
# ../libnode/bin/<abi>/libnode.so
8+
set(LIBNODE_DIR ${CMAKE_SOURCE_DIR}/../../../../app/libnode)
9+
10+
# Pre-built libnode for the current ABI; gradle's externalNativeBuild handles
11+
# the per-ABI invocation, so ANDROID_ABI is set per build.
12+
add_library(node SHARED IMPORTED)
13+
set_target_properties(node PROPERTIES
14+
IMPORTED_LOCATION ${LIBNODE_DIR}/bin/${ANDROID_ABI}/libnode.so)
15+
16+
# JNI shim — boots node::Start on a thread, pipes stdout/stderr to logcat.
17+
add_library(native-lib SHARED native-lib.cpp)
18+
19+
target_include_directories(native-lib PRIVATE
20+
${LIBNODE_DIR}/include/node)
21+
22+
find_library(log-lib log)
23+
24+
target_link_libraries(native-lib
25+
node
26+
${log-lib})

app/src/main/cpp/native-lib.cpp

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// JNI shim for nodejs-mobile. Adapted from
2+
// JaneaSystems/nodejs-mobile-samples/android/native-gradle/.../native-lib.cpp
3+
// (MIT). Two responsibilities:
4+
//
5+
// 1. startNodeWithArguments(env, argv) → invokes node::Start on the calling
6+
// thread, with the args the Kotlin side built. Blocks until Node exits.
7+
// 2. Pipe stdout/stderr to logcat — otherwise Fastify's startup logs vanish.
8+
9+
#include <jni.h>
10+
#include <android/log.h>
11+
#include <cstdlib>
12+
#include <cstring>
13+
#include <pthread.h>
14+
#include <unistd.h>
15+
16+
// Forward-declared from libnode; the real signature lives in node.h.
17+
namespace node {
18+
int Start(int argc, char* argv[]);
19+
}
20+
21+
#define LOG_TAG "jss-android"
22+
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
23+
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
24+
25+
// ----- stdout/stderr → logcat -----------------------------------------------
26+
27+
static int s_pipe_stdout[2];
28+
static int s_pipe_stderr[2];
29+
static pthread_t s_thread_stdout;
30+
static pthread_t s_thread_stderr;
31+
32+
static void* pump(void* arg) {
33+
int fd = *static_cast<int*>(arg);
34+
int prio = (fd == s_pipe_stdout[0]) ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR;
35+
char buf[512];
36+
ssize_t n;
37+
while ((n = read(fd, buf, sizeof(buf) - 1)) > 0) {
38+
if (buf[n - 1] == '\n') --n;
39+
buf[n] = 0;
40+
__android_log_write(prio, LOG_TAG, buf);
41+
}
42+
return nullptr;
43+
}
44+
45+
static void redirect_stdio_to_logcat() {
46+
setvbuf(stdout, nullptr, _IOLBF, 0);
47+
setvbuf(stderr, nullptr, _IONBF, 0);
48+
pipe(s_pipe_stdout); pipe(s_pipe_stderr);
49+
dup2(s_pipe_stdout[1], STDOUT_FILENO);
50+
dup2(s_pipe_stderr[1], STDERR_FILENO);
51+
pthread_create(&s_thread_stdout, nullptr, pump, &s_pipe_stdout[0]);
52+
pthread_create(&s_thread_stderr, nullptr, pump, &s_pipe_stderr[0]);
53+
pthread_detach(s_thread_stdout);
54+
pthread_detach(s_thread_stderr);
55+
}
56+
57+
// ----- JNI entry point -------------------------------------------------------
58+
59+
extern "C" JNIEXPORT jint JNICALL
60+
Java_live_jss_jss_1android_NodeBridge_startNodeWithArguments(
61+
JNIEnv* env, jclass /*clazz*/, jobjectArray jArgv) {
62+
63+
static bool stdio_redirected = false;
64+
if (!stdio_redirected) {
65+
redirect_stdio_to_logcat();
66+
stdio_redirected = true;
67+
}
68+
69+
jsize argc = env->GetArrayLength(jArgv);
70+
if (argc <= 0) {
71+
LOGE("startNodeWithArguments called with empty argv");
72+
return -1;
73+
}
74+
75+
auto argv = static_cast<char**>(calloc(argc + 1, sizeof(char*)));
76+
for (jsize i = 0; i < argc; i++) {
77+
auto jArg = static_cast<jstring>(env->GetObjectArrayElement(jArgv, i));
78+
const char* utf = env->GetStringUTFChars(jArg, nullptr);
79+
argv[i] = strdup(utf);
80+
env->ReleaseStringUTFChars(jArg, utf);
81+
env->DeleteLocalRef(jArg);
82+
}
83+
argv[argc] = nullptr;
84+
85+
LOGI("node::Start with %d args, argv[0]=%s, argv[1]=%s",
86+
argc, argv[0], argc > 1 ? argv[1] : "(none)");
87+
88+
int rc = node::Start(argc, argv);
89+
90+
LOGI("node::Start returned %d", rc);
91+
92+
for (int i = 0; i < argc; i++) free(argv[i]);
93+
free(argv);
94+
return rc;
95+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package live.jss.jss_android
2+
3+
import android.content.Context
4+
import android.util.Log
5+
import java.io.File
6+
import java.io.FileOutputStream
7+
8+
/**
9+
* On first launch (or after an app update) we copy the JSS bundle out of
10+
* APK assets into the app's writable filesDir, because Node needs to write
11+
* to its own project (for caches, accounts/, etc.). We track the build's
12+
* lastUpdateTime in a stamp file and skip the copy on subsequent launches
13+
* when nothing has changed — saves ~10 s on cold start.
14+
*
15+
* Pattern lifted (with attribution) from nodejs-mobile-react-native's
16+
* RNNodeJsMobileModule.java assetCopy routine. MIT licensed; covered by
17+
* AGPL on aggregation.
18+
*/
19+
object AssetCopier {
20+
21+
private const val TAG = "AssetCopier"
22+
private const val STAMP_FILE = ".asset-copy-stamp"
23+
24+
/**
25+
* Ensure [destRoot] mirrors `assets/<assetSubpath>` from the APK.
26+
* Returns the absolute path to the copied tree.
27+
*/
28+
fun ensureAssets(context: Context, assetSubpath: String, destRoot: File): File {
29+
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
30+
val currentStamp = "${pkgInfo.versionName}@${pkgInfo.lastUpdateTime}"
31+
32+
val stampFile = File(destRoot, STAMP_FILE)
33+
if (stampFile.exists() && stampFile.readText() == currentStamp) {
34+
Log.i(TAG, "Assets already up to date for $currentStamp; skip")
35+
return destRoot
36+
}
37+
38+
Log.i(TAG, "Copying assets/$assetSubpath -> $destRoot (stamp: $currentStamp)")
39+
if (destRoot.exists()) destRoot.deleteRecursively()
40+
destRoot.mkdirs()
41+
42+
copyDir(context, assetSubpath, destRoot)
43+
stampFile.writeText(currentStamp)
44+
45+
Log.i(TAG, "Asset copy done")
46+
return destRoot
47+
}
48+
49+
private fun copyDir(context: Context, src: String, dest: File) {
50+
val entries = context.assets.list(src) ?: return
51+
if (entries.isEmpty()) {
52+
// Files report list() as empty; copy as a regular file.
53+
copyFile(context, src, dest)
54+
return
55+
}
56+
dest.mkdirs()
57+
for (entry in entries) {
58+
val childSrc = "$src/$entry"
59+
val childDest = File(dest, entry)
60+
val childEntries = context.assets.list(childSrc) ?: emptyArray()
61+
if (childEntries.isEmpty()) {
62+
copyFile(context, childSrc, childDest)
63+
} else {
64+
copyDir(context, childSrc, childDest)
65+
}
66+
}
67+
}
68+
69+
private fun copyFile(context: Context, src: String, dest: File) {
70+
context.assets.open(src).use { input ->
71+
FileOutputStream(dest).use { output ->
72+
input.copyTo(output)
73+
}
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)