Skip to content

Commit ef49cee

Browse files
authored
feat: report low power mode and thermal info to stats (#1583)
## Overview Adds react native implementation for reporting device `thermal state` and `low power mode enabled` states to the call stats.
1 parent 0629e49 commit ef49cee

File tree

7 files changed

+373
-64
lines changed

7 files changed

+373
-64
lines changed

packages/client/src/client-details.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
2+
AndroidThermalState,
3+
AppleThermalState,
24
ClientDetails,
35
Device,
46
OS,
57
Sdk,
68
SdkType,
79
} from './gen/video/sfu/models/models';
10+
import { SendStatsRequest } from './gen/video/sfu/signal_rpc/signal';
811
import { isReactNative } from './helpers/platforms';
912
import { UAParser } from 'ua-parser-js';
1013

@@ -25,6 +28,7 @@ let sdkInfo: Sdk | undefined = {
2528
let osInfo: OS | undefined;
2629
let deviceInfo: Device | undefined;
2730
let webRtcInfo: WebRTCInfoType | undefined;
31+
let deviceState: SendStatsRequest['deviceState'];
2832

2933
export const setSdkInfo = (info: Sdk) => {
3034
sdkInfo = info;
@@ -62,6 +66,82 @@ export type LocalClientDetailsType = ClientDetails & {
6266
webRTCInfo?: WebRTCInfoType;
6367
};
6468

69+
export const setThermalState = (state: string) => {
70+
if (!osInfo) {
71+
deviceState = { oneofKind: undefined };
72+
return;
73+
}
74+
75+
if (osInfo.name === 'android') {
76+
const thermalState =
77+
AndroidThermalState[state as keyof typeof AndroidThermalState] ||
78+
AndroidThermalState.UNSPECIFIED;
79+
80+
deviceState = {
81+
oneofKind: 'android',
82+
android: {
83+
thermalState,
84+
isPowerSaverMode:
85+
deviceState?.oneofKind === 'android' &&
86+
deviceState.android.isPowerSaverMode,
87+
},
88+
};
89+
}
90+
91+
if (osInfo.name.toLowerCase() === 'ios') {
92+
const thermalState =
93+
AppleThermalState[state as keyof typeof AppleThermalState] ||
94+
AppleThermalState.UNSPECIFIED;
95+
96+
deviceState = {
97+
oneofKind: 'apple',
98+
apple: {
99+
thermalState,
100+
isLowPowerModeEnabled:
101+
deviceState?.oneofKind === 'apple' &&
102+
deviceState.apple.isLowPowerModeEnabled,
103+
},
104+
};
105+
}
106+
};
107+
108+
export const setPowerState = (powerMode: boolean) => {
109+
if (!osInfo) {
110+
deviceState = { oneofKind: undefined };
111+
return;
112+
}
113+
114+
if (osInfo.name === 'android') {
115+
deviceState = {
116+
oneofKind: 'android',
117+
android: {
118+
thermalState:
119+
deviceState?.oneofKind === 'android'
120+
? deviceState.android.thermalState
121+
: AndroidThermalState.UNSPECIFIED,
122+
isPowerSaverMode: powerMode,
123+
},
124+
};
125+
}
126+
127+
if (osInfo.name.toLowerCase() === 'ios') {
128+
deviceState = {
129+
oneofKind: 'apple',
130+
apple: {
131+
thermalState:
132+
deviceState?.oneofKind === 'apple'
133+
? deviceState.apple.thermalState
134+
: AppleThermalState.UNSPECIFIED,
135+
isLowPowerModeEnabled: powerMode,
136+
},
137+
};
138+
}
139+
};
140+
141+
export const getDeviceState = () => {
142+
return deviceState;
143+
};
144+
65145
export const getClientDetails = (): LocalClientDetailsType => {
66146
if (isReactNative()) {
67147
// Since RN doesn't support web, sharing browser info is not required

packages/client/src/stats/SfuStatsReporter.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { OwnCapability, StatsOptions } from '../gen/coordinator';
44
import { getLogger } from '../logger';
55
import { Publisher, Subscriber } from '../rtc';
66
import { flatten, getSdkName, getSdkVersion } from './utils';
7-
import { getWebRTCInfo, LocalClientDetailsType } from '../client-details';
7+
import {
8+
getDeviceState,
9+
getWebRTCInfo,
10+
LocalClientDetailsType,
11+
} from '../client-details';
812
import { InputDevices } from '../gen/video/sfu/models/models';
913
import { CameraManager, MicrophoneManager } from '../devices';
1014
import { createSubscription } from '../store/rxUtils';
@@ -132,7 +136,7 @@ export class SfuStatsReporter {
132136
publisherStats,
133137
audioDevices: this.inputDevices.get('mic'),
134138
videoDevices: this.inputDevices.get('camera'),
135-
deviceState: { oneofKind: undefined },
139+
deviceState: getDeviceState(),
136140
telemetry: telemetryData,
137141
});
138142
};

packages/react-native-sdk/android/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
package="com.streamvideo.reactnative">
33

44
<uses-permission android:name="android.permission.INTERNET" />
5+
<uses-permission android:name="android.permission.DEVICE_POWER" />
56
</manifest>

packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import android.app.AppOpsManager
44
import android.app.PictureInPictureParams
55
import android.content.Context
66
import android.content.pm.PackageManager
7+
import android.content.BroadcastReceiver
8+
import android.content.Intent
9+
import android.content.IntentFilter
710
import android.net.Uri
811
import android.os.Build
12+
import android.os.PowerManager
913
import android.os.Process
1014
import android.util.Rational
1115
import androidx.annotation.RequiresApi
@@ -23,13 +27,18 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : Reac
2327
return NAME;
2428
}
2529

30+
private var thermalStatusListener: PowerManager.OnThermalStatusChangedListener? = null
31+
2632
override fun initialize() {
2733
super.initialize()
2834
StreamVideoReactNative.pipListeners.add { isInPictureInPictureMode ->
2935
reactApplicationContext.getJSModule(
3036
RCTDeviceEventEmitter::class.java
3137
).emit(PIP_CHANGE_EVENT, isInPictureInPictureMode)
3238
}
39+
40+
val filter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
41+
reactApplicationContext.registerReceiver(powerReceiver, filter)
3342
}
3443

3544
@ReactMethod
@@ -81,6 +90,111 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : Reac
8190
StreamVideoReactNative.canAutoEnterPictureInPictureMode = value
8291
}
8392

93+
@ReactMethod
94+
fun startThermalStatusUpdates(promise: Promise) {
95+
try {
96+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
97+
val powerManager = reactApplicationContext.getSystemService(ReactApplicationContext.POWER_SERVICE) as PowerManager
98+
99+
val listener = PowerManager.OnThermalStatusChangedListener { status ->
100+
val thermalStatus = when (status) {
101+
PowerManager.THERMAL_STATUS_NONE -> "NONE"
102+
PowerManager.THERMAL_STATUS_LIGHT -> "LIGHT"
103+
PowerManager.THERMAL_STATUS_MODERATE -> "MODERATE"
104+
PowerManager.THERMAL_STATUS_SEVERE -> "SEVERE"
105+
PowerManager.THERMAL_STATUS_CRITICAL -> "CRITICAL"
106+
PowerManager.THERMAL_STATUS_EMERGENCY -> "EMERGENCY"
107+
PowerManager.THERMAL_STATUS_SHUTDOWN -> "SHUTDOWN"
108+
else -> "UNKNOWN"
109+
}
110+
111+
reactApplicationContext
112+
.getJSModule(RCTDeviceEventEmitter::class.java)
113+
.emit("thermalStateDidChange", thermalStatus)
114+
}
115+
116+
thermalStatusListener = listener
117+
powerManager.addThermalStatusListener(listener)
118+
// Get initial status
119+
currentThermalState(promise)
120+
} else {
121+
promise.resolve("NOT_SUPPORTED")
122+
}
123+
} catch (e: Exception) {
124+
promise.reject("THERMAL_ERROR", e.message)
125+
}
126+
}
127+
128+
@ReactMethod
129+
fun stopThermalStatusUpdates() {
130+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
131+
val powerManager = reactApplicationContext.getSystemService(ReactApplicationContext.POWER_SERVICE) as PowerManager
132+
// Store the current listener in a local val for safe null checking
133+
val currentListener = thermalStatusListener
134+
if (currentListener != null) {
135+
powerManager.removeThermalStatusListener(currentListener)
136+
thermalStatusListener = null
137+
}
138+
}
139+
}
140+
141+
@ReactMethod
142+
fun currentThermalState(promise: Promise) {
143+
try {
144+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
145+
val powerManager = reactApplicationContext.getSystemService(ReactApplicationContext.POWER_SERVICE) as PowerManager
146+
val status = powerManager.currentThermalStatus
147+
val thermalStatus = when (status) {
148+
PowerManager.THERMAL_STATUS_NONE -> "NONE"
149+
PowerManager.THERMAL_STATUS_LIGHT -> "LIGHT"
150+
PowerManager.THERMAL_STATUS_MODERATE -> "MODERATE"
151+
PowerManager.THERMAL_STATUS_SEVERE -> "SEVERE"
152+
PowerManager.THERMAL_STATUS_CRITICAL -> "CRITICAL"
153+
PowerManager.THERMAL_STATUS_EMERGENCY -> "EMERGENCY"
154+
PowerManager.THERMAL_STATUS_SHUTDOWN -> "SHUTDOWN"
155+
else -> "UNKNOWN"
156+
}
157+
promise.resolve(thermalStatus)
158+
} else {
159+
promise.resolve("NOT_SUPPORTED")
160+
}
161+
} catch (e: Exception) {
162+
promise.reject("THERMAL_ERROR", e.message)
163+
}
164+
}
165+
166+
private val powerReceiver = object : BroadcastReceiver() {
167+
override fun onReceive(context: Context?, intent: Intent?) {
168+
if (intent?.action == PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) {
169+
sendPowerModeEvent()
170+
}
171+
}
172+
}
173+
174+
override fun onCatalystInstanceDestroy() {
175+
super.onCatalystInstanceDestroy()
176+
reactApplicationContext.unregisterReceiver(powerReceiver)
177+
stopThermalStatusUpdates()
178+
}
179+
180+
private fun sendPowerModeEvent() {
181+
val powerManager = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
182+
val isLowPowerMode = powerManager.isPowerSaveMode
183+
reactApplicationContext
184+
.getJSModule(RCTDeviceEventEmitter::class.java)
185+
.emit("isLowPowerModeEnabled", isLowPowerMode)
186+
}
187+
188+
@ReactMethod
189+
fun isLowPowerModeEnabled(promise: Promise) {
190+
try {
191+
val powerManager = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
192+
promise.resolve(powerManager.isPowerSaveMode)
193+
} catch (e: Exception) {
194+
promise.reject("ERROR", e.message)
195+
}
196+
}
197+
84198
private fun hasPermission(): Boolean {
85199
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && reactApplicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
86200
val appOps =

packages/react-native-sdk/ios/StreamVideoReactNative.m

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ void broadcastNotificationCallback(CFNotificationCenterRef center,
1717
StreamVideoReactNative *this = (__bridge StreamVideoReactNative*)observer;
1818
NSString *eventName = (__bridge NSString*)name;
1919
[this screenShareEventReceived: eventName];
20-
20+
2121
}
2222

2323
@implementation StreamVideoReactNative
@@ -44,10 +44,21 @@ -(instancetype)init {
4444
_notificationCenter = CFNotificationCenterGetDarwinNotifyCenter();
4545
[self setupObserver];
4646
}
47-
47+
if (self) {
48+
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
49+
}
50+
4851
return self;
4952
}
5053

54+
RCT_EXPORT_METHOD(isLowPowerModeEnabled:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
55+
resolve(@([NSProcessInfo processInfo].lowPowerModeEnabled));
56+
}
57+
58+
RCT_EXPORT_METHOD(currentThermalState:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
59+
resolve(@([NSProcessInfo processInfo].thermalState));
60+
}
61+
5162
-(void)dealloc {
5263
[self clearObserver];
5364
}
@@ -81,10 +92,40 @@ -(void)clearObserver {
8192

8293
-(void)startObserving {
8394
hasListeners = YES;
95+
[[NSNotificationCenter defaultCenter] addObserver:self
96+
selector:@selector(powerModeDidChange)
97+
name:NSProcessInfoPowerStateDidChangeNotification
98+
object:nil];
99+
[[NSNotificationCenter defaultCenter] addObserver:self
100+
selector:@selector(thermalStateDidChange)
101+
name:NSProcessInfoThermalStateDidChangeNotification
102+
object:nil];
84103
}
85104

86105
-(void)stopObserving {
87106
hasListeners = NO;
107+
[[NSNotificationCenter defaultCenter] removeObserver:self
108+
name:NSProcessInfoPowerStateDidChangeNotification
109+
object:nil];
110+
[[NSNotificationCenter defaultCenter] removeObserver:self
111+
name:NSProcessInfoThermalStateDidChangeNotification
112+
object:nil];
113+
}
114+
115+
- (void)powerModeDidChange {
116+
if (!hasListeners) {
117+
return;
118+
}
119+
BOOL lowPowerEnabled = [NSProcessInfo processInfo].lowPowerModeEnabled;
120+
[self sendEventWithName:@"isLowPowerModeEnabled" body:@(lowPowerEnabled)];
121+
}
122+
123+
- (void)thermalStateDidChange {
124+
if (!hasListeners) {
125+
return;
126+
}
127+
NSInteger thermalState = [NSProcessInfo processInfo].thermalState;
128+
[self sendEventWithName:@"thermalStateDidChange" body:@(thermalState)];
88129
}
89130

90131
-(void)screenShareEventReceived:(NSString*)event {
@@ -113,11 +154,11 @@ +(void)registerIncomingCall:(NSString *)cid uuid:(NSString *)uuid {
113154
} else {
114155
reject(@"access_failure", @"requested incoming call found", nil);
115156
}
116-
157+
117158
}
118159

119160
-(NSArray<NSString *> *)supportedEvents {
120-
return @[@"StreamVideoReactNative_Ios_Screenshare_Event"];
161+
return @[@"StreamVideoReactNative_Ios_Screenshare_Event", @"isLowPowerModeEnabled", @"thermalStateDidChange"];
121162
}
122163

123164
@end

0 commit comments

Comments
 (0)