diff --git a/mobile/app.config.ts b/mobile/app.config.ts index 1a0d289e6b..e57c36019e 100644 --- a/mobile/app.config.ts +++ b/mobile/app.config.ts @@ -218,9 +218,11 @@ module.exports = ({config}: ConfigContext): Partial => { plugins: [ // our custom plugins: "./plugins/remove-ipad-orientations.js", - // crust's own config plugin carries its Android build contract (Mapbox - // downloads repo, protobuf-javalite exclusion, core-library desugaring). - "@mentra/crust", + // crust's own config plugin carries its Android build contract (protobuf + // exclusion + desugaring always; Mapbox downloads repo only with nav). + // The Mentra app navigates, so it opts nav IN — a non-navigating OEM host + // omits the arg and builds without a Mapbox credential. + ["@mentra/crust", {navigation: true}], "./plugins/android.ts", // Mapbox Navigation SDK v3 for iOS — added as a Swift Package (SPM is the // ONLY supported v3 install path; CocoaPods can't resolve it). The diff --git a/mobile/modules/crust/android/build.gradle b/mobile/modules/crust/android/build.gradle index be910aa464..d24d860758 100644 --- a/mobile/modules/crust/android/build.gradle +++ b/mobile/modules/crust/android/build.gradle @@ -98,7 +98,13 @@ dependencies { // banner/maneuver formatting used to label turns. // Version pinned to 3.25.0 — re-check https://docs.mapbox.com/android/navigation/releases/ def isChinaBuild = System.getenv("EXPO_PUBLIC_DEPLOYMENT_REGION") == "china" - if (isChinaBuild) { + // Host opt-in via the @mentra/crust config plugin (default false). China has + // always excluded the Nav SDK from the APK; a non-navigating host does the + // same, so it needs no MAPBOX_DOWNLOADS_TOKEN to build. Either way crust still + // compiles against the SDK — the classes are just absent at runtime, and + // CrustModule's navAvailable guard returns a clean error if nav is invoked. + def navigationEnabled = (findProperty("mentraCrustNavigation") ?: "false").toString() == "true" + if (isChinaBuild || !navigationEnabled) { compileOnly 'com.mapbox.navigationcore:navigation:3.25.0' compileOnly 'com.mapbox.navigationcore:tripdata:3.25.0' } else { diff --git a/mobile/modules/crust/android/src/main/java/com/mentra/crust/CrustModule.kt b/mobile/modules/crust/android/src/main/java/com/mentra/crust/CrustModule.kt index 17fc46dbb0..1dcd5ca060 100644 --- a/mobile/modules/crust/android/src/main/java/com/mentra/crust/CrustModule.kt +++ b/mobile/modules/crust/android/src/main/java/com/mentra/crust/CrustModule.kt @@ -13,6 +13,26 @@ import com.mentra.crust.jsc.InstalledMiniappManifest import com.mentra.crust.jsc.JSCPolyfillBridge class CrustModule : Module() { + // Whether the Mapbox Navigation SDK is compiled into this APK. crust's config + // plugin defaults navigation off (compileOnly deps → classes absent at + // runtime) so non-navigating hosts build without a Mapbox credential; when + // off, the nav commands below return a clean error instead of crashing with + // NoClassDefFoundError. Resolved once, lazily (never during class init, which + // must not touch NavigationManager). Catches Throwable — a missing class + // surfaces as an Error, not an Exception. + private val navAvailable: Boolean by lazy { + try { + Class.forName("com.mapbox.navigation.core.MapboxNavigation") + true + } catch (t: Throwable) { + Log.i(TAG, "Mapbox Navigation SDK not compiled in — navigation commands disabled") + false + } + } + + private val navUnavailableResult: Map = + mapOf("ok" to false, "error" to "navigation not available in this build") + companion object { private const val TAG = "CrustModule" @@ -640,6 +660,7 @@ class CrustModule : Module() { // MARK: - Navigation Commands (Google Navigation SDK) AsyncFunction("startNavigation") { lat: Double, lng: Double, options: Map? -> + if (!navAvailable) return@AsyncFunction navUnavailableResult val activity = appContext.currentActivity ?: return@AsyncFunction mapOf("ok" to false, "error" to "no current activity (app backgrounded?)") val simulate = (options?.get("simulate") as? Boolean) ?: false @@ -785,6 +806,10 @@ class CrustModule : Module() { // friction-free. Idempotent — resolves immediately when the user has // already accepted (in-process flag, on-disk pref, or SDK state). AsyncFunction("requestNavigationPermission") { promise: expo.modules.kotlin.Promise -> + if (!navAvailable) { + promise.resolve(navUnavailableResult) + return@AsyncFunction + } val activity = appContext.currentActivity if (activity == null) { promise.resolve(mapOf("ok" to false, "accepted" to false, "error" to "no current activity")) @@ -801,6 +826,10 @@ class CrustModule : Module() { // pref + in-process) so the next requestNavigationPermission() call // re-shows the dialog. Used by the dev-settings re-trigger button. AsyncFunction("resetNavigationPermission") { promise: expo.modules.kotlin.Promise -> + if (!navAvailable) { + promise.resolve(navUnavailableResult) + return@AsyncFunction + } val activity = appContext.currentActivity if (activity == null) { promise.resolve(mapOf("ok" to false, "error" to "no current activity")) @@ -818,6 +847,7 @@ class CrustModule : Module() { } AsyncFunction("stopNavigation") { + if (!navAvailable) return@AsyncFunction navUnavailableResult try { NavigationManager.stop() mapOf("ok" to true) @@ -831,6 +861,7 @@ class CrustModule : Module() { // exercise the Nav SDK's onRerouting() pipeline without having to // physically walk off the planned path. No-op on real GPS fixes. AsyncFunction("simulateDeviation") { offsetMeters: Double? -> + if (!navAvailable) return@AsyncFunction navUnavailableResult try { NavigationManager.simulateDeviation(offsetMeters ?: 20.0) mapOf("ok" to true) @@ -841,6 +872,7 @@ class CrustModule : Module() { } AsyncFunction("setWrongSidewalkOffset") { enabled: Boolean -> + if (!navAvailable) return@AsyncFunction navUnavailableResult try { NavigationManager.setWrongSidewalkOffset(enabled) mapOf("ok" to true) @@ -851,6 +883,7 @@ class CrustModule : Module() { } AsyncFunction("setSkipCrossings") { enabled: Boolean -> + if (!navAvailable) return@AsyncFunction navUnavailableResult try { NavigationManager.setSkipCrossings(enabled) mapOf("ok" to true) diff --git a/mobile/modules/crust/package.json b/mobile/modules/crust/package.json index f47bd52d28..09c6f6dc8a 100644 --- a/mobile/modules/crust/package.json +++ b/mobile/modules/crust/package.json @@ -6,11 +6,11 @@ "types": "build/index.d.ts", "app.plugin": "app.plugin.js", "scripts": { - "build": "expo-module build && expo-module build plugin", + "build": "expo-module build", "clean": "expo-module clean", "lint": "expo-module lint", "test": "expo-module test", - "prepare": "expo-module prepare && expo-module build plugin", + "prepare": "expo-module prepare", "prepublishOnly": "expo-module prepublishOnly", "expo-module": "expo-module", "open:ios": "xed example/ios", diff --git a/mobile/modules/crust/plugin/src/index.ts b/mobile/modules/crust/plugin/src/index.ts index 4670260980..50276f3c59 100644 --- a/mobile/modules/crust/plugin/src/index.ts +++ b/mobile/modules/crust/plugin/src/index.ts @@ -2,24 +2,44 @@ import {type ConfigPlugin} from "expo/config-plugins" import {withCrustAndroidBuildContract} from "./withAndroid" +export interface CrustPluginProps { + /** + * Compile crust's Mapbox Navigation SDK dependency INTO the app (turn-by-turn + * nav). Default `false`: a host that doesn't navigate builds credential-free — + * the Mapbox Downloads repo is not injected and the Nav SDK is `compileOnly` + * (crust compiles, but the classes aren't in the APK, so no + * `MAPBOX_DOWNLOADS_TOKEN` is needed). The Mentra app passes `true`. + * + * When `false`, crust's native `startNavigation`/`stopNavigation` return a + * clean "navigation not available in this build" error instead of running. + */ + navigation?: boolean +} + /** * @mentra/crust config plugin — the module's own Android build contract, so * every host that embeds crust (the Mentra app, the example OEM app, real OEM * hosts) inherits it by listing "@mentra/crust" in `app.json` plugins instead * of hand-mirroring gradle edits: * - * - the authenticated Mapbox Downloads Maven repo (crust bundles the Mapbox - * Navigation SDK, whose artifacts exist only in Mapbox's private registry; - * the MAPBOX_DOWNLOADS_TOKEN env / gradle property authenticates it at - * dependency-resolution time — build-time only, never in the APK) * - a global protobuf-javalite exclusion (the bluetooth-sdk/crust native stack * needs protobuf-java; Mapbox drags protobuf-javalite transitively; shipping - * both fails the release build with duplicate classes) + * both fails the release build with duplicate classes) — always applied. * - core-library desugaring (crust's AAR metadata requires it of embedding - * apps — the Nav SDK uses newer core libs) + * apps) — always applied. + * - the authenticated Mapbox Downloads Maven repo — applied ONLY when + * `navigation` is enabled (the Nav SDK's artifacts live in Mapbox's private + * registry behind MAPBOX_DOWNLOADS_TOKEN; a non-navigating host must not need + * that credential to build). + * + * The `navigation` choice is also passed to crust's gradle as the + * `mentraCrustNavigation` property, which flips the Nav SDK dependency between + * `implementation` (in the APK) and `compileOnly` (compiled against, absent at + * runtime). */ -const withCrust: ConfigPlugin = (config) => { - return withCrustAndroidBuildContract(config) +const withCrust: ConfigPlugin = (config, props) => { + const navigation = props?.navigation ?? false + return withCrustAndroidBuildContract(config, {navigation}) } export default withCrust diff --git a/mobile/modules/crust/plugin/src/withAndroid.ts b/mobile/modules/crust/plugin/src/withAndroid.ts index be531373d0..f4fff363ec 100644 --- a/mobile/modules/crust/plugin/src/withAndroid.ts +++ b/mobile/modules/crust/plugin/src/withAndroid.ts @@ -1,4 +1,14 @@ -import {withAppBuildGradle, withProjectBuildGradle, type ConfigPlugin} from "expo/config-plugins" +import { + withAppBuildGradle, + withGradleProperties, + withProjectBuildGradle, + type ConfigPlugin, + type ExportedConfig, +} from "expo/config-plugins" + +interface CrustAndroidOptions { + navigation: boolean +} // Marker comments double as idempotency checks: each injection looks for its // OWN unique marker (never a shared substring — a marker one block contains @@ -18,12 +28,14 @@ const MAPBOX_REPO = [ const PROTOBUF_EXCLUDE = "exclude group: 'com.google.protobuf', module: 'protobuf-javalite'" -function withCrustProjectGradle(config: Parameters[0]) { +function withCrustProjectGradle(config: Parameters[0], navigation: boolean) { return withProjectBuildGradle(config, (cfg) => { let gradle = cfg.modResults.contents - // Mapbox Downloads repo, inserted at the top of allprojects.repositories. - if (!gradle.includes(MAPBOX_REPO_MARKER)) { + // Mapbox Downloads repo — ONLY when navigation is compiled in. A + // non-navigating host never resolves the Nav SDK, so it must not need the + // credential the repo authenticates. + if (navigation && !gradle.includes(MAPBOX_REPO_MARKER)) { const reposMatch = gradle.match(/allprojects\s*\{[\s\S]*?repositories\s*\{/) if (reposMatch) { const idx = (reposMatch.index ?? 0) + reposMatch[0].length @@ -84,7 +96,22 @@ function withCrustAppGradle(config: Parameters[0]) { }) } -export const withCrustAndroidBuildContract: ConfigPlugin = (config) => { - config = withCrustProjectGradle(config) +// crust's android/build.gradle reads this property to switch the Nav SDK +// dependency between `implementation` (in the APK) and `compileOnly`. +function withCrustNavigationProperty(config: Parameters[0], navigation: boolean) { + return withGradleProperties(config, (cfg) => { + const key = "mentraCrustNavigation" + cfg.modResults = cfg.modResults.filter((item) => !(item.type === "property" && item.key === key)) + cfg.modResults.push({type: "property", key, value: String(navigation)}) + return cfg + }) +} + +export const withCrustAndroidBuildContract = ( + config: ExportedConfig, + {navigation}: CrustAndroidOptions, +): ExportedConfig => { + config = withCrustNavigationProperty(config, navigation) + config = withCrustProjectGradle(config, navigation) return withCrustAppGradle(config) }