A native link can succeed and still leave an Android app broken at startup. That happens when build-time symbol resolution, APK packaging, and runtime loading are treated as one step. In Android.mk, choose the dependency model first: platform library, source-built module, or prebuilt module. The variable follows from that decision.

The three dependency models

  • NDK system library: supplied by the Android platform/toolchain with public NDK APIs; link with an appropriate -l flag.

  • Source-built module: another Android.mk module built in the same graph; reference its module name.

  • Prebuilt shared library: one .so per supported ABI, declared as a prebuilt module and packaged for runtime.

  • Prebuilt static library: an ABI-specific .a archive whose object code is copied into the consuming link output.

  • Header-only dependency: no linker input, but include paths and compile definitions still need a reproducible interface.

src/main/cpp/Android.mkmakefile
LOCAL_PATH := $(call my-dir)
 
include $(CLEAR_VARS)
LOCAL_MODULE := native_core
LOCAL_SRC_FILES := native_core.c
LOCAL_LDLIBS := -llog -lz
include $(BUILD_SHARED_LIBRARY)

What LOCAL_LDLIBS actually contributes

  • LOCAL_PATH anchors relative paths to the directory containing this makefile.

  • CLEAR_VARS resets per-module variables while preserving global values such as LOCAL_PATH.

  • LOCAL_MODULE names the module; ndk-build emits a library name derived from it.

  • -llog resolves public Android logging APIs from liblog; -lz resolves zlib APIs from libz.

  • LOCAL_LDLIBS carries raw linker flags for this executable/shared-library link, not header search paths or APK copy instructions.

Include the matching public headers

src/main/cpp/native_core.cc
#include <android/log.h>
#include <zlib.h>
 
int compress_bound_for_log(unsigned long input_size) {
    const unsigned long bound = compressBound(input_size);
    __android_log_print(ANDROID_LOG_INFO, "NativeCore",
                        "compressed bound=%lu", bound);
    return bound > (unsigned long)INT_MAX ? -1 : (int)bound;
}

Compilation and linking check different promises

  • Headers declare function signatures and constants to the compiler.

  • The link step resolves compressBound and __android_log_print through the libraries named above.

  • A successful compile with unresolved link symbols usually means the declaration was visible but the defining library was absent or ordered incorrectly.

  • The narrowing conversion is guarded; production code should choose return types that preserve the actual range.

  • Only APIs exposed by the NDK for the selected minimum API level are supported application interfaces.

src/main/cpp/Android.mkmakefile
LOCAL_PATH := $(call my-dir)
 
include $(CLEAR_VARS)
LOCAL_MODULE := codec_support
LOCAL_SRC_FILES := codec/support.c
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/codec/include
include $(BUILD_STATIC_LIBRARY)
 
include $(CLEAR_VARS)
LOCAL_MODULE := native_core
LOCAL_SRC_FILES := native_core.c
LOCAL_STATIC_LIBRARIES := codec_support
LOCAL_LDLIBS := -llog
include $(BUILD_SHARED_LIBRARY)

Module names build a dependency graph

  • BUILD_STATIC_LIBRARY creates an archive for the active ABI.

  • The consumer references codec_support, the LOCAL_MODULE value—not a guessed archive filename.

  • LOCAL_EXPORT_C_INCLUDES makes public headers available to modules that depend on this module.

  • Private headers can use LOCAL_C_INCLUDES without exporting them downstream.

  • ndk-build uses the graph to order builds and link inputs more reliably than raw archive paths.

Static and shared linkage are not interchangeable

  • A static archive contributes needed object code to the final .so; the archive itself is not loaded on the device.

  • A shared dependency remains a distinct .so and must be present for the device ABI at runtime.

  • Static linking can duplicate code/runtime state across several shared libraries.

  • Shared linkage can reduce duplication but adds runtime dependency, symbol-visibility, loading, and packaging concerns.

  • License obligations, security updates, C++ runtime choice, binary size, process boundaries, and plugin architecture affect the decision.

3. Declare a prebuilt shared library per ABI

src/main/cpp/vendor/Android.mkmakefile
LOCAL_PATH := $(call my-dir)
 
include $(CLEAR_VARS)
LOCAL_MODULE := vendor_codec
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libvendor_codec.so
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include
include $(PREBUILT_SHARED_LIBRARY)

The path selects one binary for the active ABI

  • A typical layout contains libs/arm64-v8a/libvendor_codec.so, libs/armeabi-v7a/..., and libs/x86_64/... only for ABIs the app supports.

  • TARGET_ARCH_ABI is supplied by ndk-build for the current target.

  • The prebuilt file must match the Android ABI, API requirements, ELF class/machine, and C/C++ binary interface expected by the consumer.

  • Exported includes describe the vendor library’s public compile-time interface.

  • A prebuilt module declaration lets the build graph reason about the dependency and package shared output appropriately through the Android Gradle Plugin integration.

Consume the prebuilt module

src/main/cpp/Android.mkmakefile
LOCAL_PATH := $(call my-dir)
 
include $(LOCAL_PATH)/vendor/Android.mk
 
include $(CLEAR_VARS)
LOCAL_MODULE := native_core
LOCAL_SRC_FILES := native_core.cpp
LOCAL_SHARED_LIBRARIES := vendor_codec
LOCAL_LDLIBS := -llog
include $(BUILD_SHARED_LIBRARY)

Include order and module uniqueness matter

  • Including the vendor makefile registers the vendor_codec module.

  • LOCAL_SHARED_LIBRARIES adds it as a build/link/runtime dependency.

  • Each module name in the graph should be unambiguous.

  • Every module begins after CLEAR_VARS; otherwise per-module state can leak.

  • Do not mix a raw -lvendor_codec flag with the declared module unless a specific documented linker-interface reason requires both.

Prebuilt static libraries use a parallel pattern

src/main/cpp/vendor_static/Android.mkmakefile
LOCAL_PATH := $(call my-dir)
 
include $(CLEAR_VARS)
LOCAL_MODULE := vendor_math
LOCAL_SRC_FILES := libs/$(TARGET_ARCH_ABI)/libvendor_math.a
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include
include $(PREBUILT_STATIC_LIBRARY)

A .a archive still belongs to one architecture

  • The archive must contain object files for the active target ABI, not host Linux/macOS objects.

  • Consume the module with LOCAL_STATIC_LIBRARIES := vendor_math.

  • Static archives may depend on other libraries; declare/link the full dependency interface.

  • C++ archives must agree on STL/runtime, exceptions, RTTI, compiler ABI, and exported symbol assumptions.

  • An archive built with a higher Android API can introduce unavailable symbols on older devices even if the final link succeeds.

Why -L/home/user/lib is the wrong default

  • It encodes one workstation’s directory, so teammates and CI cannot reproduce the build.

  • It does not choose a library by Android ABI.

  • It may accidentally select a host library with the same name.

  • It does not describe exported headers or transitive dependencies.

  • For shared libraries, it does not by itself guarantee the .so is packaged into the APK.

  • Library search order can silently pick a different binary after an environment change.

  • A declared prebuilt module keeps the path inside the project/dependency layout and visible to the build graph.

Wire ndk-build into Gradle

app/build.gradle.ktskotlin
android {
    defaultConfig {
        minSdk = 24
        ndk {
            abiFilters += listOf("arm64-v8a", "x86_64")
        }
        externalNativeBuild {
            ndkBuild {
                arguments += "NDK_APPLICATION_MK:=src/main/cpp/Application.mk"
            }
        }
    }
 
    externalNativeBuild {
        ndkBuild {
            path = file("src/main/cpp/Android.mk")
        }
    }
}

Gradle owns variants and packaging

  • The external native build points AGP at the root Android.mk.

  • ABI filters must match the prebuilt binaries and the device/emulator fleet you support.

  • minSdk affects the native platform API surface exposed at build time.

  • Pin a tested NDK revision elsewhere in the Android block for reproducible toolchains.

  • Do not copy the example ABI/minimum values blindly; choose them from product requirements, dependency availability, store rules, and testing capacity.

Application.mk controls shared native policy

src/main/cpp/Application.mkmakefile
APP_PLATFORM := android-24
APP_ABI := arm64-v8a x86_64
APP_STL := c++_shared
APP_CPPFLAGS := -std=c++20 -fexceptions -frtti

Keep Gradle and ndk-build declarations aligned

  • APP_PLATFORM is the native minimum API level; align it with the application minimum unless a documented split is required.

  • APP_ABI selects native targets; Gradle ABI filters can further control packaging.

  • c++_shared requires the matching libc++ shared runtime to be packaged once for each ABI.

  • Exceptions and RTTI increase capabilities and potential size; enable them because the code/dependencies require them.

  • Modernize legacy GCC, gnustl, STLport, armeabi, MIPS, and standalone-toolchain declarations before using a current NDK.

src/main/cpp/Android.mk (whole archive only when required)makefile
LOCAL_STATIC_LIBRARIES := registration_plugins core_utils
LOCAL_WHOLE_STATIC_LIBRARIES := self_registering_code

Whole-archive is a targeted tool, not a blanket fix

  • Static linkers normally extract archive members needed to resolve currently unresolved symbols.

  • Self-registering code referenced only through constructors/registries may be discarded.

  • LOCAL_WHOLE_STATIC_LIBRARIES forces all archive objects into the result.

  • Forcing whole archives can inflate binaries, duplicate symbols, and include dead code.

  • Prefer explicit references or scoped whole-archive use after confirming the missing-registration mechanism.

Build with the committed Gradle Wrapper

Android project rootbash
./gradlew :app:assembleDebug --stacktrace
BUILD SUCCESSFUL
  • The Wrapper selects the project’s declared Gradle distribution.

  • AGP invokes ndk-build for each configured variant/ABI.

  • --stacktrace retains the failing task/plugin context.

  • Review native compiler/linker warnings rather than accepting success with hidden ABI/API problems.

  • Build release variants too; optimization, shrinking, symbols, and packaging can differ.

Inspect packaged native libraries

Android project rootbash
APK="app/build/outputs/apk/debug/app-debug.apk"
unzip -l "$APK" | rg "lib/[^/]+/.*\.so$"
lib/arm64-v8a/libnative_core.so
lib/arm64-v8a/libvendor_codec.so
lib/arm64-v8a/libc++_shared.so
lib/x86_64/libnative_core.so
lib/x86_64/libvendor_codec.so
lib/x86_64/libc++_shared.so

Check the APK by ABI, not by filename alone

  • Replace the APK path with the actual variant output when project naming differs.

  • Every runtime shared dependency should exist for each packaged ABI unless supplied as a public system library.

  • Static archives should not appear because their selected object code is incorporated into a shared/executable output.

  • Unexpected duplicate C++ runtimes or missing vendor .so files are packaging red flags.

  • App Bundles split delivery by ABI; test generated APK sets or store-equivalent artifacts as well as a universal debug APK.

Inspect ELF dependencies and architecture

Extracted ABI library directorybash
"$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" \
  -h -d libnative_core.so | rg "Class:|Machine:|NEEDED|SONAME"
Class: ELF64
Machine: AArch64
Shared library: [libvendor_codec.so]
Shared library: [liblog.so]
Shared library: [libc++_shared.so]

ELF metadata predicts runtime requirements

  • Use the prebuilt host directory matching the build host; the example path is Linux x86_64.

  • The ELF class/machine must match the APK ABI directory.

  • NEEDED entries list shared-library names the dynamic loader must resolve.

  • SONAME should be consistent with the packaged dependency identity.

  • Private/unexpected platform dependencies discovered here should be removed before release.

  • Confirm the symbol is declared by the header and defined by a linked library for this ABI.

  • For C APIs consumed by C++, verify the header uses the correct extern "C" guards.

  • Check spelling, namespace, overload, visibility, and whether a C++ method has an out-of-line definition.

  • Inspect archives/shared objects with NDK LLVM tools such as llvm-nm or llvm-readelf.

  • Declare all static transitive dependencies and review archive order/whole-archive needs.

  • Confirm debug and release use the same dependency graph where intended.

  • Do not solve a missing symbol by adding every library globally; identify the owning interface.

Debug runtime dlopen failures

Connected authorized devicebash
adb logcat -c
adb shell am force-stop com.example.app
adb shell monkey -p com.example.app 1
adb logcat -d | rg "dlopen failed|UnsatisfiedLinkError|linker"
java.lang.UnsatisfiedLinkError: dlopen failed: library "libvendor_codec.so" not found

Runtime diagnostics point to packaging or compatibility

  • Replace the package name with the application under test.

  • Clearing Logcat removes prior diagnostic history; do it only on a test device when acceptable.

  • “not found” can mean the direct library or one of its NEEDED dependencies is missing.

  • “wrong ELF class” or architecture errors indicate an ABI/path mismatch.

  • “cannot locate symbol” may indicate API-level mismatch, incompatible vendor build, C++ ABI mismatch, or hidden/private symbol use.

  • Do not publish device serials, application secrets, or sensitive logs.

C++ runtime consistency

  • Use libc++ with current NDKs; GCC-era gnustl/STLport settings are obsolete.

  • All C++ libraries crossing ABI boundaries should agree on runtime, compiler ABI, exceptions, RTTI, and relevant compile definitions.

  • Passing STL containers or allocated memory across independently built library boundaries increases ABI and ownership risk.

  • Prefer a narrow C ABI for long-lived vendor boundaries when practical.

  • Do not package multiple conflicting libc++_shared.so copies; select and test one compatible runtime per ABI.

  • Rebuild third-party native dependencies with the selected NDK/toolchain when source and licensing permit.

System API availability is tied to minSdk

NDK sysroot libraries are API-level aware. A symbol introduced after the application’s minimum API can link under one configuration yet fail on older devices if guarded incorrectly or built against the wrong platform. Prefer APIs available at minSdk; otherwise use documented availability checks and runtime symbol loading patterns appropriate to the API.

Security and supply-chain review for prebuilts

  • Obtain binaries and headers from a trusted upstream with version, license, provenance, and checksums.

  • Scan dependencies and monitor upstream security advisories.

  • Keep debug symbols separately and know whether the vendor binary is stripped.

  • Inspect exported symbols and dynamic dependencies for unnecessary surface.

  • Test malformed inputs at the native boundary; memory-unsafe codecs/parsers are high-risk.

  • Record which source revision, NDK, flags, and patches produced each prebuilt when builds are under your control.

  • Plan update and rollback paths before shipping a binary-only dependency.

Common Android.mk mistakes

  • Use only `-L/path`: adds a search directory but does not name, model, or package a dependency.

  • Put headers in LOCAL_LDLIBS: header directories belong in include variables or exported module interfaces.

  • Name a prebuilt only with `-lfoo`: may link locally but loses ABI selection and packaging metadata.

  • Reuse one `.so` for every ABI: each ELF binary targets one architecture.

  • Link a host Linux library: Android uses a different target ABI/API/libc environment.

  • Declare a static archive as shared: choose the matching prebuilt module type.

  • Ignore transitive dependencies: direct link success/runtime loading may fail when required libraries are absent.

  • Hard-code obsolete CPU flags: use NDK/ABI configuration and profile-guided evidence rather than old -march recipes.

  • Assume BUILD SUCCESSFUL proves runtime: inspect the artifact and run it on every supported ABI/API class.

Completion checklist

  • Every dependency is classified as public NDK system, source-built, prebuilt shared, prebuilt static, or header-only.

  • LOCAL_LDLIBS contains intentional raw/system linker flags only.

  • Source and prebuilt dependencies are declared as uniquely named ndk-build modules.

  • Headers and transitive dependency interfaces are exported deliberately.

  • Prebuilt binaries exist and are compatible for every supported ABI.

  • Gradle, Application.mk, minSdk, ABI filters, and pinned NDK agree.

  • Debug and release artifacts contain the expected .so set and one compatible C++ runtime.

  • ELF architecture, SONAME, NEEDED entries, and API dependencies are reviewed.

  • Device/emulator tests exercise JNI/native flows on every shipped ABI.

  • Licensing, provenance, symbols, security updates, and rollback are documented.

Official Android NDK references

  • Android.mk documents module variables including LOCAL_LDLIBS, shared/static dependency variables, and exported flags.

  • Use prebuilt libraries documents PREBUILT_SHARED_LIBRARY, PREBUILT_STATIC_LIBRARY, ABI files, and consumer modules.

  • Stable APIs lists public native platform libraries intended for NDK apps.

  • Android ABIs explains supported ABI names, architecture behavior, and packaging.

  • C++ support covers libc++, shared/static runtime choices, and one-STL guidance.

  • Other build systems explains the NDK Clang/sysroot model when integrating nonstandard builds.