diff --git a/.eslintrc.js b/.eslintrc.js index c935d01b..304591de 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -77,6 +77,13 @@ module.exports = { '@typescript-eslint/no-require-imports': 'off', }, }, + // Expo loads config plugins as CommonJS + { + files: ['app.plugin.js', 'plugin/**/*.js'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, // Test files { files: ['**/*.test.js', '**/*.test.ts', '**/e2e/**/*.js'], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 541b11f8..48d62ba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,15 +156,22 @@ jobs: run: | find . -name "*.kt" -not -path "*/build/*" | xargs java -jar $HOME/ktfmt/ktfmt.jar --google-style --dry-run --set-exit-if-changed - # Required check on the googlemaps organization, so the job has to report a - # status. There is no test orchestration for Android and iOS yet; replace the - # placeholder step once Detox runs on both platforms. + # Required check on the googlemaps organization. Runs the Node unit tests; + # the Detox integration tests for Android and iOS run outside CI. test: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - name: Placeholder test step - run: echo "No tests to run yet" + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Run tests + run: yarn test build-library: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 0d2bdb86..70d7d740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,8 @@ The repository contains one React Native library and its Yarn workspace example: Objective-C++, including CarPlay support. - `react-native-navigation-sdk.podspec`: iOS package integration and native SDK dependency configuration. +- `app.plugin.js` and `plugin/`: Expo config plugin, in CommonJS JavaScript + because Expo loads it directly. Its unit tests live in `plugin/__tests__/`. - `lib/`: Generated CommonJS, ES module, and TypeScript declaration outputs; ignored by Git. - `example/`: Sample app, native app projects, and test infrastructure. @@ -66,8 +68,8 @@ The repository contains one React Native library and its Yarn workspace example: - `example/src/screens/integration_tests/`: Integration-test support code. - `scripts/`: Native formatting and license-header scripts. - `.github/workflows/` and `.github/actions/setup/`: CI checks and tool setup. -- `ANDROIDAUTO.md`, `CARPLAY.md`, and `MIGRATING.md`: Specialized setup and - migration documentation. +- `ANDROIDAUTO.md`, `CARPLAY.md`, `EXPO.md`, and `MIGRATING.md`: Specialized + setup and migration documentation. ## Development setup @@ -125,6 +127,9 @@ Run commands from the repository root unless noted otherwise. yarn lint yarn test:types +# Run the Node unit tests. +yarn test + # Build CommonJS, ES modules, and TypeScript declarations with Bob. yarn build @@ -201,8 +206,12 @@ A cross-platform API change commonly touches several layers. Check each layer: Objective-C/Objective-C++ modules, views, and translation utilities. 5. Regenerate and compile native bindings through both platform builds. 6. Add integration coverage and update the example app. -7. Update `README.md` and, when relevant, `MIGRATING.md`, `ANDROIDAUTO.md`, or - `CARPLAY.md`. +7. Update `README.md` and, when relevant, `MIGRATING.md`, `ANDROIDAUTO.md`, + `CARPLAY.md`, or `EXPO.md`. + +A change to the native setup the package requires, such as a new Gradle +setting or `AppDelegate` call, also updates the Expo config plugin in +`plugin/`, its tests, and `EXPO.md`. For platform-specific limitations, preserve existing documented semantics and explain the limitation in public API documentation. Do not introduce an @@ -229,6 +238,9 @@ Run the narrowest relevant checks while iterating, then verify every affected layer before finishing: - TypeScript changes: run `yarn lint`, `yarn test:types`, and `yarn build`. +- Expo config plugin changes: run `yarn lint` and `yarn test`, then run + `npx expo prebuild --clean` in an Expo app that installs the packed package + (`npm pack`) and build the generated iOS and Android projects. - Android changes: also run native formatting checks and the Android example build. - iOS changes: also run native formatting checks, install pods as needed, and @@ -238,8 +250,8 @@ layer before finishing: - Documentation-only changes: validate referenced paths, commands, and diff whitespace; native builds are not necessary. -CI does not run tests: `.github/workflows/ci.yml` has no test job, and there is -no root `yarn test` script. The example has a Jest script, but it is not a +CI's `test` job runs `yarn test`, the Node unit tests for the Expo config +plugin. CI does not run Detox. The example has a Jest script, but it is not a substitute for the Detox build-and-test flow. ### Detox integration tests diff --git a/EXPO.md b/EXPO.md new file mode 100644 index 00000000..68cde4dd --- /dev/null +++ b/EXPO.md @@ -0,0 +1,117 @@ +# Using the Navigation SDK with Expo + +This guide explains how to set up the React Native Navigation SDK in an Expo app. +The package ships an [Expo config plugin](https://docs.expo.dev/config-plugins/introduction/) +that applies the native setup during `npx expo prebuild`. + +## Requirements + +- Expo SDK 58 or later. Earlier SDKs ship a React Native version below this + package's minimum; see [React Native Compatibility](./README.md#react-native-compatibility). +- A [development build](https://docs.expo.dev/develop/development-builds/introduction/). + Expo Go cannot load this package's native code, so start the app with + `npx expo run:android` and `npx expo run:ios`, or build it with EAS Build. + +## Setup + +Install the package and `expo-build-properties`: + +```shell +npx expo install @googlemaps/react-native-navigation-sdk expo-build-properties +``` + +Add the required settings to the `expo` object in `app.json`, keeping the +fields your app already has: + +```json +{ + "expo": { + "ios": { + "deploymentTarget": "17.0", + "infoPlist": { + "NSLocationWhenInUseUsageDescription": "Navigation needs your location.", + "NSLocationAlwaysAndWhenInUseUsageDescription": "Navigation needs your location.", + "NSMotionUsageDescription": "Used to improve navigation features.", + "UIBackgroundModes": ["location", "audio"] + } + }, + "plugins": [ + "@googlemaps/react-native-navigation-sdk", + ["expo-build-properties", { "android": { "kotlinVersion": "2.3.21" } }] + ] + } +} +``` + +`npx expo install` may already have added `expo-build-properties` to `plugins` +without options; keep a single entry with the Kotlin version. The override is +required because the Navigation SDK needs Kotlin Gradle Plugin 2.3.0 or later; +see the [Migration Guide](./MIGRATING.md). + +Set the API keys in `ios.config.googleMapsApiKey` and +`android.config.googleMaps.apiKey`. Expo writes them to `Info.plist` as +`GMSApiKey` and to `AndroidManifest.xml`. To read them from environment +variables, add an `app.config.js` that extends `app.json`: + +```js +module.exports = ({ config }) => ({ + ...config, + ios: { + ...config.ios, + config: { googleMapsApiKey: process.env.GOOGLE_MAPS_IOS_API_KEY }, + }, + android: { + ...config.android, + config: { googleMaps: { apiKey: process.env.GOOGLE_MAPS_ANDROID_API_KEY } }, + }, +}); +``` + +Then generate the native projects: + +```shell +npx expo prebuild --clean +``` + +## Location permission + +The `infoPlist` usage descriptions only provide the text of the iOS permission +prompt. The app requests location permission at runtime before it initializes +navigation; see +[Requesting and handling permissions](./README.md#requesting-and-handling-permissions). +In an Expo app, `expo-location` can request it: + +```shell +npx expo install expo-location +``` + +```tsx +import * as Location from 'expo-location'; + +const { granted } = await Location.requestForegroundPermissionsAsync(); +``` + +## What the plugin changes + +The plugin applies the native setup that Expo's app config does not cover: + +- **iOS**: adds `import GoogleMaps` to `AppDelegate.swift` and passes the + `Info.plist` `GMSApiKey` value to `GMSServices.provideAPIKey` at launch. +- **Android**: enables Jetifier in `gradle.properties`, and enables core library + desugaring with `desugar_jdk_libs_nio` in `app/build.gradle`. + +`npx expo prebuild` fails with an error from the plugin when an API key is not +set, or when it cannot find where to add its code in `AppDelegate.swift`. The +plugin supports the Swift `AppDelegate` and the Groovy `app/build.gradle` that +Expo SDK 58 generates. + +Other packages that depend on the Google Maps SDK, such as `react-native-maps`, +may conflict with this package; see +[Compatibility with other libraries](./README.md#compatibility-with-other-libraries). + +## Projects that keep native folders + +If your project commits its `ios` and `android` folders instead of generating +them with `npx expo prebuild`, the plugin does not run. Follow the +[Android](./README.md#android) and [iOS](./README.md#ios) setup in the README +instead. diff --git a/README.md b/README.md index dbbf4f50..0885cfdd 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ In your TSX or JSX file, import the components you need: import { NavigationView } from '@googlemaps/react-native-navigation-sdk'; ``` +### Expo + +Expo apps that generate their native projects with `npx expo prebuild` use the +package's config plugin instead of the Android and iOS steps below. See the +[Expo documentation](./EXPO.md). + ### Android This package uses Navigation SDK for Android **8.0.0**. See the @@ -266,7 +272,7 @@ const initializeNavigation = useCallback(async () => { // Initialize the navigation session and check the status const status = await navigationController.init(); - + switch (status) { case NavigationSessionStatus.OK: console.log('Navigation initialized successfully'); @@ -360,8 +366,8 @@ await navigationController.startGuidance(); #### Adding navigation listeners ```tsx -const { - navigationController, +const { + navigationController, removeAllListeners, setOnArrival, setOnRouteChanged, @@ -616,8 +622,8 @@ The `useNavigation()` hook provides access to the `NavigationController` and lis ```tsx import { useNavigation } from '@googlemaps/react-native-navigation-sdk'; -const { - navigationController, +const { + navigationController, removeAllListeners, setOnArrival, setOnRouteChanged, @@ -703,7 +709,7 @@ useEffect(() => { } }); setOnRouteChanged(() => console.log('Route changed')); - + // Use removeAllListeners() to clear all listeners at once on cleanup // Alternatively, clear individual listeners: setOnArrival(null) return () => removeAllListeners(); @@ -761,8 +767,8 @@ For Android Auto and CarPlay support, the `useNavigationAuto()` hook provides a ```tsx import { useNavigationAuto } from '@googlemaps/react-native-navigation-sdk'; -const { - mapViewAutoController, +const { + mapViewAutoController, removeAllListeners, setOnAutoScreenAvailabilityChanged, setOnCustomNavigationAutoEvent, diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 00000000..e607b5a1 --- /dev/null +++ b/app.plugin.js @@ -0,0 +1,17 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = require('./plugin'); diff --git a/package.json b/package.json index 35246b88..c674c989 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "android", "ios", "cpp", + "app.plugin.js", + "plugin", "*.podspec", "!ios/build", "!android/build", @@ -50,6 +52,7 @@ }, "scripts": { "example": "yarn workspace react-native-navigation-sdk-sample", + "test": "node --test \"plugin/**/*.test.js\"", "test:types": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", "lint-fix": "eslint \"**/*.{js,ts,tsx}\" --fix", @@ -87,9 +90,15 @@ "typescript": "~6.0.3" }, "peerDependencies": { + "expo": "*", "react": "*", "react-native": ">=0.87.0" }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + }, "workspaces": [ "example" ], diff --git a/plugin/__tests__/appDelegate.test.js b/plugin/__tests__/appDelegate.test.js new file mode 100644 index 00000000..39154707 --- /dev/null +++ b/plugin/__tests__/appDelegate.test.js @@ -0,0 +1,131 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { addGoogleMapsApiKey } = require('../appDelegate'); + +// Has the shape of the AppDelegate.swift that Expo SDK 58 generates: the file +// starts with `internal import Expo`, and didFinishLaunchingWithOptions takes a +// default argument and spans several lines. +const template = [ + 'internal import Expo', + 'import React', + 'import ReactAppDependencyProvider', + '', + '@main', + 'class AppDelegate: ExpoAppDelegate {', + ' public override func application(', + ' _ application: UIApplication,', + ' didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil', + ' ) -> Bool {', + ' let delegate = ReactNativeDelegate()', + ' return super.application(application, didFinishLaunchingWithOptions: launchOptions)', + ' }', + '}', + '', +].join('\n'); + +test('adds import GoogleMaps after the template imports, including internal import Expo', () => { + const lines = addGoogleMapsApiKey(template).split('\n'); + + assert.deepEqual(lines.slice(0, 5), [ + 'internal import Expo', + 'import React', + 'import ReactAppDependencyProvider', + 'import GoogleMaps', + '', + ]); +}); + +test('adds import GoogleMaps when the only import has an access-level modifier', () => { + const onlyExpo = template.replace( + 'import React\nimport ReactAppDependencyProvider\n', + '' + ); + + const lines = addGoogleMapsApiKey(onlyExpo).split('\n'); + + assert.deepEqual(lines.slice(0, 2), [ + 'internal import Expo', + 'import GoogleMaps', + ]); +}); + +test('provides the Info.plist GMSApiKey as the first statement of didFinishLaunchingWithOptions', () => { + const result = addGoogleMapsApiKey(template); + const launch = result.indexOf( + 'didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil\n ) -> Bool {\n' + ); + assert.notEqual(launch, -1); + + const body = result.slice(launch).split('\n').slice(2, 9); + assert.deepEqual(body, [ + ' // @generated begin react-native-navigation-sdk-api-key', + ' guard let googleMapsApiKey = Bundle.main.object(forInfoDictionaryKey: "GMSApiKey") as? String else {', + ' fatalError("GMSApiKey is missing from Info.plist. Set ios.config.googleMapsApiKey in the Expo app config.")', + ' }', + ' GMSServices.provideAPIKey(googleMapsApiKey)', + ' // @generated end react-native-navigation-sdk-api-key', + ' let delegate = ReactNativeDelegate()', + ]); +}); + +test('running twice leaves the file unchanged', () => { + const once = addGoogleMapsApiKey(template); + + assert.equal(addGoogleMapsApiKey(once), once); +}); + +test('replaces an outdated generated block in place', () => { + const current = addGoogleMapsApiKey(template); + const outdated = current.replace( + 'GMSServices.provideAPIKey(googleMapsApiKey)', + 'GMSServices.provideAPIKey("OLD")' + ); + + assert.equal(addGoogleMapsApiKey(outdated), current); +}); + +test('keeps an existing import GoogleMaps', () => { + const imported = template.replace( + 'import React\n', + 'import React\nimport GoogleMaps\n' + ); + + const result = addGoogleMapsApiKey(imported); + + assert.equal(result.match(/^import GoogleMaps$/gm).length, 1); +}); + +test('fails when the file has no import statements', () => { + const noImports = template.replace(/^.*import .*\n/gm, ''); + + assert.throws(() => addGoogleMapsApiKey(noImports), { + code: 'APP_DELEGATE_IMPORTS_NOT_FOUND', + }); +}); + +test('fails when didFinishLaunchingWithOptions is missing', () => { + const noLaunch = template.replace( + 'didFinishLaunchingWithOptions', + 'willFinishLaunchingWithOptions' + ); + + assert.throws(() => addGoogleMapsApiKey(noLaunch), { + code: 'APP_DELEGATE_LAUNCH_METHOD_NOT_FOUND', + }); +}); diff --git a/plugin/__tests__/buildGradle.test.js b/plugin/__tests__/buildGradle.test.js new file mode 100644 index 00000000..2ed05c60 --- /dev/null +++ b/plugin/__tests__/buildGradle.test.js @@ -0,0 +1,67 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { addCoreLibraryDesugaring } = require('../buildGradle'); + +const template = [ + 'apply plugin: "com.android.application"', + '', + 'android {', + ' namespace "com.example.app"', + '}', + '', + 'dependencies {', + ' implementation("com.facebook.react:react-android")', + '}', + '', +].join('\n'); + +const block = [ + '// @generated begin react-native-navigation-sdk-desugaring', + 'android {', + ' compileOptions {', + ' coreLibraryDesugaringEnabled true', + ' }', + '}', + '', + 'dependencies {', + " coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'", + '}', + '// @generated end react-native-navigation-sdk-desugaring', + '', +].join('\n'); + +test('appends the desugaring block to the template', () => { + const result = addCoreLibraryDesugaring(template); + + assert.ok(result.startsWith(template.trimEnd())); + assert.ok(result.endsWith(`\n\n${block}`)); +}); + +test('running twice leaves the file unchanged', () => { + const once = addCoreLibraryDesugaring(template); + + assert.equal(addCoreLibraryDesugaring(once), once); +}); + +test('updates an outdated desugaring version in place', () => { + const current = addCoreLibraryDesugaring(template); + const outdated = current.replace('nio:2.1.5', 'nio:2.0.4'); + + assert.equal(addCoreLibraryDesugaring(outdated), current); +}); diff --git a/plugin/__tests__/gradleProperties.test.js b/plugin/__tests__/gradleProperties.test.js new file mode 100644 index 00000000..686f046b --- /dev/null +++ b/plugin/__tests__/gradleProperties.test.js @@ -0,0 +1,42 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { enableJetifier } = require('../gradleProperties'); + +test('adds android.enableJetifier=true when it is absent', () => { + const properties = [ + { type: 'property', key: 'android.useAndroidX', value: 'true' }, + ]; + + assert.deepEqual(enableJetifier(properties), [ + { type: 'property', key: 'android.useAndroidX', value: 'true' }, + { type: 'property', key: 'android.enableJetifier', value: 'true' }, + ]); +}); + +test('turns on an existing android.enableJetifier=false', () => { + const properties = [ + { type: 'comment', value: 'Jetifier' }, + { type: 'property', key: 'android.enableJetifier', value: 'false' }, + ]; + + assert.deepEqual(enableJetifier(properties), [ + { type: 'comment', value: 'Jetifier' }, + { type: 'property', key: 'android.enableJetifier', value: 'true' }, + ]); +}); diff --git a/plugin/appDelegate.js b/plugin/appDelegate.js new file mode 100644 index 00000000..1be0131c --- /dev/null +++ b/plugin/appDelegate.js @@ -0,0 +1,76 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { generatedBlock, upsertGeneratedBlock } = require('./generatedBlock'); +const { pluginError } = require('./pluginError'); + +const TAG = 'react-native-navigation-sdk-api-key'; + +// Matches access-level and attribute prefixes, such as the SDK 58 template's +// `internal import Expo`. +const IMPORT_LINE = /^(?:[\w@]+[ \t]+)*import[ \t]+[\w.]+[ \t]*$/gm; +const GOOGLE_MAPS_IMPORT = /^(?:[\w@]+[ \t]+)*import[ \t]+GoogleMaps[ \t]*$/m; +const DID_FINISH_LAUNCHING = + /func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions[^{]*\{[^\n]*\n/; + +// Expo writes `ios.config.googleMapsApiKey` to Info.plist as `GMSApiKey`, so +// the key has a single source and never appears in Swift source. +const API_KEY_BLOCK = generatedBlock(TAG, ' ', [ + 'guard let googleMapsApiKey = Bundle.main.object(forInfoDictionaryKey: "GMSApiKey") as? String else {', + ' fatalError("GMSApiKey is missing from Info.plist. Set ios.config.googleMapsApiKey in the Expo app config.")', + '}', + 'GMSServices.provideAPIKey(googleMapsApiKey)', +]); + +function addGoogleMapsImport(contents) { + if (GOOGLE_MAPS_IMPORT.test(contents)) { + return contents; + } + const imports = [...contents.matchAll(IMPORT_LINE)]; + if (imports.length === 0) { + throw pluginError( + 'APP_DELEGATE_IMPORTS_NOT_FOUND', + 'Could not find the import statements in AppDelegate.swift to add `import GoogleMaps`.' + ); + } + const last = imports[imports.length - 1]; + const end = last.index + last[0].length; + return `${contents.slice(0, end)}\nimport GoogleMaps${contents.slice(end)}`; +} + +function insertAtLaunch(contents, block) { + const launch = contents.match(DID_FINISH_LAUNCHING); + if (!launch) { + throw pluginError( + 'APP_DELEGATE_LAUNCH_METHOD_NOT_FOUND', + 'Could not find application(_:didFinishLaunchingWithOptions:) in AppDelegate.swift to provide the Google Maps API key.' + ); + } + const end = launch.index + launch[0].length; + return `${contents.slice(0, end)}${block}\n${contents.slice(end)}`; +} + +/** Makes AppDelegate.swift import GoogleMaps and pass the Info.plist API key to GMSServices at launch. */ +function addGoogleMapsApiKey(contents) { + return upsertGeneratedBlock( + addGoogleMapsImport(contents), + TAG, + API_KEY_BLOCK, + insertAtLaunch + ); +} + +module.exports = { addGoogleMapsApiKey }; diff --git a/plugin/buildGradle.js b/plugin/buildGradle.js new file mode 100644 index 00000000..5d34c98b --- /dev/null +++ b/plugin/buildGradle.js @@ -0,0 +1,42 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { generatedBlock, upsertGeneratedBlock } = require('./generatedBlock'); + +const TAG = 'react-native-navigation-sdk-desugaring'; + +const DESUGARING_BLOCK = generatedBlock(TAG, '', [ + 'android {', + ' compileOptions {', + ' coreLibraryDesugaringEnabled true', + ' }', + '}', + '', + 'dependencies {', + " coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'", + '}', +]); + +function append(contents, block) { + return `${contents.trimEnd()}\n\n${block}\n`; +} + +/** Enables core library desugaring, which the Navigation SDK requires, in the Groovy app/build.gradle. */ +function addCoreLibraryDesugaring(contents) { + return upsertGeneratedBlock(contents, TAG, DESUGARING_BLOCK, append); +} + +module.exports = { addCoreLibraryDesugaring }; diff --git a/plugin/generatedBlock.js b/plugin/generatedBlock.js new file mode 100644 index 00000000..1e3a0b54 --- /dev/null +++ b/plugin/generatedBlock.js @@ -0,0 +1,45 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Wraps `lines` in `// @generated` markers, indenting every non-empty line. */ +function generatedBlock(tag, indent, lines) { + return [`// @generated begin ${tag}`, ...lines, `// @generated end ${tag}`] + .map(line => (line ? indent + line : line)) + .join('\n'); +} + +/** + * Replaces the block tagged `tag` in place, or calls `insert` when the file + * has none. Running `expo prebuild` without `--clean` reuses the native + * project, so this keeps a second run from adding a duplicate block. + */ +function upsertGeneratedBlock(contents, tag, block, insert) { + const markers = new RegExp( + `^[ \\t]*// @generated begin ${escapeRegExp(tag)}$[\\s\\S]*?` + + `^[ \\t]*// @generated end ${escapeRegExp(tag)}$`, + 'm' + ); + if (markers.test(contents)) { + return contents.replace(markers, () => block); + } + return insert(contents, block); +} + +module.exports = { generatedBlock, upsertGeneratedBlock }; diff --git a/plugin/gradleProperties.js b/plugin/gradleProperties.js new file mode 100644 index 00000000..bce02c6c --- /dev/null +++ b/plugin/gradleProperties.js @@ -0,0 +1,34 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Sets `android.enableJetifier=true` in Expo's parsed gradle.properties entries. */ +function enableJetifier(properties) { + const existing = properties.find( + entry => entry.type === 'property' && entry.key === 'android.enableJetifier' + ); + if (existing) { + existing.value = 'true'; + } else { + properties.push({ + type: 'property', + key: 'android.enableJetifier', + value: 'true', + }); + } + return properties; +} + +module.exports = { enableJetifier }; diff --git a/plugin/index.js b/plugin/index.js new file mode 100644 index 00000000..f38a5f22 --- /dev/null +++ b/plugin/index.js @@ -0,0 +1,85 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { + createRunOncePlugin, + withAppBuildGradle, + withAppDelegate, + withGradleProperties, + withPlugins, +} = require('expo/config-plugins'); +const pkg = require('../package.json'); +const { addGoogleMapsApiKey } = require('./appDelegate'); +const { addCoreLibraryDesugaring } = require('./buildGradle'); +const { enableJetifier } = require('./gradleProperties'); +const { pluginError } = require('./pluginError'); + +// Expo evaluates plugins every time it reads the app config, including from +// native build phases such as expo-constants. Checks therefore run only inside +// the mods, which execute during `expo prebuild`, so a build never fails on an +// API key that is already written into the native project. + +const withIosApiKey = config => + withAppDelegate(config, mod => { + if (!mod.ios?.config?.googleMapsApiKey) { + throw pluginError( + 'IOS_API_KEY_MISSING', + 'Set ios.config.googleMapsApiKey in the Expo app config.' + ); + } + if (mod.modResults.language !== 'swift') { + throw pluginError( + 'APP_DELEGATE_NOT_SWIFT', + `Expected a Swift AppDelegate, found ${mod.modResults.language}. Expo SDK 58 and later generate a Swift AppDelegate.` + ); + } + mod.modResults.contents = addGoogleMapsApiKey(mod.modResults.contents); + return mod; + }); + +const withAndroidDesugaring = config => + withAppBuildGradle(config, mod => { + if (!mod.android?.config?.googleMaps?.apiKey) { + throw pluginError( + 'ANDROID_API_KEY_MISSING', + 'Set android.config.googleMaps.apiKey in the Expo app config.' + ); + } + if (mod.modResults.language !== 'groovy') { + throw pluginError( + 'BUILD_GRADLE_NOT_GROOVY', + `Expected a Groovy app/build.gradle, found ${mod.modResults.language}.` + ); + } + mod.modResults.contents = addCoreLibraryDesugaring(mod.modResults.contents); + return mod; + }); + +const withAndroidJetifier = config => + withGradleProperties(config, mod => { + mod.modResults = enableJetifier(mod.modResults); + return mod; + }); + +/** Applies the native project setup that the Navigation SDK needs and Expo's app config does not cover. */ +const withNavigationSdk = config => + withPlugins(config, [ + withIosApiKey, + withAndroidDesugaring, + withAndroidJetifier, + ]); + +module.exports = createRunOncePlugin(withNavigationSdk, pkg.name, pkg.version); diff --git a/plugin/pluginError.js b/plugin/pluginError.js new file mode 100644 index 00000000..addcc736 --- /dev/null +++ b/plugin/pluginError.js @@ -0,0 +1,26 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Creates an error that callers and tests can tell apart by its `code`. */ +function pluginError(code, message) { + const error = new Error( + `[@googlemaps/react-native-navigation-sdk] ${message}` + ); + error.code = code; + return error; +} + +module.exports = { pluginError }; diff --git a/yarn.lock b/yarn.lock index cb868c53..611baac2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3039,8 +3039,12 @@ __metadata: turbo: ^1.10.7 typescript: ~6.0.3 peerDependencies: + expo: "*" react: "*" react-native: ">=0.87.0" + peerDependenciesMeta: + expo: + optional: true languageName: unknown linkType: soft