In this week, I found a great POC to run a pure Java standalone app (command line tool, no apk) on Android. But what about running a standalone application using JNI (with .so files) on Android like this?

Java app with JNI

Imagine there is a Java program that loads the JNI shared native library to run and use some Android APIs :

HelloWorld.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example;
import android.os.Build;
import android.util.Log;
public class Helloworld {

static { System.loadLibrary("hello"); }
public static native String stringFromJNI();

public static void main(String[] args) {
Log.i("@@", "Hello world, " + Build.MANUFACTURER + " "+ Build.MODEL + "!");
Log.i("@@", stringFromJNI());
System.out.println(stringFromJNI());
System.out.println("DONE.");
}

public static String getBuildVersion() {
return Build.VERSION.RELEASE;
}
}

…the JNI source would be like:

hello-jni.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ... emit codes

JNIEXPORT jstring JNICALL
Java_com_example_Helloworld_stringFromJNI(JNIEnv *env,
jobject thiz)
{
// ... emit codes
jmethodID versionFunc = (*env)->GetStaticMethodID(env, clz, "getBuildVersion", "()Ljava/lang/String;");

jstring buildVersion = (*env)->CallStaticObjectMethod(env, clz, versionFunc);
const char *version = (*env)->GetStringUTFChars(env, buildVersion, NULL);

if (!version)
{
LOGE("Unable to get version string");
}
else
{
LOGI("Build Version - %s\n", version);
(*env)->ReleaseStringUTFChars(env, buildVersion, version);
}
(*env)->DeleteLocalRef(env, buildVersion);

return (*env)->NewStringUTF(env,
"Hello from JNI ! Compiled with ABI " ABI ".");
}
// ...

The working directory structure

1
2
3
.
├── Helloworld.java
└── hello-jni.c

Compile and deploy

Now we need to compile both the Java and C sources for Android.

Using javac and dx to compile for a jar file which Android can read:

1
2
3
4
5
6
7
export BUILD_DIR=$PWD/build
export JARFILE=helloworld.jar
export JAVAC_OPTS=-source 1.8 -target 1.8 -cp .:$ANDROID_HOME/platforms/android-30/android.jar
# Compile .java to .class
javac $JAVAC_OPTS -d $BUILD_DIR/classes Helloworld.java
# Convert .class file into a dex file and embedded in a jar file
$ANDROID_HOME/build-tools/30.0.2/dx --output=$BUILD_DIR/$JARFILE --dex ./$BUILD_DIR/classes

Cross-compile the C to Android shared native library via NDK:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Using the prebuilt toolchain diretly
# See https://developer.android.com/ndk/guides/other_build_systems
export ANDROID_NDK_STANDALONE=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64
$ANDROID_NDK_STANDALONE/bin/clang \
--target=aarch64-none-linux-android21 \
--gcc-toolchain=$ANDROID_NDK_STANDALONE \
--sysroot $ANDROID_NDK_STANDALONE/sysroot \
-L${ANDROID_NDK_STANDALONE}/sysroot/usr/lib \
-shared -g -DANDROID -fdata-sections -ffunction-sections -funwind-tables \
-fstack-protector-strong -no-canonical-prefixes -fno-addrsig -fPIC \
-Wl,-llog \
-Wl,-soname,libhello.so \
-o $BUILD_DIR/libhello.so hello-jni.c

The build directory looks like:

1
2
3
4
5
6
7
build
├── classes
│ └── com
│ └── example
│ └── Helloworld.class
├── helloworld.jar
└── libhello.so

Using the adb tool to deploy the helloworld.jar and libhello.so to Android device:

1
2
adb shell mkdir /data/local/tmp/helloworld
adb push $BUILD_DIR/helloworld.jar $BUILD_DIR/libhello.so /data/local/tmp/helloworld/

Run appliation on Android

Run helloworld.jar via app_process on Android:

1
2
3
4
5
6
7
8
adb shell CLASSPATH="/data/local/tmp/helloworld/helloworld.jar" \
LD_LIBRARY_PATH=/data/local/tmp/helloworld \
app_process \
/data/local/tmp/helloworld \
com.example.Helloworld
# output
Hello from JNI ! Compiled with ABI arm64-v8a.
DONE.

Logging in adb logcat:

1
2
3
4
5
6
7
8
07-06 07:57:38.911 21599 21599 D AndroidRuntime: >>>>>> START com.android.internal.os.RuntimeInit uid 2000 <<<<<<
07-06 07:57:38.915 21599 21599 I AndroidRuntime: Using default boot image
07-06 07:57:39.025 21599 21599 D AndroidRuntime: Calling main entry com.example.Helloworld
07-06 07:57:39.027 21599 21599 I @@ : Hello world, vivo V2219A!
07-06 07:57:39.027 21599 21599 I hello-jni: Build Version - 12
07-06 07:57:39.027 21599 21599 I @@ : Hello from JNI ! Compiled with ABI arm64-v8a.
07-06 07:57:39.027 21599 21599 I hello-jni: Build Version - 12
07-06 07:57:39.028 21599 21599 D AndroidRuntime: Shutting down VM

Startup shell script

We can create a startup shell script for this tool:

helloworld
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/system/bin/sh
HERE="$(cd "$(dirname "$0")" && pwd)"

export CLASSPATH=$HERE/helloworld.jar
export ANDROID_DATA=$HERE
export LD_LIBRARY_PATH="$HERE"

if [ -f "$HERE/libc++_shared.so" ]; then
# Workaround for https://github.com/android-ndk/ndk/issues/988.
export LD_PRELOAD="$HERE/libc++_shared.so"
fi

echo "try com.example.Helloworld with LD_LIBRARY_PATH=${LD_LIBRARY_PATH}"
cmd="app_process $HERE com.example.Helloworld $@"
echo "run: $cmd"
exec $cmd

Push the script to Android device:

1
2
3
adb push helloworld /data/local/tmp/helloworld/
# executable
adb shell chmod a+x /data/local/tmp/helloworld/helloworld

Execute the shell script via adb shell:

1
adb shell /data/local/tmp/helloworld/helloworld

Makefile

We can bundle the compile and deploy commands into a convenient Makefile :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
BUILD_DIR=build

JAVAC_OPTS=-source 1.8 -target 1.8 -cp .:$(ANDROID_HOME)/platforms/android-30/android.jar

APP_PROCESS=app_process
JARFILE=helloworld.jar
ifeq ($(ARCH),arm)
TARGET=--target=armv7-none-linux-androideabi19 -march=armv7-a -mfpu=vfpv3-d16
APP_PROCESS=app_process32
else
TARGET=--target=aarch64-none-linux-android21
endif

$(BUILD_DIR)/$(JARFILE) : Helloworld.java
test -d $(BUILD_DIR) || mkdir $(BUILD_DIR)
$(JAVA_HOME)/bin/javac $(JAVAC_OPTS) -d $(BUILD_DIR)/classes Helloworld.java
$(ANDROID_HOME)/build-tools/30.0.2/dx --output=$(BUILD_DIR)/$(JARFILE) --dex ./$(BUILD_DIR)/classes

$(BUILD_DIR)/libhello.so : hello-jni.c
test -d $(BUILD_DIR) || mkdir $(BUILD_DIR)
$(ANDROID_NDK_STANDALONE)/bin/clang \
$(TARGET) --gcc-toolchain=$(ANDROID_NDK_STANDALONE) \
--sysroot $(ANDROID_NDK_STANDALONE)/sysroot \
-L$(ANDROID_NDK_STANDALONE)/sysroot/usr/lib \
-shared -g -DANDROID -fdata-sections -ffunction-sections -funwind-tables \
-fstack-protector-strong -no-canonical-prefixes -fno-addrsig -fPIC \
$(CFLAGS) -Wl,--exclude-libs,libgcc.a -Wl,--exclude-libs,libatomic.a \
-Wl,--build-id -Wl,--warn-shared-textrel \
-Wl,--no-undefined -Wl,--as-needed \
$(LINKFLAGS) -Wl,-llog \
-Wl,-soname,libhello.so \
-o $(BUILD_DIR)/libhello.so hello-jni.c

all: $(BUILD_DIR)/$(JARFILE) $(BUILD_DIR)/libhello.so helloworld
.PHONY : clean deploy
deploy : all
adb shell mkdir /data/local/tmp/helloworld
adb push --sync $(BUILD_DIR)/$(JARFILE) $(BUILD_DIR)/libhello.so helloworld /data/local/tmp/helloworld/
adb shell chmod a+x /data/local/tmp/helloworld/helloworld
$(info adb shell /data/local/tmp/helloworld/helloworld)
clean :
test -d $(BUILD_DIR) && rm -rf $(BUILD_DIR) || true
adb shell -n "test -d /data/local/tmp/helloworld && rm -rf /data/local/tmp/helloworld || true"

Using make for compile all targets:

1
2
ANDROID_NDK_STANDALONE=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64 \
make all

Complete codes

You can get the complete codes in my git repo: https://github.com/yrom/sample-android-java-standalone-tool

1
2
3
4
5
git clone https://github.com/yrom/sample-android-java-standalone-tool.git
cd sample-android-java-standalone-tool

ANDROID_NDK_STANDALONE=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG \
make all

…the HOST_TAG:

NDK hostHOST_TAG
macOSdarwin-x86_64
Linuxlinux-x86_64
64bit Windowswindows-x86_64

(from https://developer.android.com/ndk/guides/other_build_systems#overview)

Conclusion:

  • Compile Java sources to an Android dex jar file via javac and dx
  • Compile C (or C++) sources to shared native library via ndk
  • Push complete .jar file and .so file(s) to Android device under /data/local/tmp
  • Run the main class via app_prcoess on Android device from adb shell

References: