Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
38 changes: 20 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
6 changes: 3 additions & 3 deletions docs/motiontag-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
└──────────────────────────────────┘ └────────────────────────────────────────┘
```
Expand All @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
34 changes: 26 additions & 8 deletions ios/MotionTagBootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
45 changes: 35 additions & 10 deletions ios/MotionTagDelegateImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand All @@ -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()
}
Expand All @@ -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
}
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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

Expand Down
57 changes: 37 additions & 20 deletions ios/MotionTagModule.mm
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
#import "MotionTagModule.h"

#import <React/RCTLog.h>
// 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/MotionTagSDK.h>
// 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
Expand Down Expand Up @@ -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<NSString *, id> *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<NSString *, id> *event) {
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf && strongSelf->_hasListeners) {
[strongSelf sendEventWithName:@"MotionTagEvent" body:event];
}
}];
});
}

- (void)stopObserving
Expand Down Expand Up @@ -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
Expand All @@ -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<void>,
// and Android resolves null. Discard it rather than diverge.
[MotionTagDelegateImpl.shared clearData];
resolve(nil);
});
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
Expand Down
Loading
Loading