diff --git a/AGENTS.md b/AGENTS.md index 7b8c9d2..19b59c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ before making changes — most of it is not derivable from the code alone. ## What this repo is A React Native Turbo Module (`@panter/react-native-motiontag`) wrapping -the MotionTag tracking SDKs (iOS 6.5.x, Android 7.2.x). New architecture +the MotionTag tracking SDKs (iOS 7.0.x, Android 7.2.x). New architecture only. JS surface mirrors the official Flutter SDK so payloads stay interchangeable. diff --git a/README.md b/README.md index ca7eb7b..4a80a5e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Turbo Module wrapping the [MotionTag tracking SDK](https://motion-tag.com/) for React Native (new architecture only). The JS surface mirrors the [official Flutter SDK](https://github.com/MOTIONTAG/motiontag-sdk-flutter) -so payloads are interchangeable. Bridges the iOS SDK v6.5.x and the Android +so payloads are interchangeable. Bridges the iOS SDK v7.0.x and the Android SDK v7.2.x — the platform asymmetry is hidden behind a shared TS contract. ## Contents @@ -56,11 +56,10 @@ own `EventSubscription`. The `MotionTagEvent` discriminated union covers `batteryOptimizationsChanged` (Android), and a fall-through `log` channel that carries the diagnostic string format the underlying SDKs emit. -The platform-only methods (`isPowerSaveModeEnabled`, -`isBatteryOptimizationsEnabled` on Android; `getWifiOnlyDataTransfer` / -`setWifiOnlyDataTransfer` / `clearData` on Android) resolve to safe defaults -(`false`) or reject (`'UNSUPPORTED'`) on iOS, matching the Flutter SDK's -behaviour. +`getUserToken`, `getWifiOnlyDataTransfer`, `setWifiOnlyDataTransfer` and +`clearData` work on both platforms. The Android-only methods +(`isPowerSaveModeEnabled`, `isBatteryOptimizationsEnabled`) resolve to `false` +on iOS, matching the Flutter SDK's behaviour. ## Setup with Expo (recommended) @@ -130,19 +129,22 @@ func application( func application( _ application: UIApplication, - handleEventsForBackgroundURLSession identifier: String, - completionHandler: @escaping () -> Void -) { - // Forward every identifier unconditionally — the SDK decides internally - // which sessions are its own. If the app uses Firebase, also call its - // handleEvents(forBackgroundURLSession:) here (see the MotionTag iOS guide). - MotionTagBootstrap.processBackgroundSessionEvents( - identifier: identifier, - completionHandler: completionHandler - ) + handleEventsForBackgroundURLSession identifier: String +) async { + // Forward every identifier unconditionally — a foreign identifier is a + // cheap no-op. UIKit invokes the underlying completion handler once this + // method returns. + await MotionTagBootstrap.processBackgroundSessionEvents(identifier: identifier) } ``` +If the app owns **other** background URL sessions (Firebase, downloads), `await` +them from this same method. The completion handler must be invoked exactly once, +and since SDK v7 MotionTag no longer inspects it — so it can no longer tell you +whether a session was its own. A host that instead uses the completion-handler +overload, `processBackgroundSessionEvents(identifier:completionHandler:)`, is +responsible for chaining rather than calling the handler from each SDK. + The host's `Info.plist` must declare: - `NSLocationAlwaysAndWhenInUseUsageDescription`, @@ -214,7 +216,7 @@ practice this is rare and only affects authorization-status changes; the | Platform | Version | Source | | --- | --- | --- | -| iOS | `MotionTagSDK ~> 6.5.0` | CocoaPods trunk (transitive from this pod) | +| iOS | `MotionTagSDK ~> 7.0.0` | CocoaPods trunk (transitive from this pod) | | Android | `de.motiontag:tracker:7.2.5` | `pkgs.dev.azure.com/motiontag/releases` (Maven repo declared in this package) | The two SDKs are intentionally out of sync — aligning them is tracked as @@ -329,7 +331,7 @@ doesn't publish a GitHub release feed. Both `locale=en` and `locale=de` work. | Axis | Pinned in | Currently | | --- | --- | --- | -| iOS SDK | [`react-native-motiontag.podspec`](react-native-motiontag.podspec) | `MotionTagSDK ~> 6.5.0` | +| iOS SDK | [`react-native-motiontag.podspec`](react-native-motiontag.podspec) | `MotionTagSDK ~> 7.0.0` | | Android SDK | [`android/build.gradle`](android/build.gradle) | `de.motiontag:tracker:7.2.5` | | Example Expo SDK | [`example/package.json`](example/package.json) | `expo ~55` | | Library build tooling | [`package.json`](package.json) | `react-native-builder-bob`, `@expo/config-plugins`, `typescript` | diff --git a/docs/motiontag-integration.md b/docs/motiontag-integration.md index b4506c1..7757eb0 100644 --- a/docs/motiontag-integration.md +++ b/docs/motiontag-integration.md @@ -62,7 +62,7 @@ asymmetry between iOS and Android, and the known smells. │ ios/cyclomania-Bridging-Header │ │ MotionTag.Callback singleton │ │ exposes TrackingSDK.h to Swift │ │ │ │ │ │ MotionTagPackage.java │ -│ Pod: MotionTagSDK ~> 6.5.0 │ │ ReactPackage registering the module │ +│ Pod: MotionTagSDK ~> 7.0.0 │ │ ReactPackage registering the module │ │ │ │ Dep: de.motiontag:tracker:7.2.5 │ └──────────────────────────────────┘ └────────────────────────────────────────┘ ``` @@ -79,7 +79,7 @@ the main reason a refactor will need to be careful. | Platform | Dependency | Version | Source | | --- | --- | --- | --- | -| iOS | `MotionTagSDK` | `~> 6.5.0` | CocoaPods (`ios/Podfile:35`) | +| iOS | `MotionTagSDK` | `~> 7.0.0` | CocoaPods (`ios/Podfile:35`) | | Android | `de.motiontag:tracker` | `7.2.5` | Azure DevOps Maven repo (`android/build.gradle:27-29`) | The iOS framework is also referenced as a binary in `ios/Frameworks/` and @@ -649,7 +649,7 @@ before the first `Activity` (`MainApplication.kt:55`). | Concern | iOS | Android | | --- | --- | --- | -| SDK major version | 6 (`MotionTagSDK ~> 6.5.0`) | 7 (`de.motiontag:tracker:7.2.5`) | +| SDK major version | 7 (`MotionTagSDK ~> 7.0.0`) | 7 (`de.motiontag:tracker:7.2.5`) | | Integration style | `MotionTagDelegate` (callback methods on a delegate object) | `MotionTag.Callback` with sealed `Event` hierarchy in `onEvent(event)` | | Module init point | `AppDelegate.didFinishLaunchingWithOptions` | `MainApplication.onCreate` | | Foreground service | not applicable | required `Notification` passed to `MotionTag.initialize` | diff --git a/ios/MotionTagBootstrap.swift b/ios/MotionTagBootstrap.swift index befe498..3ec4f94 100644 --- a/ios/MotionTagBootstrap.swift +++ b/ios/MotionTagBootstrap.swift @@ -7,24 +7,42 @@ import UIKit /// Initialise the MotionTag SDK. Must be called from `application(_:didFinishLaunchingWithOptions:)` /// before React Native starts up — Turbo Modules are instantiated lazily on first JS access /// and cannot run pre-RN init themselves. + @MainActor @objc public static func bootstrap(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { MotionTagCore.sharedInstance.initialize( using: MotionTagDelegateImpl.shared, - launchOption: launchOptions + launchOptions: launchOptions ) } /// Forward background URL session events so the SDK can finish background uploads on - /// cold-launch wake-ups. Call from `application(_:handleEventsForBackgroundURLSession:completionHandler:)` - /// with every identifier, unconditionally — the SDK decides internally which sessions are its own - /// (matches the MotionTag iOS guide and the official Flutter SDK's AppDelegate). + /// cold-launch wake-ups. Suspends until the SDK's pending events have been delivered. + /// + /// Prefer this over the completion-handler form: a host that owns other background URL + /// sessions can `await` each SDK in turn from a single + /// `application(_:handleEventsForBackgroundURLSession:) async` override, and UIKit invokes + /// the underlying completion handler exactly once when that override returns. + @MainActor + public static func processBackgroundSessionEvents(identifier: String) async { + await MotionTagCore.sharedInstance.processBackgroundSessionEvents(with: identifier) + } + + /// Completion-handler form, for hosts that cannot use the `async` override — notably Expo: + /// `ExpoAppDelegate` declares `application(_:handleEventsForBackgroundURLSession:completionHandler:)` + /// in Swift, and Swift only synthesises the `async` spelling for Objective-C declarations. + /// + /// Unlike SDK v6, the SDK no longer receives the handler and can no longer decide whether an + /// identifier is one of its own, so this calls `completionHandler` for *every* identifier. + /// A host that owns other background URL sessions must therefore ensure the handler is + /// invoked exactly once — chain them, don't call it from each. + @MainActor @objc public static func processBackgroundSessionEvents( identifier: String, completionHandler: @escaping () -> Void ) { - MotionTagCore.sharedInstance.processBackgroundSessionEvents( - with: identifier, - completionHandler: completionHandler - ) + Task { @MainActor in + await processBackgroundSessionEvents(identifier: identifier) + completionHandler() + } } } diff --git a/ios/MotionTagDelegateImpl.swift b/ios/MotionTagDelegateImpl.swift index 3d88bc9..8b15c3c 100644 --- a/ios/MotionTagDelegateImpl.swift +++ b/ios/MotionTagDelegateImpl.swift @@ -4,11 +4,18 @@ import MotionTagSDK public typealias MotionTagEventCallback = ([String: Any]) -> Void -@objc public class MotionTagDelegateImpl: NSObject, MotionTagDelegate { +/// `MotionTagDelegate` is `@MainActor` and refines `Sendable`, so this type must be main-actor +/// isolated — a plain `NSObject` holding a mutable `eventCallback` is not `Sendable`. Every +/// `@objc` entry point below must therefore be called from the main queue. +@objc @MainActor public class MotionTagDelegateImpl: NSObject, MotionTagDelegate { @objc public static let shared = MotionTagDelegateImpl() - private let motionTag = MotionTagCore.sharedInstance + /// `var`, not `let`: `MotionTag` is not class-constrained, so the compiler treats a write + /// through the existential as a mutation of the binding. The conforming type is a reference + /// type (`start()`/`stop()` are non-mutating yet change SDK state), so writes still land on + /// the shared instance rather than on a copy. + private var motionTag = MotionTagCore.sharedInstance public var eventCallback: MotionTagEventCallback? private override init() {} @@ -17,6 +24,10 @@ public typealias MotionTagEventCallback = ([String: Any]) -> Void motionTag.userToken = jwt } + @objc public func getUserToken() -> String? { + return motionTag.userToken + } + @objc public func startTracking() { motionTag.start() } @@ -29,6 +40,20 @@ public typealias MotionTagEventCallback = ([String: Any]) -> Void return motionTag.isTrackingActive } + @objc public func getWifiOnlyDataTransfer() -> Bool { + return motionTag.wifiOnlyDataTransfer + } + + @objc public func setWifiOnlyDataTransfer(_ wifiOnly: Bool) { + motionTag.wifiOnlyDataTransfer = wifiOnly + } + + /// Returns the number of cleared records, which the JS contract discards. + @discardableResult + @objc public func clearData() -> Int { + return motionTag.clearData() + } + @objc public func setEventCallback(_ callback: @escaping MotionTagEventCallback) { eventCallback = callback } @@ -44,14 +69,14 @@ public typealias MotionTagEventCallback = ([String: Any]) -> Void // MARK: - MotionTagDelegate - public func trackingStatusChanged(_ isTracking: Bool) { + public func trackingDidChange(isTracking: Bool) { emit( ["type": isTracking ? "started" : "stopped"], log: "SDK TrackingStatusChanged: \(isTracking)" ) } - public func locationAuthorizationStatusDidChange(_ status: CLAuthorizationStatus, precise: Bool) { + public func locationAuthorizationDidChange(status: CLAuthorizationStatus, isPrecise: Bool) { let statusString: String switch status { case .authorizedAlways: statusString = "granted" @@ -62,16 +87,16 @@ public typealias MotionTagEventCallback = ([String: Any]) -> Void @unknown default: statusString = "denied" } emit( - ["type": "authorization", "status": statusString, "precise": precise], - log: "SDK CLAuthorizationStatus: \(status.rawValue) precise: \(precise)" + ["type": "authorization", "status": statusString, "precise": isPrecise], + log: "SDK CLAuthorizationStatus: \(status.rawValue) precise: \(isPrecise)" ) } - public func motionActivityAuthorized(_ authorized: Bool) { - emitLog("SDK MotionActivityAuthorized: \(authorized)") + public func motionActivityAuthorizationDidChange(isAuthorized: Bool) { + emitLog("SDK MotionActivityAuthorized: \(isAuthorized)") } - public func didTrackLocation(_ location: CLLocation) { + public func didUpdateLocation(_ location: CLLocation) { emit( [ "type": "location", @@ -87,7 +112,7 @@ public typealias MotionTagEventCallback = ([String: Any]) -> Void ) } - public func dataUploadWithTracked(from startDate: Date, to endDate: Date, didCompleteWithError error: Error?) { + public func dataUploadDidComplete(from startDate: Date, to endDate: Date, error: Error?) { let trackedFromMs = startDate.timeIntervalSince1970 * 1000 let trackedToMs = endDate.timeIntervalSince1970 * 1000 diff --git a/ios/MotionTagModule.mm b/ios/MotionTagModule.mm index dccdfc4..de9a166 100644 --- a/ios/MotionTagModule.mm +++ b/ios/MotionTagModule.mm @@ -1,10 +1,9 @@ #import "MotionTagModule.h" #import -// Import the SDK before the Swift bridge so the bridge's reference to -// MotionTagDelegate (used as a protocol conformance on -// MotionTagDelegateImpl) is satisfied. -#import +// MotionTagSDK ships no Objective-C headers since v7 — it is a pure Swift module. All SDK +// access goes through MotionTagDelegateImpl, whose generated header omits the (non-@objc) +// MotionTagDelegate conformance, so nothing here needs to see the SDK. // Swift generated header: CocoaPods only exposes it via the framework-style // include when the pod is built as a framework (use_frameworks!). In the // default static-library mode it stays in DerivedSources and is only @@ -33,16 +32,20 @@ @implementation MotionTagModule { return @[@"MotionTagEvent"]; } +// MotionTagDelegateImpl is @MainActor-isolated (the SDK's delegate protocol requires it), so +// every call into it below hops to the main queue first. - (void)startObserving { _hasListeners = YES; __weak __typeof(self) weakSelf = self; - [MotionTagDelegateImpl.shared setEventCallback:^(NSDictionary *event) { - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf && strongSelf->_hasListeners) { - [strongSelf sendEventWithName:@"MotionTagEvent" body:event]; - } - }]; + dispatch_async(dispatch_get_main_queue(), ^{ + [MotionTagDelegateImpl.shared setEventCallback:^(NSDictionary *event) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf && strongSelf->_hasListeners) { + [strongSelf sendEventWithName:@"MotionTagEvent" body:event]; + } + }]; + }); } - (void)stopObserving @@ -72,20 +75,25 @@ - (void)setUserToken:(NSString *)jwt resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - [MotionTagDelegateImpl.shared setUserToken:jwt]; - resolve(nil); + dispatch_async(dispatch_get_main_queue(), ^{ + [MotionTagDelegateImpl.shared setUserToken:jwt]; + resolve(nil); + }); } - (void)getUserToken:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - // MotionTag iOS SDK v6 does not expose a public getUserToken API. - // Resolve to nil so JS callers can detect "not implemented on this platform". - resolve([NSNull null]); + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *token = [MotionTagDelegateImpl.shared getUserToken]; + resolve(token ?: [NSNull null]); + }); } - (void)isTrackingActive:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - resolve(@([MotionTagDelegateImpl.shared isTrackingActive])); + dispatch_async(dispatch_get_main_queue(), ^{ + resolve(@([MotionTagDelegateImpl.shared isTrackingActive])); + }); } - (void)isPowerSaveModeEnabled:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject @@ -102,20 +110,29 @@ - (void)isBatteryOptimizationsEnabled:(RCTPromiseResolveBlock)resolve reject:(RC - (void)getWifiOnlyDataTransfer:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - // Not exposed by the v6 iOS SDK; return false until v7 migration. - resolve(@NO); + dispatch_async(dispatch_get_main_queue(), ^{ + resolve(@([MotionTagDelegateImpl.shared getWifiOnlyDataTransfer])); + }); } - (void)setWifiOnlyDataTransfer:(BOOL)wifiOnly resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - reject(@"UNSUPPORTED", @"setWifiOnlyDataTransfer is not supported on iOS (MotionTag SDK v6).", nil); + dispatch_async(dispatch_get_main_queue(), ^{ + [MotionTagDelegateImpl.shared setWifiOnlyDataTransfer:wifiOnly]; + resolve(nil); + }); } - (void)clearData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - reject(@"UNSUPPORTED", @"clearData is not supported on iOS (MotionTag SDK v6).", nil); + dispatch_async(dispatch_get_main_queue(), ^{ + // The SDK returns the number of cleared records; the JS contract is Promise, + // and Android resolves null. Discard it rather than diverge. + [MotionTagDelegateImpl.shared clearData]; + resolve(nil); + }); } - (std::shared_ptr)getTurboModule: diff --git a/plugin/withIosAppDelegate.js b/plugin/withIosAppDelegate.js index e61e881..be8a610 100644 --- a/plugin/withIosAppDelegate.js +++ b/plugin/withIosAppDelegate.js @@ -14,18 +14,26 @@ const BOOTSTRAP_CALL = ` // MotionTag SDK must initialise before React Native // Background uploads run in a background URL session. When iOS wakes the // (possibly killed) app to deliver its events, they must reach the SDK. -// Per the MotionTag iOS guide (and the official Flutter SDK's AppDelegate), -// every identifier is forwarded unconditionally — the SDK decides internally -// which sessions are its own and only calls the completion handler for those. +// Every identifier is forwarded unconditionally — since SDK v7 the SDK no +// longer inspects the handler, so it can no longer tell us whether a session +// is its own; forwarding a foreign identifier is a cheap no-op. +// +// The completion handler is passed to `super`, not called here: ExpoAppDelegate +// fans the event out to its subscribers and invokes the handler exactly once +// (immediately, when there are none). Calling it here as well would invoke a +// background completion handler twice and skip every Expo subscriber. const BACKGROUND_SESSION_OVERRIDE = ` public override func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { - MotionTagBootstrap.processBackgroundSessionEvents( - identifier: identifier, - completionHandler: completionHandler - ) + MotionTagBootstrap.processBackgroundSessionEvents(identifier: identifier) { + super.application( + application, + handleEventsForBackgroundURLSession: identifier, + completionHandler: completionHandler + ) + } }` /** diff --git a/react-native-motiontag.podspec b/react-native-motiontag.podspec index 4087525..c6c96a4 100644 --- a/react-native-motiontag.podspec +++ b/react-native-motiontag.podspec @@ -18,7 +18,7 @@ Pod::Spec.new do |s| s.requires_arc = true s.swift_versions = ["5.0"] - s.dependency "MotionTagSDK", "~> 6.5.0" + s.dependency "MotionTagSDK", "~> 7.0.0" install_modules_dependencies(s) end