Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,11 @@ module.exports = ({config}: ConfigContext): Partial<ExpoConfig> => {
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
Expand Down
8 changes: 7 additions & 1 deletion mobile/modules/crust/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nav-off compile needs Mapbox repo

High Severity

When navigation is off, the crust plugin stops injecting the Mapbox Downloads Maven repo, but :mentra-crust still declares compileOnly Mapbox Navigation SDK dependencies. Gradle must still resolve those artifacts to compile NavigationManager.kt, which only exists in Mapbox’s private registry—so credential-free OEM builds fail on a clean cache.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f6602b. Configure here.

compileOnly 'com.mapbox.navigationcore:navigation:3.25.0'
compileOnly 'com.mapbox.navigationcore:tripdata:3.25.0'
Comment on lines 108 to 109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid resolving Mapbox in nav-off builds

When navigation is omitted or false, the config plugin no longer injects the Mapbox Downloads Maven repo, but this branch still puts com.mapbox.navigationcore:* on crust's compileOnly classpath. compileOnly is still resolved when compiling NavigationManager.kt; it only keeps the artifacts out of the APK. In a clean OEM host with no Mapbox repo/token and no cached artifacts, Gradle fails dependency resolution instead of producing the intended credential-free build.

Useful? React with 👍 / 👎.

} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any> =
mapOf("ok" to false, "error" to "navigation not available in this build")

companion object {
private const val TAG = "CrustModule"

Expand Down Expand Up @@ -640,6 +660,7 @@ class CrustModule : Module() {
// MARK: - Navigation Commands (Google Navigation SDK)

AsyncFunction("startNavigation") { lat: Double, lng: Double, options: Map<String, Any?>? ->
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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permission response missing accepted

Low Severity

When navigation is unavailable, requestNavigationPermission resolves navUnavailableResult, which only has ok and error. Other failure paths include accepted: false, and NavigationService.requestPermission types the result as {ok, accepted, error?}—callers may treat accepted as undefined instead of explicitly false.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f6602b. Configure here.

}
val activity = appContext.currentActivity
if (activity == null) {
promise.resolve(mapOf("ok" to false, "accepted" to false, "error" to "no current activity"))
Expand All @@ -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"))
Expand All @@ -818,6 +847,7 @@ class CrustModule : Module() {
}

AsyncFunction("stopNavigation") {
if (!navAvailable) return@AsyncFunction navUnavailableResult
try {
NavigationManager.stop()
mapOf("ok" to true)
Expand All @@ -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)
Expand All @@ -841,6 +872,7 @@ class CrustModule : Module() {
}

AsyncFunction("setWrongSidewalkOffset") { enabled: Boolean ->
if (!navAvailable) return@AsyncFunction navUnavailableResult
try {
NavigationManager.setWrongSidewalkOffset(enabled)
mapOf("ok" to true)
Expand All @@ -851,6 +883,7 @@ class CrustModule : Module() {
}

AsyncFunction("setSkipCrossings") { enabled: Boolean ->
if (!navAvailable) return@AsyncFunction navUnavailableResult
try {
NavigationManager.setSkipCrossings(enabled)
mapOf("ok" to true)
Expand Down
4 changes: 2 additions & 2 deletions mobile/modules/crust/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +9 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep building the config plugin on prepare

app.plugin.js requires ./plugin/build, and that directory is not tracked in the repo; it is produced by expo-module build plugin. Dropping that command from the normal build/prepare path means a clean install or published package can leave the plugin output missing, so Expo fails to load @mentra/crust before prebuild reaches any of the new Gradle logic.

Useful? React with 👍 / 👎.

"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module",
"open:ios": "xed example/ios",
Expand Down
36 changes: 28 additions & 8 deletions mobile/modules/crust/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CrustPluginProps | void> = (config, props) => {
const navigation = props?.navigation ?? false
return withCrustAndroidBuildContract(config, {navigation})
}

export default withCrust
39 changes: 33 additions & 6 deletions mobile/modules/crust/plugin/src/withAndroid.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,12 +28,14 @@ const MAPBOX_REPO = [

const PROTOBUF_EXCLUDE = "exclude group: 'com.google.protobuf', module: 'protobuf-javalite'"

function withCrustProjectGradle(config: Parameters<ConfigPlugin>[0]) {
function withCrustProjectGradle(config: Parameters<ConfigPlugin>[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
Expand Down Expand Up @@ -84,7 +96,22 @@ function withCrustAppGradle(config: Parameters<ConfigPlugin>[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<ConfigPlugin>[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)
}
Loading