From 8a1dbc3ae1ab807b4b73ed93039b6357197f83a8 Mon Sep 17 00:00:00 2001 From: Adrian Lea Date: Mon, 13 Jul 2026 15:50:56 +0100 Subject: [PATCH 1/3] feat: SDK 0.4.12/0.3.14, background-streaming bridges, one-shot connectDevice promise --- android/build.gradle | 2 +- .../com/terrartreact/TerraRtReactModule.java | 72 ++++++++++++++++++- ios/TerraRtReact.swift | 5 ++ package-lock.json | 8 +-- package.json | 2 +- react-native-terra-rt-react.podspec | 2 +- src/index.tsx | 49 +++++++++++++ 7 files changed, 131 insertions(+), 9 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index fb05585..86895d4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -71,7 +71,7 @@ dependencies { // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" - implementation 'co.tryterra:terra-rtandroid:0.4.11' + implementation 'co.tryterra:terra-rtandroid:0.4.12' } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/terrartreact/TerraRtReactModule.java b/android/src/main/java/com/terrartreact/TerraRtReactModule.java index 6ef4497..f2e93b8 100644 --- a/android/src/main/java/com/terrartreact/TerraRtReactModule.java +++ b/android/src/main/java/com/terrartreact/TerraRtReactModule.java @@ -13,6 +13,11 @@ import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.bridge.ReadableArray; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.PowerManager; +import android.provider.Settings; import co.tryterra.terrartandroid.enums.Connections; import co.tryterra.terrartandroid.enums.DataTypes; import com.facebook.react.modules.core.DeviceEventManagerModule; @@ -350,11 +355,74 @@ public void connectDevice(String deviceId, Promise promise){ return; } + // The SDK reuses this callback for later connection updates + // (e.g. disconnect) — a promise must resolve exactly once. + final java.util.concurrent.atomic.AtomicBoolean resolved = + new java.util.concurrent.atomic.AtomicBoolean(false); this.terraRt.connectDevice(devices.get(deviceId), (success) -> { - map.putBoolean("success", success); - promise.resolve(map); + if (resolved.compareAndSet(false, true)) { + map.putBoolean("success", success); + promise.resolve(map); + } return Unit.INSTANCE; }); } + + /** + * Customizes the persistent notification and (re)starts the streaming + * foreground service. As of terra-rtandroid 0.4.12 the service lifecycle + * is managed by the SDK (startRealtime starts it, stopRealtime/disconnect + * stop it) — calling this is only needed to override the notification. + */ + @ReactMethod + public void startForegroundService(String title, String text, Promise promise){ + WritableMap map = new WritableNativeMap(); + if (this.terraRt == null){ + map.putBoolean("success", false); + map.putString("error", "Please initialise a terra class by using `initTerra` first"); + promise.resolve(map); + return; + } + this.terraRt.startForegroundService(title, text, null); + map.putBoolean("success", true); + promise.resolve(map); + } + + /** Stops the streaming foreground service (managed automatically by the SDK). */ + @ReactMethod + public void stopForegroundService(Promise promise){ + WritableMap map = new WritableNativeMap(); + if (this.terraRt == null){ + map.putBoolean("success", false); + map.putString("error", "Please initialise a terra class by using `initTerra` first"); + promise.resolve(map); + return; + } + this.terraRt.stopForegroundService(); + map.putBoolean("success", true); + promise.resolve(map); + } + + /** Whether the app is exempt from battery optimizations (aggressive OEMs kill streams otherwise). */ + @ReactMethod + public void isIgnoringBatteryOptimizations(Promise promise){ + PowerManager pm = (PowerManager) this.reactContext.getSystemService(Context.POWER_SERVICE); + promise.resolve(pm != null && pm.isIgnoringBatteryOptimizations(this.reactContext.getPackageName())); + } + + /** Opens the system dialog requesting a battery-optimization exemption. */ + @ReactMethod + public void requestIgnoreBatteryOptimizations(Promise promise){ + try { + Intent intent = new Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + this.reactContext.getPackageName())); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + this.reactContext.startActivity(intent); + promise.resolve(true); + } catch (Exception e) { + promise.resolve(false); + } + } } diff --git a/ios/TerraRtReact.swift b/ios/TerraRtReact.swift index c179f53..107b757 100644 --- a/ios/TerraRtReact.swift +++ b/ios/TerraRtReact.swift @@ -165,7 +165,12 @@ class TerraRtReact: NSObject { } if let device_ = TerraRtReact.scannedDevices[device]{ + // The SDK reuses this callback for later connection updates + // (e.g. disconnect) — a promise must resolve exactly once. + var resolved = false terraRT.connectDevice(device_){success in + guard !resolved else { return } + resolved = true resolve(["success": success]) } } diff --git a/package-lock.json b/package-lock.json index eb7c7ed..b98b20f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "react-native-terra-rt-react", - "version": "0.2.3", + "name": "terra-rt", + "version": "0.2.7", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "react-native-terra-rt-react", - "version": "0.2.3", + "name": "terra-rt", + "version": "0.2.7", "license": "MIT", "dependencies": { "terra-react": "^1.8.6" diff --git a/package.json b/package.json index 7e07fa6..9750739 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "terra-rt", - "version": "0.2.6", + "version": "0.2.7", "description": "React Native SDK for Terra's realtime (websocket) streaming \u2014 live data from BLE devices and Apple Watch.", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/react-native-terra-rt-react.podspec b/react-native-terra-rt-react.podspec index 6b40fd1..1cfb39c 100644 --- a/react-native-terra-rt-react.podspec +++ b/react-native-terra-rt-react.podspec @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm,swift}" s.frameworks = ['HealthKit'] s.dependency "React-Core" - s.dependency "TerraRTiOS", "=0.3.13" + s.dependency "TerraRTiOS", "=0.3.14" # Don't install the dependencies when we run `pod install` in the old architecture. diff --git a/src/index.tsx b/src/index.tsx index 3fd5123..daa707d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -84,3 +84,52 @@ export function disconnect(connections: Connections): Promise { export function connectWithWatchOS(): Promise { return TerraRtReact.connectWithWatchOS(); } + +/** + * ANDROID ONLY. Customizes the persistent notification shown while + * streaming and (re)starts the streaming foreground service. As of + * terra-rtandroid 0.4.12 the service lifecycle is managed by the SDK + * (startRealtime starts it; stopRealtime/disconnect stop it) — call this + * only to override the default notification content. No-op on iOS. + */ +export function startForegroundService( + notificationTitle: string, + notificationText: string +): Promise { + if (Platform.OS !== 'android') { + return Promise.resolve({ success: true, error: null }); + } + return TerraRtReact.startForegroundService(notificationTitle, notificationText); +} + +/** ANDROID ONLY. Stops the streaming foreground service. No-op on iOS. */ +export function stopForegroundService(): Promise { + if (Platform.OS !== 'android') { + return Promise.resolve({ success: true, error: null }); + } + return TerraRtReact.stopForegroundService(); +} + +/** + * ANDROID ONLY. Whether the app is exempt from battery optimizations — + * aggressive OEM battery managers can kill background streaming without + * the exemption. Always true on iOS. + */ +export function isIgnoringBatteryOptimizations(): Promise { + if (Platform.OS !== 'android') { + return Promise.resolve(true); + } + return TerraRtReact.isIgnoringBatteryOptimizations(); +} + +/** + * ANDROID ONLY. Opens the system dialog asking the user to exempt the app + * from battery optimizations. Resolves false if the dialog can't open. + * No-op (true) on iOS. + */ +export function requestIgnoreBatteryOptimizations(): Promise { + if (Platform.OS !== 'android') { + return Promise.resolve(true); + } + return TerraRtReact.requestIgnoreBatteryOptimizations(); +} From 79e67e6c74d81d062b7599f1b57d359fcb9edf98 Mon Sep 17 00:00:00 2001 From: AdrianLea Date: Mon, 13 Jul 2026 18:10:27 +0100 Subject: [PATCH 2/3] fix: linting issues --- src/__tests__/index.test.tsx | 74 +++++++++++++++++++++++++++++++++++- src/index.tsx | 5 ++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index bf84291..b77f791 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1 +1,73 @@ -it.todo('write a test'); +import { NativeModules, Platform } from 'react-native'; + +// Must exist before src/index is imported, or the linking-error proxy trips. +NativeModules.TerraRtReact = { + connectDevice: jest.fn(async () => ({ success: true, error: null })), + startForegroundService: jest.fn(async () => ({ success: true, error: null })), + stopForegroundService: jest.fn(async () => ({ success: true, error: null })), + isIgnoringBatteryOptimizations: jest.fn(async () => false), + requestIgnoreBatteryOptimizations: jest.fn(async () => true), +}; + +// require, not import: imports hoist above the NativeModules assignment, +// which would capture the linking-error proxy instead of the mock. +const { + isIgnoringBatteryOptimizations, + requestIgnoreBatteryOptimizations, + startForegroundService, + stopForegroundService, +} = require('../index'); + +const native = NativeModules.TerraRtReact; + +describe('background-streaming API (android)', () => { + beforeEach(() => { + Platform.OS = 'android'; + jest.clearAllMocks(); + }); + + it('startForegroundService forwards the notification content', async () => { + const res = await startForegroundService('Terra', 'Streaming live data'); + expect(native.startForegroundService).toHaveBeenCalledWith( + 'Terra', + 'Streaming live data' + ); + expect(res).toEqual({ success: true, error: null }); + }); + + it('stopForegroundService calls through', async () => { + await stopForegroundService(); + expect(native.stopForegroundService).toHaveBeenCalled(); + }); + + it('battery-optimization helpers call through', async () => { + await expect(isIgnoringBatteryOptimizations()).resolves.toBe(false); + await expect(requestIgnoreBatteryOptimizations()).resolves.toBe(true); + expect(native.isIgnoringBatteryOptimizations).toHaveBeenCalled(); + expect(native.requestIgnoreBatteryOptimizations).toHaveBeenCalled(); + }); +}); + +describe('background-streaming API (ios no-ops)', () => { + beforeEach(() => { + Platform.OS = 'ios'; + jest.clearAllMocks(); + }); + + it('resolves successfully without touching the native module', async () => { + await expect(startForegroundService('t', 'x')).resolves.toEqual({ + success: true, + error: null, + }); + await expect(stopForegroundService()).resolves.toEqual({ + success: true, + error: null, + }); + await expect(isIgnoringBatteryOptimizations()).resolves.toBe(true); + await expect(requestIgnoreBatteryOptimizations()).resolves.toBe(true); + expect(native.startForegroundService).not.toHaveBeenCalled(); + expect(native.stopForegroundService).not.toHaveBeenCalled(); + expect(native.isIgnoringBatteryOptimizations).not.toHaveBeenCalled(); + expect(native.requestIgnoreBatteryOptimizations).not.toHaveBeenCalled(); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index daa707d..b8ed95d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -99,7 +99,10 @@ export function startForegroundService( if (Platform.OS !== 'android') { return Promise.resolve({ success: true, error: null }); } - return TerraRtReact.startForegroundService(notificationTitle, notificationText); + return TerraRtReact.startForegroundService( + notificationTitle, + notificationText + ); } /** ANDROID ONLY. Stops the streaming foreground service. No-op on iOS. */ From 1b2471ae7fcc7d1f7e84fc7d783983bbb0ec05db Mon Sep 17 00:00:00 2001 From: AdrianLea Date: Mon, 13 Jul 2026 18:16:09 +0100 Subject: [PATCH 3/3] fix(android): manifest permission for battery exemption, API-level guards on battery bridges --- android/src/main/AndroidManifest.xml | 2 ++ .../main/java/com/terrartreact/TerraRtReactModule.java | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c0cb69a..61a413c 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,5 @@ + + diff --git a/android/src/main/java/com/terrartreact/TerraRtReactModule.java b/android/src/main/java/com/terrartreact/TerraRtReactModule.java index f2e93b8..58331d5 100644 --- a/android/src/main/java/com/terrartreact/TerraRtReactModule.java +++ b/android/src/main/java/com/terrartreact/TerraRtReactModule.java @@ -406,6 +406,10 @@ public void stopForegroundService(Promise promise){ /** Whether the app is exempt from battery optimizations (aggressive OEMs kill streams otherwise). */ @ReactMethod public void isIgnoringBatteryOptimizations(Promise promise){ + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) { + promise.resolve(true); // no battery optimizations before API 23 + return; + } PowerManager pm = (PowerManager) this.reactContext.getSystemService(Context.POWER_SERVICE); promise.resolve(pm != null && pm.isIgnoringBatteryOptimizations(this.reactContext.getPackageName())); } @@ -413,6 +417,10 @@ public void isIgnoringBatteryOptimizations(Promise promise){ /** Opens the system dialog requesting a battery-optimization exemption. */ @ReactMethod public void requestIgnoreBatteryOptimizations(Promise promise){ + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) { + promise.resolve(true); // no battery optimizations before API 23 + return; + } try { Intent intent = new Intent( Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,