whoami

Lucas Jenß

cat /etc/motd

The Coding Journal ツ — Notes taken on an epic coding journey. Technical solutions, debugging notes, and practical guides from the trenches of software development.

Close-up of white dominoes with black dots standing on green felt surface, shallow depth of field, focused mood

ls -la ~/languages/

total 8
drwxr-xr-x
▶ PHP
▶ Ruby
▶ Scala
▶ C#
▶ JavaScript
▶ Objective-C
▶ Shell Scripting

ls -la ~/toolchain/

total 7
drwxr-xr-x
▶ Typo3
▶ Akka
▶ Capistrano
▶ Git
▶ MAMP
▶ Adobe Illustrator
▶ NSTrackingArea (Cocoa)

uname -a

platforms
drwxr-xr-x
▶ Mac OS X
▶ Unix

Compiling native Android code with a self-contained Gradle build

Modern Android builds can make a small native project look harder than it is. The Android Gradle Plugin often adds an external native build, a CMake configuration and a Ninja-driven compiler command. The workflow below shows how to compile an Android NDK project with Gradle without external tools. Gradle calls the Clang binary already shipped in the NDK, so nobody has to install CMake, Ninja, Make, Ant or a separate compiler first. In this context, “external tools” means additional build programs; the NDK compiler itself remains essential.

That trade-off is useful in Australia, where Android devices sold through Google Play range from current flagship handsets to older, budget-focused models. Aussie developers may brush off a one-off clean build with “no worries”, but release testing still benefits from covering arm64-v8a and, where the audience warrants it, armeabi-v7a. Any native library must also load through its Java or Kotlin binding on every supported ABI.

Build teams can also be spread across Sydney, Melbourne, Brisbane and Perth, so reproducible commands matter more than a clever one-machine script. Release logs commonly carry day/month/year dates and AEST or AEDT timestamps, yet the underlying binaries should not depend on local time. Keep the Gradle wrapper and NDK revision pinned, and give CI and laptops the same inputs.

Choose a Gradle path with fewer moving parts

Start by deciding what “without external tools” means for the project. If an existing Android.mk build works and AGP 7.x still supports it, externalNativeBuild.ndkBuild can invoke the NDK's bundled ndk-build. That is an easy legacy path, but AGP 8 no longer supports the old ndk-build integration. A direct Clang task works across current plugin versions and provides precise control over compiler flags.

The direct approach keeps the module in ordinary Gradle Groovy or Kotlin DSL. Gradle resolves the SDK and NDK paths, creates a task for each ABI, compiles the sources and places each shared object under the matching jniLibs directory. The Android plugin then packages those files into the APK or app bundle exactly as it packages prebuilt libraries. There is no repository-wide shell script and no PATH dependency on a machine-wide toolchain.

That restraint is useful beyond CI. For a quiet change of pace, I keep my minimalist site separate from the denser engineering notes, and the same preference for fewer moving parts guides this build setup. Each added installer is another version to document and another thing to diagnose when a teammate in another state opens the project.

Resolve the NDK from Gradle

Install the NDK through Android Studio's SDK Manager or the command-line SDK manager, then pin its revision in the module. The following Groovy fragment resolves local.properties first, falls back to ANDROID_HOME, and allows ANDROID_NDK_HOME to override the path. It also detects the host directory under toolchains/llvm/prebuilt; no CMake or Ninja installation is consulted.

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')

if (localPropertiesFile.exists()) {
    localPropertiesFile.withInputStream {
        localProperties.load(it)
    }
}

def sdkDir = localProperties.getProperty('sdk.dir') ?:
        System.getenv('ANDROID_HOME')
def ndkVersion = '26.3.11579264'
def ndkRoot = System.getenv('ANDROID_NDK_HOME')

if (!ndkRoot && sdkDir) {
    ndkRoot = "${sdkDir}/ndk/${ndkVersion}"
}

if (!ndkRoot) {
    throw new GradleException(
        'Set ANDROID_NDK_HOME or sdk.dir in local.properties'
    )
}

def ndkRootPath = file(ndkRoot).canonicalPath
def hostTag = [
    'darwin-x86_64',
    'darwin-arm64',
    'windows-x86_64',
    'linux-x86_64',
    'linux-aarch64'
].find { candidate ->
    new File(ndkRootPath,
        "toolchains/llvm/prebuilt/${candidate}").isDirectory()
}

if (!hostTag) {
    throw new GradleException("No NDK host tools found under ${ndkRootPath}")
}

android {
    ndkVersion ndkVersion
}

Keep local.properties out of version control because it contains a machine-specific SDK path. The pinned NDK version must exist in the SDK's ndk directory, and the selected host tag must match the installed package. If the NDK lives somewhere custom, ANDROID_NDK_HOME is the least surprising escape hatch. On Windows, a path such as C:\Users\...\Android\Sdk\ndk\26.3.11579264 works after the Java properties loader handles the escaped form.

Compile native code as a Gradle task

With the path resolved, compile the C or C++ sources into an Android shared library. This example targets API 21 and arm64-v8a. The NDK's versioned Clang wrapper selects the correct target, while the output filename must be libnative.so so System.loadLibrary("native") can find it. The task belongs in app/build.gradle and can use the variables from the preceding fragment.

def apiLevel = 21
def nativeDir = file('src/main/cpp')
def nativeOutput = layout.buildDirectory
        .file('generated/jniLibs/arm64-v8a/libnative.so')
        .get().asFile

def clang = new File(ndkRootPath,
    "toolchains/llvm/prebuilt/${hostTag}/bin/" +
    "aarch64-linux-android${apiLevel}-clang++")

def compileFlags = [
    '-shared',
    '-fPIC',
    '-O2',
    '-std=c++17',
    '-fvisibility=hidden',
    '-Wl,--no-undefined'
]

tasks.register('compileArm64Native', Exec) {
    inputs.files(fileTree(nativeDir) {
        include '**/*.c', '**/*.cc', '**/*.cpp',
                '**/*.h', '**/*.hpp'
    })
    inputs.property('compiler', clang.absolutePath)
    inputs.property('compileFlags', compileFlags)
    inputs.property('apiLevel', apiLevel)
    outputs.file(nativeOutput)

    doFirst {
        nativeOutput.parentFile.mkdirs()
    }

    commandLine([clang.absolutePath] + compileFlags + [
        "-I${nativeDir.absolutePath}",
        '-o',
        nativeOutput.absolutePath,
        "${nativeDir.absolutePath}/native.cpp",
        '-llog'
    ])
}

tasks.named('preBuild').configure {
    dependsOn(tasks.named('compileArm64Native'))
}

Add every translation unit to the command, or use an NDK-style response file when the source list becomes long. The wrapper name carries both architecture and minimum API, so adapt it for other ABIs rather than adding arbitrary linker flags. Use armv7a-linux-androideabi21-clang++ for armeabi-v7a and x86_64-linux-android21-clang++ for x86_64. If the source uses the C++ standard library, link the NDK runtime with -static-libstdc++, or package its libc++_shared.so in the same ABI folder. Desktop libstdc++ is not an Android substitute.

-Wl,--no-undefined catches missing symbols while building, and -llog can be removed when unused or replaced with -landroid for native-activity work. More difficult cases, including ABI-specific linker failures, belong in my miscellaneous coding notes so they do not clutter the main project. The essential result remains an ordinary Gradle dependency: preBuild waits for the native task, while the packaging task finds the generated shared object through the following source-set entry.

android {
    sourceSets {
        main {
            jniLibs.srcDirs += layout.buildDirectory
                    .dir('generated/jniLibs').get().asFile
        }
    }
}

Checks that prevent broken native packages

A native build can look successful while producing a package that fails on launch. A short verification routine catches that gap before it reaches a tester in Melbourne or Perth. Treat these checks as part of the build rather than folklore.

  • Pin the NDK revision, target API and Clang flags in version control, and record the same values in CI images.
  • Declare every .c, .cc, .cpp, .h and .hpp file as a task input; otherwise a header change may leave a stale library.
  • Build every ABI you intend to ship and map it deliberately: arm64-v8a to aarch64, armeabi-v7a to armv7a and x86_64 to x86_64.
  • Inspect the final APK or app bundle and call the JNI entry point on real arm64 hardware plus an x86_64 emulator when that ABI ships.

Gradle's up-to-date checks need to see the compiler, headers and flags. If flags change, declare them as inputs.property values; if generated output moves, update the source-set path in the same commit. For CI, include the NDK revision in the cache key and run at least one clean build. An Australian team should not need to coordinate a timezone just to know whether today's binary is the one being tested.

Run the next build without extra installers

At this point, a fresh checkout needs the Gradle wrapper, a JDK, the Android SDK platform and the pinned NDK. It does not need a globally installed compiler suite or a separate build orchestrator. The native module remains reproducible because Android Studio, the command line and CI all execute the same Gradle task and consume the same output directory.

Publishing details and the author's contact route are recorded on the imprint and contact details page. Keep those details current when reusing the pattern in a commercial app, and retain attribution for any borrowed build logic.

Install the pinned NDK, save the Gradle snippets in a real module, and run ./gradlew clean assembleDebug now. Follow it with bundleRelease, then exercise the JNI function on arm64 hardware and an x86_64 emulator. When those commands pass without a machine-wide CMake or Ninja package, the native build is ready to become the project's normal path.


cat ~/interests.json

KeyValue
editorTerminal-first workflow
osMac OS X / Unix
vcsGit, distributed version control
deployCapistrano, cron automation
graphicsSVG, Adobe Illustrator troubleshooting
networkingIP validation, SSH, VPN

git log --oneline --reverse

2013-10-30

Solving SVG import issues in Adobe Illustrator CS6 and CC

When importing an SVG into Illustrator, the operation fails with an unknown error [CANT]. A workaround for this Adobe-side bug.

2013

Solving NDK build issues on OS X

Troubleshooting native development kit compilation problems on Mac OS X.

2013

Programmatically adding PHP generated TypoScript to the backend configuration

Integrating dynamically generated TypoScript into Typo3 backend setups using PHP.

2013

ArgumentError: Could not parse PKey: no start line

Debugging an SSH key parsing error encountered during deployment.

2011-08-04

Validating IP-Addresses in PHP

Using PHP filter functions with flags like FILTER_FLAG_IPV4 and FILTER_FLAG_IPV6, and understanding how filter_var handles reserved IP addresses.

2011-07-09

Cocoa: Using NSTrackingArea

A short tutorial on using Cocoa's NSTrackingArea to capture mouseEntered and mouseExited events.


cat ~/contact.txt

github: github.com/x3ro
stackoverflow: x3ro
coderwall: coderwall.com/x3ro
twitter: @x3rames