Distributing Android CLI Programs in APKs

Featured image

Command-line interface (CLI) programs have no graphical user interface (GUI), which means less effort required when porting them to a different system.

Regular computers (still, for the time being??) support CLI programs. Although Android ecosystem does not actively promote support for CLI apps, it is still possible, because Android is a (modified) Linux system.

Less effort when porting is what got me here. A program to be ported, can be first ported with its CLI as is, as a proof of concept, and only later modified into a proper Android interface.

Segfault or any other crash inside a library means that your application crashes too. Segfault signal could be handled, but good luck try-catching memory corruption. Same crash in an external program is way easier to isolate.

The Why - APK

Distributing CLI executables is somewhat tricky. The usual way is uploading them to device manually, like any regular file, enabling executable permission and running in a terminal. Example:

adb push myProgram /data/local/tmp
adb shell chmod +x /data/local/tmp/myProgram
adb shell /data/local/tmp/myProgram

It has some caveats. Such as either requiring debugging mode on device or having a terminal emulator app. Dynamically linked libraries are not found automatically either.

The How - Including in The APK

Executable could be placed in assets directory, but it would negate all the ABI split benefits, they would also have to be extracted manually. Shared libraries placed in jniLibs directory are included in the APK, this will (eventually) work for executables too.

$ mkdir -p app/src/main/jniLibs/{x86,x86_64,armeabi-v7a,arm64-v8a}
$ touch app/src/main/jniLibs/{x86,x86_64,armeabi-v7a,arm64-v8a}/myProgram
$ ./gradlew build
$ unzip -v app/build/outputs/apk/debug/app-debug.apk | grep -q lib || echo "lib dir not found in the final APK"
lib dir not found in the final APK

Build process tries to include only shared libraries from jniLibs directory. Program name needs to end in .so to be included in the APK.

$ rm app/src/main/jniLibs/{x86,x86_64,armeabi-v7a,arm64-v8a}/myProgram
$ touch app/src/main/jniLibs/{x86,x86_64,armeabi-v7a,arm64-v8a}/myProgram.so
$ ./gradlew build
$ unzip -v app/build/outputs/apk/debug/app-debug.apk | grep lib
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/arm64-v8a/myProgram.so
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/armeabi-v7a/myProgram.so
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/x86/myProgram.so
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/x86_64/myProgram.so

Just because it ends up in the APK, does not mean it is extracted during install.

Log.i("list of nativeLibs", Arrays.toString(
  new File(getApplicationInfo().nativeLibraryDir).listFiles()
));

Output: list of nativeLibs: null

Installation process on the device also performs a name-based filtration. Library filenames need to start with “lib”.

$ rm app/src/main/jniLibs/{x86,x86_64,armeabi-v7a,arm64-v8a}/myProgram.so
$ touch app/src/main/jniLibs/{x86,x86_64,armeabi-v7a,arm64-v8a}/libMyProgram.so
$ ./gradlew build
$ unzip -v app/build/outputs/apk/debug/app-debug.apk | grep lib
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/arm64-v8a/libMyProgram.so
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/armeabi-v7a/libMyProgram.so
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/x86/libMyProgram.so
       0  Defl:N        2   0% 01-01-1981 01:01 00000000  lib/x86_64/libMyProgram.so
Log.i("list of nativeLibs", Arrays.toString(
  new File(getApplicationInfo().nativeLibraryDir).listFiles()
));

Output: list of nativeLibs: [/data/app/com.viliussutkus89.exeinapk-1/lib/x86_64/libMyProgram.so]

Building with CMake

Now that it is clear how to “properly” name executable files, it could also be done in CMake.

Clear out the previously made dummy files first:

$ rm app/src/main/jniLibs/{x86,x86_64,armeabi-v7a,arm64-v8a}/libMyProgram.so

Create a working program:

// app/src/main/cpp/myProgram.c
#include <stdio.h>

int main(int argc, const char * argv[]) {
    if (2 == argc) {
        printf("%s %s\n", argv[1], argv[1]);
        return 0;
    }
    return 1;
}

Build it with CMake:

# app/src/main/cpp/CMakeLists.txt

cmake_minimum_required(VERSION 3.6)
project("exeinapk")

add_executable(myProgram myProgram.c)
set_target_properties(myProgram PROPERTIES OUTPUT_NAME libMyProgram.so)

Verify that it is extracted during application install:

Log.i("list of nativeLibs", Arrays.toString(
  new File(getApplicationInfo().nativeLibraryDir).listFiles()
));

Output: list of nativeLibs: [/data/app/com.viliussutkus89.exeinapk-1/lib/x86_64/libMyProgram.so]

The How - Running in The Application

Programs included in APK can only be interfaced from the application itself. Android application can act as a skin over the CLI program.

File programFile = new File(getApplicationInfo().nativeLibraryDir, "libMyProgram.so");
ProcessBuilder processBuilder = new ProcessBuilder(programFile.getAbsolutePath(), "Hello World!");
Process process;
try {
    process = processBuilder.start();
} catch (IOException e) {
    Log.e("ExecutableRunner", "Failed to start the program", e);
    return;
}

// Wait for the process to execute
int returnValue;
while (true) {
    try {
        returnValue = process.waitFor();
        break;
    } catch (InterruptedException ignored) {
    }
}

Scanner stderr = new Scanner(process.getErrorStream()).useDelimiter("\n");
while (stderr.hasNext()) {
    Log.e("ExecutableRunner", stderr.next());
}

Scanner stdout = new Scanner(process.getInputStream()).useDelimiter("\n");
while (stdout.hasNext()) {
    Log.i("ExecutableRunner", stdout.next());
}

Log.i("ExecutableRunner", "Process finished with return value: " + returnValue);
ExecutableRunner: Hello World! Hello World!
ExecutableRunner: Process finished with return value: 0

Linking Against Shared Libraries

Let us create a library containing a single function - reverseString.

Header file, to declare the function:

// app/src/main/cpp/myLibrary.h
#ifndef MY_LIBRARY_H
#define MY_LIBRARY_H

#include <stddef.h> // needed to have size_t

#ifdef __cplusplus
extern "C"
#endif
__attribute__ ((visibility ("default")))
void reverseString(char *data, size_t length);

#endif // MY_LIBRARY_H

C file, to implement the function:

// app/src/main/cpp/myLibrary.c
#include "myLibrary.h"

void reverseString(char *data, size_t length) {
    for (size_t i = 0; i < length / 2; i++) {
        char tmp = data[i];
        data[i] = data[length - 1 - i];
        data[length - 1 - i] = tmp;
    }
}

Use the function in myProgram:

// app/src/main/cpp/myProgram.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "myLibrary.h"

int main(int argc, const char *argv[]) {
    if (2 == argc) {
        const char *inputString = argv[1];
        size_t length = strlen(argv[1]);
        char *reversedString = strdup(inputString);
        reverseString(reversedString, length);

        printf("%s %s\n", inputString, reversedString);
        free(reversedString);
        return 0;
    }
    return 1;
}

Link executable against the shared library in CMake:

# app/src/main/cpp/myProgram/CMakeLists.txt

cmake_minimum_required(VERSION 3.6)
project("exeinapk")

add_library(myLibrary SHARED myLibrary.c)

add_executable(myProgram myProgram.c)
set_target_properties(myProgram PROPERTIES OUTPUT_NAME libMyProgram.so)
target_link_libraries(myProgram myLibrary)

Compile and run it:

list of nativeLibs: [/data/app/com.viliussutkus89.exeinapk-2/lib/x86_64/libmyLibrary.so, /data/app/com.viliussutkus89.exeinapk-2/lib/x86_64/libMyProgram.so]
ExecutableRunner: CANNOT LINK EXECUTABLE "/data/app/com.viliussutkus89.exeinapk-2/lib/x86_64/libMyProgram.so": library "libmyLibrary.so" not found
ExecutableRunner: Process finished with return value: 134

First line of output confirms that the library was extracted successfully, but the second line informs that it could not be found during execution. This is because nativeLibraryDir is not among the directories, which are searched for, when loading libraries.

Could be solved by linking executables with rpath=$ORIGIN flag, but strangely it does not work on all devices. For example Android-16-x86 emulator image. If it does not work on emulator image - it means there are dozens of real world devices where it also would not work.

Use LD_LIBRARY_PATH environment variable, it just works:

File nativeLibraryDir = new File(getApplicationInfo().nativeLibraryDir);
Log.i("list of nativeLibs", Arrays.toString(nativeLibraryDir.listFiles()));
File programFile = new File(nativeLibraryDir, "libMyProgram.so");
ProcessBuilder processBuilder = new ProcessBuilder(programFile.getAbsolutePath(), "Hello World!");
processBuilder.environment().put("LD_LIBRARY_PATH", nativeLibraryDir.getAbsolutePath());

Process process;
...

Output:

list of nativeLibs: [/data/app/com.viliussutkus89.exeinapk-2/lib/x86_64/libmyLibrary.so, /data/app/com.viliussutkus89.exeinapk-2/lib/x86_64/libMyProgram.so]
ExecutableRunner: Hello World! !dlroW olleH
ExecutableRunner: Process finished with return value: 0