// SPDX-FileCopyrightText: 2026 Tiger Analytics
// SPDX-License-Identifier: Apache-2.0
//
// Gradle module for the Android side of react-native-image-stitcher.
//
// What lives here:
//   - QualityChecker.kt — Laplacian-variance blur scoring
//     and mean-luminance brightness scoring via OpenCV.  Mirror of
//     the iOS Phase 1 implementation (which used vImage + vDSP).
//   - Stitcher.kt — extractFrames + stitchFrames +
//     stitchVideo + normaliseImage, mirroring the iOS Phase 2 / 2.5
//     ObjC++ surface.
//   - RNImageStitcherPackage.kt — ReactPackage that surfaces the
//     two RN modules to the host app via Android autolinking.
//
// OpenCV vendor:
//   We use the `quickbirdstudios/opencv-android` Maven artifact
//   because it ships a clean AAR with the standard
//   `org.opencv.*` Java packages, including the stitching module
//   (which `opencv-mobile` strips on iOS — same trade-off applies
//   here).  Version pinned to 4.x to match the iOS framework.

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

def reactNativeRoot = rootProject.findProperty("reactNativeRoot") ?: project.file("../../../node_modules/react-native")

// ── OpenCV Android SDK download ──────────────────────────────────────
//
// The OpenCV custom Android SDK is delivered by the package's
// postinstall script (`scripts/postinstall-fetch-binaries.js`).  It
// downloads the version-matched release asset from GitHub Releases
// and extracts it into `android/vendor/OpenCV-android-sdk/` — exactly
// where this build.gradle (and `cpp/CMakeLists.txt`) read it from.
//
// We deliberately do NOT have a fallback that fetches stock OpenCV
// from upstream: stock 4.x ships WITHOUT `BUILD_opencv_stitching=ON`,
// so the static lib `libopencv_stitching.a` that CMake links isn't
// in the upstream archive.  If we silently downloaded the upstream
// release, the build would explode with a confusing CMake "static
// archive not found" error.  Better to fail FAST here with a clear
// message pointing at the actual cause.
// ── Bring-your-own-OpenCV (v0.24.4) ──────────────────────────────────
//
// An app must contain exactly ONE OpenCV.  A host that already ships
// its own (an enterprise wrapper SDK, a native CV pipeline of their
// own) previously had no way to say so: this AAR unconditionally
// compiled `org.opencv.*` into itself AND packaged
// libopencv_java4.so, so the host's build died on `Duplicate class
// org.opencv.core.Mat` or "More than one file was found with OS
// independent path 'lib/arm64-v8a/libopencv_java4.so'".  The iOS side
// gained `$RNISHostOpenCV` in this same release; these are its Android
// counterpart.
//
// Two knobs, because there are two genuinely different situations:
//
//   rnisHostOpenCVSdkDir   (String path, opt-in)
//     Path to the host's OpenCV Android SDK — the directory that
//     contains `java/`, `native/libs/` and `native/jni/`.  We use it
//     in place of our vendored copy for the Java sources, the
//     jniLibs, the resources AND the CMake link.  Net effect: one
//     OpenCV, contributed by us, built from the host's tree.  Skips
//     our ~90 MB download and eliminates version skew.
//     REQUIREMENT: it must be built with `BUILD_opencv_stitching=ON`
//     (stock upstream releases are NOT — see the note below), and it
//     must include arm64-v8a.
//
//   rnisHostOpenCVPackagedByHost   (boolean, default false)
//     Set when the host ALREADY packages OpenCV's Java classes and
//     .so into the APK itself (their own AAR / Gradle module).  We
//     then contribute nothing — no java srcDir, no jniLibs, no res —
//     and merely compile against the host's dependency, declared as
//     `rnisHostOpenCVDependency` (a Maven coordinate string or a
//     `project(':x')` handle).  `rnisHostOpenCVSdkDir` is still
//     required in this mode: CMake needs real headers and a real
//     .so to link the JNI shim against.
//
// Both are read from rootProject.ext first (so a host can set them in
// its own build.gradle) and then from Gradle properties (so they can
// live in gradle.properties or come from -P on the command line).
def rnisProp = { String name ->
    if (rootProject.ext.has(name)) return rootProject.ext.get(name)
    if (rootProject.hasProperty(name)) return rootProject.property(name)
    return null
}

def hostOpenCVSdkDirRaw = rnisProp('rnisHostOpenCVSdkDir')
def hostOpenCVPackagedByHost =
    Boolean.parseBoolean(String.valueOf(rnisProp('rnisHostOpenCVPackagedByHost') ?: 'false'))
def hostOpenCVDependency = rnisProp('rnisHostOpenCVDependency')

//   rnisHostOpenCVProducerTask   (String task path, optional)
//     Set when the host's OpenCV SDK is PRODUCED BY A GRADLE TASK rather
//     than just being present on disk — e.g. a task that downloads and
//     extracts it at build time, which is the common arrangement for an
//     SDK fetched from a private artifact repository.
//
//     Without it Gradle 8 fails configuration outright:
//
//       Task ':react-native-image-stitcher:generateDebugResources' uses
//       this output of task ':their-lib:downloadOpenCV' without
//       declaring an explicit or implicit dependency.
//
//     which is correct and worth failing on — we read `java/`, `res/`
//     and `native/libs/` out of that directory, so if their download
//     task has not run yet we would silently compile against nothing.
//     Naming the task lets us wire the edge instead:
//
//       rnisHostOpenCVProducerTask=:their-lib:downloadOpenCV
//
//     Not needed when the SDK is simply checked in or pre-extracted.
def hostOpenCVProducerTask = rnisProp('rnisHostOpenCVProducerTask')

def opencvVendorDir = file("$projectDir/vendor")
def opencvSdkDir
def usingHostOpenCV = hostOpenCVSdkDirRaw != null

if (usingHostOpenCV) {
    opencvSdkDir = file(String.valueOf(hostOpenCVSdkDirRaw))
    if (!opencvSdkDir.exists()) {
        throw new GradleException(
            "react-native-image-stitcher: rnisHostOpenCVSdkDir points at\n" +
            "  ${opencvSdkDir.absolutePath}\n" +
            "which does not exist.  It must be the `sdk` directory of an\n" +
            "OpenCV Android SDK (the one containing java/, native/libs/ and\n" +
            "native/jni/).  Unset the property to use our vendored copy."
        )
    }
    def stitchingArchive =
        file("$opencvSdkDir/native/staticlibs/arm64-v8a/libopencv_stitching.a")
    if (!stitchingArchive.exists()) {
        throw new GradleException(
            "react-native-image-stitcher: the OpenCV SDK at\n" +
            "  ${opencvSdkDir.absolutePath}\n" +
            "has no arm64-v8a libopencv_stitching.a.  Stock OpenCV Android\n" +
            "releases are built WITHOUT the stitching module, so panorama\n" +
            "compositing cannot link against them.  Rebuild OpenCV with\n" +
            "`-DBUILD_opencv_stitching=ON`, or unset rnisHostOpenCVSdkDir to\n" +
            "use the copy this package ships (which already has it)."
        )
    }
    if (hostOpenCVPackagedByHost && hostOpenCVDependency == null) {
        throw new GradleException(
            "react-native-image-stitcher: rnisHostOpenCVPackagedByHost=true\n" +
            "requires rnisHostOpenCVDependency — the Maven coordinate or\n" +
            "project handle that puts org.opencv.* on the compile classpath.\n" +
            "In that mode we deliberately compile no OpenCV Java sources of\n" +
            "our own, so without it this module cannot compile."
        )
    }
    logger.lifecycle(
        "react-native-image-stitcher: using HOST OpenCV at ${opencvSdkDir}" +
        (hostOpenCVPackagedByHost ? " (host packages it; we contribute none)" : "")
    )
} else {
    // ── Vendored OpenCV (default) ───────────────────────────────────
    opencvSdkDir = file("$opencvVendorDir/OpenCV-android-sdk/sdk")
    if (!opencvSdkDir.exists()) {
        throw new GradleException(
            "OpenCV custom SDK missing at ${opencvSdkDir.absolutePath}.\n" +
            "Did `npm install` succeed?  The package's postinstall script\n" +
            "(scripts/postinstall-fetch-binaries.js) downloads the binaries\n" +
            "from GitHub Releases on install.  Recovery:\n" +
            "  - `npm install --force` to re-run postinstall\n" +
            "  - Or set OPENCV_BINARY_BASE_URL env var to an internal mirror\n" +
            "  - Or run `SKIP_OPENCV_FETCH=1 npm install` and stage the SDK manually\n" +
            "  - Or, if your app already ships OpenCV, set\n" +
            "    `rootProject.ext.rnisHostOpenCVSdkDir` to your SDK's `sdk`\n" +
            "    directory (see docs/bring-your-own-opencv.md)"
        )
    }
}

android {
    namespace 'io.imagestitcher.rn'
    compileSdkVersion safeExtGet('compileSdkVersion', 34)
    // Inherit the host's NDK pin (Expo apps expose `ndkVersion` on
    // rootProject.ext) so AGP doesn't fall back to its built-in default
    // (which on AGP 8.6+ is 27.0.12077973, a version that's commonly
    // half-installed on developer machines and trips [CXX1101]
    // "source.properties missing").  27.1.12297006 is the
    // last-known-good fallback for raw RN hosts without an Expo pin.
    ndkVersion safeExtGet('ndkVersion', '27.1.12297006')

    defaultConfig {
        minSdkVersion safeExtGet('minSdkVersion', 24)
        targetSdkVersion safeExtGet('targetSdkVersion', 34)
        versionCode 1
        versionName "0.1.0"

        // ── ABI filter ───────────────────────────────────────────
        // Only build arm64-v8a.  Our custom OpenCV is built for
        // arm64-v8a only (matches the production Samsung physical
        // test device and modern Android phones).  Skipping
        // armeabi-v7a / x86_64 / x86 saves ~120 MB on the final APK
        // and avoids "libopencv_java4.so missing for ABI X" link
        // errors during the native build.
        ndk {
            abiFilters 'arm64-v8a'
        }

        // ── JNI shim (image_stitcher) build args ─────────────
        // Hands the cpp/CMakeLists.txt the location of the vendored
        // OpenCV (with stitching symbols) so the shim can link.
        externalNativeBuild {
            cmake {
                // CMakeLists.txt appends `sdk/native/...` itself, so this
                // is the PARENT of the `sdk` directory — for the host
                // OpenCV path that's whatever contains the dir the host
                // named in `rnisHostOpenCVSdkDir`.
                arguments "-DOPENCV_ANDROID_SDK=${opencvSdkDir.parentFile.absolutePath}",
                          // v0.8.0 Phase 3 — switched from c++_static
                          // to c++_shared.  Required for linking
                          // ReactAndroid::jsi (RN's prefab uses
                          // shared libc++).  STL probe at
                          // android/src/main/cpp/CMakeLists.txt:99-118
                          // confirms OpenCV's libopencv_stitching.a is
                          // already built with __ndk1 (c++_shared), so
                          // the static archive link continues to
                          // work cleanly.  Pre-Phase-3 it worked only
                          // because the JNI shim's .so boundary used
                          // POD types — fragile.  Now properly aligned.
                          "-DANDROID_STL=c++_shared"
                cppFlags "-std=c++17"
            }
        }
    }

    // v0.8.0 Phase 3 — consume React Native's prefab packages
    // (ReactAndroid::jsi + fbjni::fbjni) for the JSI host object.
    // RN 0.71+ ships these as prefabs; this lib targets RN 0.84.
    buildFeatures {
        prefab true
    }

    // ── Host OpenCV reuse: why NOT prefab publishing ─────────────────
    //
    // Goal: let a HOST app's own native (C++/NDK) code reuse the SAME
    // custom OpenCV (4.10.0, arm64-v8a) this AAR already bundles — both
    // libopencv_java4.so (cv::Mat, imgproc, features2d, calib3d, flann,
    // photo, video …) AND cv::Stitcher (which lives in the static
    // archive libopencv_stitching.a, NOT in the fat .so).
    //
    // We evaluated AGP `prefabPublishing` first (the idiomatic AAR way)
    // and it CANNOT carry this OpenCV.  Empirically verified: AGP's
    // prefab modules only export libraries the module's own
    // externalNativeBuild PRODUCES (here: just `image_stitcher`).  A
    // prebuilt jniLib (.so) or a prebuilt static archive (.a) is not an
    // accepted prefab `libraryName` — the configure fails with
    // `[CXX1404] did not find implicitly required targets`.  So neither
    // libopencv_java4.so nor libopencv_stitching.a can ride a prefab.
    //
    // Instead we expose the bundled OpenCV's OWN first-class CMake
    // package — which already defines every module (incl. opencv_stitching
    // as a STATIC IMPORTED target and opencv_java as a SHARED IMPORTED
    // target) — to consumers via a rootProject ext property.  A host
    // points its externalNativeBuild at `-DOpenCV_DIR=<that dir>`, does
    // `find_package(OpenCV REQUIRED)`, then links the SHARED `opencv_java`
    // (cv::Mat & friends resolved at runtime from the AAR's already-shipped
    // libopencv_java4.so — NO second copy) plus the whole-archived STATIC
    // `opencv_stitching` (cv::Stitcher, a small private copy since it isn't
    // in the fat .so).  This is the idiomatic Android OpenCV-consumption
    // path.  See example/android/app for the working consumer.
    //
    // Set unconditionally at configure time so it's readable from the
    // host app module regardless of project evaluation order.
    rootProject.ext.rnisOpenCVAndroidSdkDir = "$opencvSdkDir/native"
    rootProject.ext.rnisOpenCVDir = "$opencvSdkDir/native/jni"

    // ── JNI shim build path ─────────────────────────────────────────
    // Gradle compiles cpp/image_stitcher_jni.cpp into
    // libimage_stitcher.so for the ABIs filtered above.  The shim
    // is what bridges Kotlin's `external fun nativeStitchFramePaths`
    // to the cv::Stitcher C++ API inside our custom libopencv_java4.so.
    externalNativeBuild {
        cmake {
            path file("src/main/cpp/CMakeLists.txt")
            version "3.22.1"
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }

    // The hand-rolled JNI wrapper for cv::Stitcher (planned for
    // Android stitching parity with iOS) was removed because
    // OpenCV's prebuilt Android `libopencv_java4.so` does NOT
    // contain `cv::Stitcher::create` symbols — the stitching
    // module is dropped from the binary, not just from the Java
    // bindings.  Re-enabling Android-side stitching requires
    // building OpenCV Android from source with `BUILD_opencv_stitching=ON`,
    // which is a hours-long build job out of scope for the SDK
    // package itself.  See the Kotlin BatchStitcher for the
    // current "iOS-only" stitch behaviour.

    sourceSets {
        main {
            if (hostOpenCVPackagedByHost) {
                // v0.24.4 BYO-OpenCV, "host packages it" mode.  We
                // contribute NO OpenCV Java classes, NO .so and NO
                // resources — the host's own AAR/module already puts all
                // three in the APK, and shipping a second copy is a hard
                // build failure (`Duplicate class org.opencv.core.Mat`,
                // and the jniLibs merge conflict on
                // lib/arm64-v8a/libopencv_java4.so).  org.opencv.* reaches
                // our compile classpath through `rnisHostOpenCVDependency`
                // in the dependencies block below.  The JNI shim still
                // links against the host's SDK via -DOPENCV_ANDROID_SDK.
                logger.lifecycle(
                    "react-native-image-stitcher: contributing no OpenCV " +
                    "java/jniLibs/res (host packages OpenCV)"
                )
            } else {
                // OpenCV ships its Java side as plain source files (not
                // a pre-built JAR) under sdk/java/src/, so we compile it
                // into our own AAR alongside the Kotlin code.  The
                // pre-built .so libraries live under sdk/native/libs/
                // and ride along as jniLibs.
                java.srcDirs += [
                    "$opencvSdkDir/java/src",
                ]
                jniLibs.srcDirs = [
                    "$opencvSdkDir/native/libs",
                ]
                // OpenCV references some resources (logos, drawables)
                // from its old wrapper; pointing res at the SDK keeps
                // them available without copying.
                res.srcDirs += [
                    "$opencvSdkDir/java/res",
                ]
            }
        }
    }

    // OpenCV's MatAt.kt uses Kotlin inline-class types (UByte etc.)
    // that the Kotlin 1.9.x compiler bundled with our toolchain
    // can't lower.  We don't use the typed-Mat-accessor API anyway.
    tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
        exclude '**/MatAt.kt'
    }

    // OpenCV's `org.opencv.android.*` package is the legacy
    // OpenCV-Manager-service support layer + the camera UI views.
    // We don't use ANY of it (we load the .so directly via
    // System.loadLibrary and use vision-camera for camera UI).
    // Excluding the whole package side-steps a wave of compile
    // errors caused by missing AIDL-generated symbols and the
    // missing `org.opencv.R` resource class.
    tasks.withType(JavaCompile).configureEach {
        exclude 'org/opencv/android/**'
        exclude 'org/opencv/engine/**'
    }

    buildTypes {
        release {
            minifyEnabled false
        }
    }
}

// ── Host-OpenCV producer-task wiring (v0.24.5) ───────────────────────
//
// When the host's OpenCV SDK is generated by one of their Gradle tasks,
// every task of ours that READS that directory needs an explicit
// dependency edge on it.  Gradle 8 detects the missing edge and fails
// configuration rather than racing — see `rnisHostOpenCVProducerTask`
// above for the exact error.
//
// The consumers are the AGP tasks that walk our `sourceSets`: resource
// generation/merging (java/res), the Kotlin/Java compiles (java/src),
// and the jniLib packaging (native/libs).  Matching by name prefix
// covers every build type and variant without hardcoding a list that
// AGP would later rename underneath us.
if (usingHostOpenCV && hostOpenCVProducerTask) {
    def producer = String.valueOf(hostOpenCVProducerTask)
    logger.lifecycle(
        "react-native-image-stitcher: host OpenCV is produced by ${producer}; " +
        "wiring task dependencies"
    )
    // Matched by BOTH task type and name.  The name prefixes alone were
    // not enough: the CMake tasks are named `configureCMakeDebug[arm64-v8a]`
    // / `buildCMakeDebug[...]` / `externalNativeBuildDebug`, none of which
    // start with any of them — and those are the tasks that consume the SDK
    // FIRST, via -DOPENCV_ANDROID_SDK, and are UPSTREAM of the merge tasks
    // that did get the edge.  Gradle was therefore still free to run the
    // native build before the host's download, configuring CMake against a
    // missing or half-extracted SDK.
    //
    // Type matching covers the native tasks robustly (they are renamed per
    // ABI and per variant); the name prefixes cover the source-set
    // consumers (resources, Kotlin/Java compile, jniLibs packaging).
    tasks.configureEach { t ->
        def isNative = t.class.name.contains('ExternalNativeBuild')
        def isConsumer = t.name ==~ /^(generate|merge|package|compile|bundle|extract|map|process).*/
        def isCMake = t.name.toLowerCase().contains('cmake')
        if (isNative || isCMake || isConsumer) {
            t.dependsOn producer
        }
    }
}

repositories {
    google()
    mavenCentral()
    // OpenCV Android — the QuickBird build is the most up-to-date
    // Maven artifact with full module set (including stitching).
    maven { url 'https://jitpack.io' }
}

dependencies {
    // React Native — provided by the host app via gradle's
    // "compileOnly" so we don't pull a duplicate copy when the
    // SDK is consumed from a hosting RN app.
    compileOnly "com.facebook.react:react-android"

    // OpenCV is wired in via sourceSets above (java/src compiled
    // alongside Kotlin, native/libs surfaced as jniLibs).  No
    // Maven coordinate needed.
    //
    // …except in v0.24.4's `rnisHostOpenCVPackagedByHost` mode, where
    // the host packages OpenCV and we compile against it instead.
    // `compileOnly` is deliberate: the classes must NOT be packaged
    // again by us — that's the whole point of the mode.
    if (hostOpenCVPackagedByHost) {
        compileOnly hostOpenCVDependency
    }

    // Kotlin stdlib + coroutines for the background-queue dispatch
    // pattern that matches the iOS DispatchQueue.global(.userInitiated).
    implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.22"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"

    // ExifInterface for reading rotation metadata from JPEGs taken
    // by Android cameras (which save in sensor-native landscape
    // with an EXIF Orientation tag, same pattern as iOS).  The
    // androidx version supports more file types and stays
    // up-to-date with platform changes.
    implementation "androidx.exifinterface:exifinterface:1.3.7"

    // ARCore for Phase 4 of the AR-measurement plan: 6DoF pose
    // tracking from camera + IMU on supported Android devices.
    // Falls back to "AR not available" when:
    //   - Device isn't on Google's ARCore-supported list
    //   - Google Play Services for AR isn't installed / up-to-date
    // The sample app doesn't require AR, so the dependency adds
    // ~2 MB to the AAR; the Play Services for AR app is downloaded
    // on demand (~30 MB) the first time the user opens an AR
    // capture screen on a supported device.
    implementation "com.google.ar:core:1.45.0"

    // F8.4 — vision-camera as a compile-time peer dep.  Same
    // `compileOnly` pattern as React Native above: host apps that
    // wire `<Camera>` already include the autolinked
    // `:react-native-vision-camera` Gradle project; we just need
    // `com.mrousavy.camera.frameprocessors.*` types on the compile
    // classpath to build `CvFlowGateFrameProcessor.kt` + the
    // registration in `RNImageStitcherPackage`.  Runtime
    // resolution is the host's responsibility —
    // `RNImageStitcherPackage`'s static initialiser catches
    // `NoClassDefFoundError` so the SDK still loads if a non-
    // camera consumer omits the dep.
    //
    // `findProject(...)` guard: lets the SDK still compile in
    // hosts that haven't installed react-native-vision-camera.
    // The plugin file's import resolution will fail in that case,
    // which is fine — we conditionally exclude the plugin sources
    // below.
    if (findProject(':react-native-vision-camera') != null) {
        compileOnly project(':react-native-vision-camera')
        // CameraX `ImageProxy` / `ImageInfo` types — vision-camera
        // exposes them through `Frame.getImageProxy()` and we need
        // the compile-time class for `imageInfo.rotationDegrees`.
        // `compileOnly` because the host app already ships these via
        // vision-camera's transitive runtime dep.
        compileOnly "androidx.camera:camera-core:1.5.0-alpha03"
    } else {
        // Without vision-camera on the classpath the Frame
        // Processor plugin source can't compile (imports unresolved).
        // Exclude it from the source set so the rest of the SDK
        // still builds for non-camera consumers.
        android.sourceSets.main.java.exclude '**/CvFlowGateFrameProcessor.kt'
    }

    // v0.8.0 Phase 4b.ii/iii — react-native-worklets-core, consumed
    // for its `rnworklets` PREFAB module (RNWorklet::JsiWorkletContext /
    // WorkletInvoker) by the AR frame-processor JSI fan-out
    // (src/main/cpp/stitcher_jsi_install_jni.cpp + the shared cpp/
    // stitcher_*_jsi.cpp).  `find_package(react-native-worklets-core ...)`
    // in CMakeLists.txt needs this gradle project on the path so AGP
    // wires the prefab into the native build.  Same consumption pattern
    // react-native-vision-camera uses for Frame Processors.
    //
    // `findProject(...)` guard mirrors the vision-camera block above:
    // the SDK still compiles in hosts that don't install worklets-core
    // (those hosts simply don't use the AR frame processor — the JSI
    // sources still compile, but the prefab link only happens when the
    // host provides worklets-core).  Autolinking adds the project for
    // any host that depends on vision-camera (which transitively pulls
    // in worklets-core).
    if (findProject(':react-native-worklets-core') != null) {
        implementation project(':react-native-worklets-core')
    }

    // v0.10.0 audit #11A — Android JUnit test scaffold.  JVM unit
    // tests for pure-Kotlin data wrappers + algorithm helpers that
    // don't need an Android device.  Run via
    // `gradlew :react-native-image-stitcher:test`.
    //
    // Kept minimal — only JUnit 4 (matches AGP's default test
    // runner).  Hosts that want to add their own android-test
    // dependencies can do so independently.
    testImplementation "junit:junit:4.13.2"
}

// Helper from the React Native gradle convention to read host-app
// SDK versions when present, with a sane default otherwise.
def safeExtGet(prop, fallback) {
    rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
