Skip to content

Commit

Permalink
feat: android 12+ pip uses setAutoEnterEnabled api (#1643)
Browse files Browse the repository at this point in the history
## Main Goal

Android 12+ now uses setAutoEnterEnabled api instead of being dependent
on onUserHint, which is buggy on these platforms when any external
dialog like permission dialog comes.

This adds the seamless animation transition support for Android
whereever it is available.

## Side optimisiations

* Added a a native workaround for PiP views from react native not being
updated on Android 14 and above 🎉
* In JS part, moved PiP to one global state in StreamCall to avoid
multiple state updates


## Android 15


https://github.com/user-attachments/assets/1d9d9021-d294-4650-b6c7-b39a0dc56cdf
  • Loading branch information
santhoshvai authored Jan 16, 2025
1 parent 744b8d3 commit b07a9a6
Show file tree
Hide file tree
Showing 18 changed files with 458 additions and 377 deletions.
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
package com.streamvideo.reactnative
import kotlin.properties.Delegates

import android.content.res.Configuration
import java.util.concurrent.CopyOnWriteArrayList // For thread safety

object StreamVideoReactNative {

var pipListeners = ArrayList<(b: Boolean) -> Unit>()
// Use CopyOnWriteArrayList for thread safety
private val pipListeners = CopyOnWriteArrayList<(isInPip: Boolean, newConfig: Configuration) -> Unit>()

@JvmField
var canAutoEnterPictureInPictureMode = false

// fires off every time value of the property changes
private var isInPictureInPictureMode: Boolean by Delegates.observable(false) { _, _, newValue ->
pipListeners.forEach {listener ->
listener(newValue)
}
}
private var isInPictureInPictureMode: Boolean = false

@JvmStatic @Deprecated("No need to use setup() anymore")
@Deprecated("No need to use setup() anymore")
@JvmStatic
fun setup() {
// do nothing
// Do nothing
}

@JvmStatic
fun addPipListener(listener: (isInPip: Boolean, newConfig: Configuration) -> Unit) {
pipListeners.add(listener)
}

@JvmStatic
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
fun clearPipListeners() {
pipListeners.clear()
}

@JvmStatic
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
this.isInPictureInPictureMode = isInPictureInPictureMode
// Iterate safely over the list
pipListeners.forEach { listener ->
listener(isInPictureInPictureMode, newConfig)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ package com.streamvideo.reactnative

import android.app.AppOpsManager
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.os.Process
import android.util.Rational
import androidx.annotation.RequiresApi
import com.facebook.react.ReactActivity
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
Expand All @@ -31,10 +33,34 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : Reac

override fun initialize() {
super.initialize()
StreamVideoReactNative.pipListeners.add { isInPictureInPictureMode ->
StreamVideoReactNative.addPipListener { isInPictureInPictureMode, newConfig ->
// Send event to JavaScript
reactApplicationContext.getJSModule(
RCTDeviceEventEmitter::class.java
).emit(PIP_CHANGE_EVENT, isInPictureInPictureMode)
// inform the activity
if (isInPictureInPictureMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(reactApplicationContext.currentActivity as? ReactActivity)?.let { activity ->
val params = getPiPParams()
val aspect =
if (newConfig.orientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
Rational(9, 16)
} else {
Rational(16, 9)
}
params.setAspectRatio(aspect)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
// this platform doesn't support autoEnterEnabled
// so we manually enter here
activity.enterPictureInPictureMode(params.build())
} else {
activity.setPictureInPictureParams(params.build())
}
// NOTE: workaround - on PiP mode, android goes to "paused but can render" state
// RN pauses rendering in paused mode, so we instruct it to resume here
activity.reactDelegate?.onHostResume()
}
}
}

val filter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
Expand All @@ -59,35 +85,38 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : Reac
promise.resolve(inPictureInPictureMode)
}

@Suppress("UNUSED_PARAMETER")
@ReactMethod
fun addListener(eventName: String?) {
}

@Suppress("UNUSED_PARAMETER")
@ReactMethod
fun removeListeners(count: Int) {
}

@RequiresApi(Build.VERSION_CODES.O)
@ReactMethod
fun enterPipMode(width: Int, height: Int) {
if (hasPermission()) {
val width1 = if (width > 0) width else 480
val height1 = if (height > 0) height else 640
val ratio = Rational(width1, height1)
val pipBuilder = PictureInPictureParams.Builder()
pipBuilder.setAspectRatio(ratio)
reactApplicationContext!!.currentActivity!!.enterPictureInPictureMode(pipBuilder.build())
}
}

override fun invalidate() {
StreamVideoReactNative.pipListeners.clear();
StreamVideoReactNative.clearPipListeners()
reactApplicationContext.unregisterReceiver(powerReceiver)
stopThermalStatusUpdates()
super.invalidate()
}

@ReactMethod
fun canAutoEnterPipMode(value: Boolean) {
StreamVideoReactNative.canAutoEnterPictureInPictureMode = value
if (!hasPermission() || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val activity = reactApplicationContext!!.currentActivity!!
if (value) {
activity.setPictureInPictureParams(getPiPParams().build())
// NOTE: for SDK_INT < Build.VERSION_CODES.S
// onUserLeaveHint from Activity is used, SDK cant directly use it
// onUserLeaveHint will call the PiP listener and we call enterPictureInPictureMode there
} else {
val params = PictureInPictureParams.Builder()
params.setAutoEnterEnabled(false)
activity.setPictureInPictureParams(params.build())
}
}

@ReactMethod
Expand Down Expand Up @@ -171,12 +200,6 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : Reac
}
}

override fun onCatalystInstanceDestroy() {
super.onCatalystInstanceDestroy()
reactApplicationContext.unregisterReceiver(powerReceiver)
stopThermalStatusUpdates()
}

private fun sendPowerModeEvent() {
val powerManager = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val isLowPowerMode = powerManager.isPowerSaveMode
Expand Down Expand Up @@ -210,6 +233,31 @@ class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : Reac
}
}

@RequiresApi(Build.VERSION_CODES.O)
private fun getPiPParams(): PictureInPictureParams.Builder {
val activity = reactApplicationContext!!.currentActivity!!
val currentOrientation = activity.resources.configuration.orientation

val aspect =
if (currentOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
Rational(9, 16)
} else {
Rational(16, 9)
}

val params = PictureInPictureParams.Builder()
params.setAspectRatio(aspect).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setTitle("Video Player")
setSeamlessResizeEnabled(false)
}
}
return params
}

companion object {
private const val NAME = "StreamVideoReactNative"
private const val PIP_CHANGE_EVENT = NAME + "_PIP_CHANGE_EVENT"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ const props: ConfigProps = {
ringingPushNotifications: {
disableVideoIos: false,
},
androidPictureInPicture: {
enableAutomaticEnter: true,
},
androidPictureInPicture: true,
androidKeepCallAlive: true,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ describe('withStreamVideoReactNativeSDKAppDelegate', () => {
};

const props: ConfigProps = {
androidPictureInPicture: {
enableAutomaticEnter: true,
},
androidPictureInPicture: true,
enableScreenshare: true,
};

Expand All @@ -54,17 +52,15 @@ describe('withStreamVideoReactNativeSDKAppDelegate', () => {
);

expect(updatedConfig.modResults.contents).toMatch(
/StreamVideoReactNative.canAutoEnterPictureInPictureMode/
/StreamVideoReactNative.Companion.getCanAutoEnterPictureInPictureMode/
);

expect(updatedConfig.modResults.contents).toMatch(
/options.enableMediaProjectionService = true/
);

const props2: ConfigProps = {
androidPictureInPicture: {
enableAutomaticEnter: false,
},
androidPictureInPicture: false,
enableScreenshare: false,
};

Expand All @@ -83,7 +79,7 @@ describe('withStreamVideoReactNativeSDKAppDelegate', () => {
) as CustomExpoConfig;

expect(updatedConfig2.modResults.contents).not.toMatch(
/StreamVideoReactNative.canAutoEnterPictureInPictureMode/
/StreamVideoReactNative.Companion.getCanAutoEnterPictureInPictureMode/
);

expect(updatedConfig2.modResults.contents).not.toMatch(
Expand All @@ -103,9 +99,7 @@ describe('withStreamVideoReactNativeSDKAppDelegate', () => {
},
};
const props: ConfigProps = {
androidPictureInPicture: {
enableAutomaticEnter: true,
},
androidPictureInPicture: true,
};
expect(() => withMainActivity(config, props)).toThrow();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@ export type RingingPushNotifications = {
showWhenLockedAndroid?: boolean;
};

export type AndroidPictureInPicture = {
enableAutomaticEnter: boolean;
};

export type ConfigProps =
| {
ringingPushNotifications?: RingingPushNotifications;
enableNonRingingPushNotifications?: boolean;
androidPictureInPicture?: AndroidPictureInPicture;
androidPictureInPicture?: boolean;
androidKeepCallAlive?: boolean;
enableScreenshare?: boolean;
appleTeamId?: string;
Expand Down
Loading

0 comments on commit b07a9a6

Please sign in to comment.