diff --git a/README.md b/README.md index b75d2db..4c10f3b 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,11 @@ behaviour. ## Setup with Expo (recommended) The package ships an Expo config plugin that wires up everything for you on -`expo prebuild`: AppDelegate bootstrap (iOS), MainApplication bootstrap -(Android), `Info.plist` permission + background-mode keys, foreground-service -notification factory, the Azure DevOps Maven repo, and the extra Android -permissions (`POST_NOTIFICATIONS`, `FOREGROUND_SERVICE`). +`expo prebuild`: AppDelegate bootstrap + background URL session forwarding +(iOS), MainApplication bootstrap (Android), `Info.plist` permission + +background-mode keys, foreground-service notification factory, the Azure +DevOps Maven repo, and the extra Android permissions (`POST_NOTIFICATIONS`, +`FOREGROUND_SERVICE`). Add the plugin to `app.json`: @@ -118,6 +119,9 @@ func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // Must be the FIRST statement: when iOS relaunches the killed app for a + // background location event, the SDK has to re-arm tracking before any + // React Native startup work runs. MotionTagBootstrap.bootstrap(launchOptions: launchOptions) // … rest of RN bootstrap … } @@ -127,10 +131,17 @@ func application( handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { - MotionTagBootstrap.processBackgroundSessionEvents( - identifier: identifier, - completionHandler: completionHandler - ) + if MotionTagBootstrap.handlesBackgroundURLSession(identifier: identifier) { + MotionTagBootstrap.processBackgroundSessionEvents( + identifier: identifier, + completionHandler: completionHandler + ) + } else { + // Forward sessions owned by other SDKs (Firebase, …) to their handlers, or + // finish them immediately if nothing else in the app uses background sessions. + // Each session's completion handler must be called exactly once. + completionHandler() + } } ``` @@ -158,8 +169,10 @@ import de.motiontag.reactnative.MotionTagBootstrap override fun onCreate() { super.onCreate() - loadReactNative(this) + // Init the SDK before React Native loads — the MotionTag SDK requires + // initialisation as early as possible in onCreate. MotionTagBootstrap.init(this, createNotification()) + loadReactNative(this) } ``` @@ -309,8 +322,12 @@ A version bump is **not** "just" a version bump when the changelog touches: `UIBackgroundModes`) → update both the [bare-RN snippet above](#ios--appdelegateswift) and the Expo plugin's Info.plist injection in `plugin/`. - iOS bootstrap signature (`MotionTagBootstrap.bootstrap`, - `processBackgroundSessionEvents`) → update `ios/MotionTagBootstrap.swift`, the - README snippet, and the plugin's AppDelegate injection. + `processBackgroundSessionEvents`, `handlesBackgroundURLSession`) → update + `ios/MotionTagBootstrap.swift`, the README snippet, and the plugin's + AppDelegate injection. Note: `handlesBackgroundURLSession` hard-codes the + SDK's background URL session identifier prefixes (`com.motion-tag.` / + `com.motiontag.`) — re-check them against the SDK binary on every iOS SDK + bump (`strings MotionTagSDK | grep -i session`). - Android manifest permissions or foreground-service contract → update `android/src/main/AndroidManifest.xml`, the plugin's manifest edits, and the Android section above. diff --git a/ios/MotionTagBootstrap.swift b/ios/MotionTagBootstrap.swift index eb7deea..8ac1856 100644 --- a/ios/MotionTagBootstrap.swift +++ b/ios/MotionTagBootstrap.swift @@ -14,6 +14,15 @@ import UIKit ) } + /// Whether the given background URL session belongs to the MotionTag SDK. Use this in + /// `application(_:handleEventsForBackgroundURLSession:completionHandler:)` to decide whether + /// to forward the event to `processBackgroundSessionEvents(identifier:completionHandler:)` or + /// to the host's other background-session owners (e.g. Expo modules, Firebase) — each session's + /// completion handler must be called exactly once, by exactly one owner. + @objc public static func handlesBackgroundURLSession(identifier: String) -> Bool { + return identifier.hasPrefix("com.motion-tag.") || identifier.hasPrefix("com.motiontag.") + } + /// Forward background URL session events so the SDK can finish background uploads on /// cold-launch wake-ups. Call from `application(_:handleEventsForBackgroundURLSession:completionHandler:)`. @objc public static func processBackgroundSessionEvents( diff --git a/plugin/withIosAppDelegate.js b/plugin/withIosAppDelegate.js index 1cc94ad..163665b 100644 --- a/plugin/withIosAppDelegate.js +++ b/plugin/withIosAppDelegate.js @@ -1,8 +1,69 @@ const { withAppDelegate } = require('@expo/config-plugins') const { mergeContents, + removeContents, } = require('@expo/config-plugins/build/utils/generateCode') +// MotionTag requires SDK initialisation "as early as possible" in +// application(_:didFinishLaunchingWithOptions:) — when iOS relaunches a +// killed app for a background location event, the SDK must re-arm tracking +// before any React Native / Expo startup work runs. +const BOOTSTRAP_CALL = ` // MotionTag SDK must initialise before React Native starts so background + // wake-ups (after the app is killed) can re-arm tracking. + MotionTagBootstrap.bootstrap(launchOptions: launchOptions)` + +// Background uploads run in a background URL session. When iOS wakes the +// (possibly killed) app to deliver its events, they must reach the SDK — +// sessions owned by Expo modules keep going through super. +const BACKGROUND_SESSION_OVERRIDE = ` public override func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + if MotionTagBootstrap.handlesBackgroundURLSession(identifier: identifier) { + MotionTagBootstrap.processBackgroundSessionEvents( + identifier: identifier, + completionHandler: completionHandler + ) + } else { + super.application( + application, + handleEventsForBackgroundURLSession: identifier, + completionHandler: completionHandler + ) + } + }` + +/** + * mergeContents, but tries a list of [anchor, offset] pairs in order so the + * plugin survives small template differences between Expo SDK versions. + * + * Always removes an existing block for the tag first: mergeContents alone is + * content-hash idempotent, so a block whose content is unchanged would stay + * at its old position even when this plugin's anchor moved. + */ +function mergeWithAnchors(src, { tag, newSrc, anchors }) { + src = removeContents({ src, tag }).contents + + let lastError + for (const [anchor, offset] of anchors) { + try { + return mergeContents({ tag, src, newSrc, anchor, offset, comment: '//' }) + .contents + } catch (error) { + if (error.code !== 'ERR_NO_MATCH') { + throw error + } + lastError = error + } + } + throw new Error( + `[react-native-motiontag] Could not find an insertion point for "${tag}" in AppDelegate.swift. ` + + 'Your AppDelegate seems to be heavily customised — add the MotionTag calls manually as shown ' + + `in the package README ("Setup with bare React Native"). Original error: ${lastError.message}`, + ) +} + module.exports = function withIosAppDelegate(config) { return withAppDelegate(config, (cfg) => { if (cfg.modResults.language !== 'swift') { @@ -22,16 +83,28 @@ module.exports = function withIosAppDelegate(config) { comment: '//', }).contents - contents = mergeContents({ + contents = mergeWithAnchors(contents, { tag: 'react-native-motiontag-bootstrap', - src: contents, - newSrc: - ' MotionTagBootstrap.bootstrap(launchOptions: launchOptions)', - anchor: - /return super\.application\(application, didFinishLaunchingWithOptions: launchOptions\)/, - offset: 0, - comment: '//', - }).contents + newSrc: BOOTSTRAP_CALL, + anchors: [ + // First statement of didFinishLaunchingWithOptions in the Expo + // SDK 52+ template — insert the bootstrap call right before it. + [/let delegate = ReactNativeDelegate\(\)/, 0], + // Fallback: the line opening the first `-> Bool {` body in the file, + // which in the Expo template is didFinishLaunchingWithOptions. + [/\)\s*->\s*Bool\s*\{/, 1], + ], + }) + + contents = mergeWithAnchors(contents, { + tag: 'react-native-motiontag-background-session', + newSrc: BACKGROUND_SESSION_OVERRIDE, + anchors: [ + // Right after the AppDelegate class declaration, before its first member. + [/class AppDelegate\s*:\s*ExpoAppDelegate\s*\{/, 1], + [/:\s*ExpoAppDelegate\s*\{/, 1], + ], + }) cfg.modResults.contents = contents return cfg