fix(Android|PiP): do not invoke 'enterPictureInPicture' in PAUSED state

Activity.enterPictureInPictureMode method must be invoked synchronously
on userLeaveHint callback in order to be sure that the current Activity
is still visible (does not transit to PAUSED state). Previously if the
asynchronous processing would be delayed enough for the Activity to go
into the PAUSED state it will be too late to go into the PiP mode.
pull/2905/head
paweldomas 7 years ago committed by Saúl Ibarra Corretgé
parent effd3728b6
commit 565fd37f28
  1. 42
      android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java
  2. 4
      android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java
  3. 62
      android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java
  4. 79
      android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java
  5. 22
      android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java
  6. 13
      react/features/mobile/picture-in-picture/actionTypes.js
  7. 24
      react/features/mobile/picture-in-picture/actions.js
  8. 3
      react/features/mobile/picture-in-picture/index.js
  9. 70
      react/features/mobile/picture-in-picture/middleware.js
  10. 17
      react/features/mobile/picture-in-picture/reducer.js

@ -111,6 +111,34 @@ class ExternalAPIModule extends ReactContextBaseJavaModule {
return "ExternalAPI";
}
/**
* The internal processing for the conference URL set on
* a {@link JitsiMeetView} instance.
*
* @param eventName the name of the external API event to be processed.
* @param view the {@link JitsiMeetView} instance.
* @param url the "url" attribute value retrieved from the "data" carried by
* the event.
*/
private void maybeSetConferenceUrlOnTheView(
String eventName, JitsiMeetView view, String url)
{
switch(eventName) {
case "CONFERENCE_WILL_JOIN":
view.setCurrentConferenceUrl(url);
break;
case "CONFERENCE_FAILED":
case "CONFERENCE_WILL_LEAVE":
case "LOAD_CONFIG_ERROR":
// Abandon the conference only if it's for the current URL
if (url != null && url.equals(view.getCurrentConferenceUrl())) {
view.setCurrentConferenceUrl(null);
}
break;
}
}
/**
* Dispatches an event that occurred on JavaScript to the view's listener.
*
@ -130,6 +158,8 @@ class ExternalAPIModule extends ReactContextBaseJavaModule {
return;
}
maybeSetConferenceUrlOnTheView(name, view, data.getString("url"));
JitsiMeetViewListener listener = view.getListener();
if (listener == null) {
@ -141,7 +171,17 @@ class ExternalAPIModule extends ReactContextBaseJavaModule {
if (method != null) {
try {
method.invoke(listener, toHashMap(data));
} catch (IllegalAccessException | InvocationTargetException e) {
} catch (IllegalAccessException e) {
// FIXME There was a multicatch for IllegalAccessException and
// InvocationTargetException, but Android Studio complained
// with:
// "Multi-catch with these reflection exceptions requires
// API level 19 (current min is 16) because they get compiled to
// the common but new super type ReflectiveOperationException.
// As a workaround either create individual catch statements, or
// catch Exception."
throw new RuntimeException(e);
} catch (InvocationTargetException e) {
throw new RuntimeException(e);
}
}

@ -260,7 +260,9 @@ public class JitsiMeetActivity extends AppCompatActivity {
@Override
protected void onUserLeaveHint() {
JitsiMeetView.onUserLeaveHint();
if (view != null) {
view.onUserLeaveHint();
}
}
/**

@ -167,7 +167,7 @@ public class JitsiMeetView extends FrameLayout {
DefaultHardwareBackBtnHandler defaultBackButtonImpl) {
ReactInstanceManager reactInstanceManager
= ReactInstanceManagerHolder.getReactInstanceManager();
if (reactInstanceManager != null) {
reactInstanceManager.onHostResume(activity, defaultBackButtonImpl);
}
@ -205,15 +205,13 @@ public class JitsiMeetView extends FrameLayout {
}
/**
* Activity lifecycle method which should be called from
* {@code Activity.onUserLeaveHint} so we can do the required internal
* processing.
* Stores the current conference URL. Will have a value when the app is in
* a conference.
*
* This is currently not mandatory.
* Currently one thread writes and one thread reads, so it should be fine to
* have this field volatile without additional synchronization.
*/
public static void onUserLeaveHint() {
ReactInstanceManagerHolder.emitEvent("onUserLeaveHint", null);
}
private volatile String conferenceUrl;
/**
* The default base {@code URL} used to join a conference when a partial URL
@ -293,6 +291,16 @@ public class JitsiMeetView extends FrameLayout {
}
}
/**
* Retrieves the current conferences URL.
*
* @return a string with conference URL if the view is currently in
* a conference or {@code null} otherwise.
*/
public String getCurrentConferenceUrl() {
return conferenceUrl;
}
/**
* Gets the default base {@code URL} used to join a conference when a
* partial URL (e.g. a room name only) is specified to
@ -458,6 +466,33 @@ public class JitsiMeetView extends FrameLayout {
loadURLObject(urlObject);
}
/**
* Activity lifecycle method which should be called from
* {@code Activity.onUserLeaveHint} so we can do the required internal
* processing.
*
* This is currently not mandatory, but if used will provide automatic
* handling of the picture in picture mode when user minimizes the app. It
* will be probably the most useful in case the app is using the welcome
* page.
*/
public void onUserLeaveHint() {
if (getPictureInPictureEnabled() && conferenceUrl != null) {
PictureInPictureModule pipModule
= ReactInstanceManagerHolder.getNativeModule(
PictureInPictureModule.class);
if (pipModule != null) {
try {
pipModule.enterPictureInPicture();
} catch (RuntimeException exc) {
Log.e(
TAG, "onUserLeaveHint: failed to enter PiP mode", exc);
}
}
}
}
/**
* Called when the window containing this view gains or loses focus.
*
@ -495,6 +530,17 @@ public class JitsiMeetView extends FrameLayout {
}
}
/**
* Sets the current conference URL.
*
* @param conferenceUrl a string with new conference URL to set if the view
* is entering the conference or {@code null} if the view is no longer in
* the conference.
*/
void setCurrentConferenceUrl(String conferenceUrl) {
this.conferenceUrl = conferenceUrl;
}
/**
* Sets the default base {@code URL} used to join a conference when a
* partial URL (e.g. a room name only) is specified to

@ -26,50 +26,57 @@ public class PictureInPictureModule extends ReactContextBaseJavaModule {
* Enters Picture-in-Picture (mode) for the current {@link Activity}.
* Supported on Android API >= 26 (Oreo) only.
*
* @param promise a {@code Promise} which will resolve with a {@code null}
* value upon success, and an {@link Exception} otherwise.
* @throws IllegalStateException if {@link #isPictureInPictureSupported()}
* returns {@code false} or if {@link #getCurrentActivity()} returns
* {@code null}.
* @throws RuntimeException if
* {@link Activity#enterPictureInPictureMode(PictureInPictureParams)} fails.
* That method can also throw a {@link RuntimeException} in various cases,
* including when the activity is not visible (paused or stopped), if the
* screen is locked or if the user has an activity pinned.
*/
@ReactMethod
public void enterPictureInPicture(Promise promise) {
if (isPictureInPictureSupported()) {
Activity currentActivity = getCurrentActivity();
if (currentActivity == null) {
promise.reject(new Exception("No current Activity!"));
return;
}
public void enterPictureInPicture() {
if (!isPictureInPictureSupported()) {
throw new IllegalStateException("Picture-in-Picture not supported");
}
Log.d(TAG, "Entering Picture-in-Picture");
Activity currentActivity = getCurrentActivity();
PictureInPictureParams.Builder builder
= new PictureInPictureParams.Builder()
.setAspectRatio(new Rational(1, 1));
Throwable error;
if (currentActivity == null) {
throw new IllegalStateException("No current Activity!");
}
// https://developer.android.com/reference/android/app/Activity.html#enterPictureInPictureMode(android.app.PictureInPictureParams)
//
// The system may disallow entering picture-in-picture in various
// cases, including when the activity is not visible, if the screen
// is locked or if the user has an activity pinned.
try {
error
= currentActivity.enterPictureInPictureMode(builder.build())
? null
: new Exception("Failed to enter Picture-in-Picture");
} catch (RuntimeException re) {
error = re;
}
Log.d(TAG, "Entering Picture-in-Picture");
if (error == null) {
promise.resolve(null);
} else {
promise.reject(error);
}
PictureInPictureParams.Builder builder
= new PictureInPictureParams.Builder()
.setAspectRatio(new Rational(1, 1));
return;
// https://developer.android.com/reference/android/app/Activity.html#enterPictureInPictureMode(android.app.PictureInPictureParams)
//
// The system may disallow entering picture-in-picture in various cases,
// including when the activity is not visible, if the screen is locked
// or if the user has an activity pinned.
if (!currentActivity.enterPictureInPictureMode(builder.build())) {
throw new RuntimeException("Failed to enter Picture-in-Picture");
}
}
promise.reject(new Exception("Picture-in-Picture not supported"));
/**
* Enters Picture-in-Picture (mode) for the current {@link Activity}.
* Supported on Android API >= 26 (Oreo) only.
*
* @param promise a {@code Promise} which will resolve with a {@code null}
* value upon success, and an {@link Exception} otherwise.
*/
@ReactMethod
public void enterPictureInPicture(Promise promise) {
try {
enterPictureInPicture();
promise.resolve(null);
} catch (RuntimeException re) {
promise.reject(re);
}
}
@Override

@ -61,7 +61,7 @@ public class ReactInstanceManagerHolder {
@Nullable Object data) {
ReactInstanceManager reactInstanceManager
= ReactInstanceManagerHolder.getReactInstanceManager();
if (reactInstanceManager != null) {
ReactContext reactContext
= reactInstanceManager.getCurrentReactContext();
@ -77,6 +77,26 @@ public class ReactInstanceManagerHolder {
return false;
}
/**
* Finds a native React module for given class.
*
* @param nativeModuleClass the native module's class for which an instance
* is to be retrieved from the {@link #reactInstanceManager}.
* @param <T> the module's type.
* @return {@link NativeModule} instance for given interface type or
* {@code null} if no instance for this interface is available, or if
* {@link #reactInstanceManager} has not been initialized yet.
*/
static <T extends NativeModule> T getNativeModule(
Class<T> nativeModuleClass) {
ReactContext reactContext
= reactInstanceManager != null
? reactInstanceManager.getCurrentReactContext() : null;
return reactContext != null
? reactContext.getNativeModule(nativeModuleClass) : null;
}
static ReactInstanceManager getReactInstanceManager() {
return reactInstanceManager;
}

@ -9,16 +9,3 @@
* @public
*/
export const ENTER_PICTURE_IN_PICTURE = Symbol('ENTER_PICTURE_IN_PICTURE');
/**
* The type of redux action to set the {@code EventEmitter} subscriptions
* utilized by the feature picture-in-picture.
*
* {
* type: _SET_EMITTER_SUBSCRIPTIONS,
* emitterSubscriptions: Array|undefined
* }
*
* @protected
*/
export const _SET_EMITTER_SUBSCRIPTIONS = Symbol('_SET_EMITTER_SUBSCRIPTIONS');

@ -4,10 +4,7 @@ import { NativeModules } from 'react-native';
import { Platform } from '../../base/react';
import {
ENTER_PICTURE_IN_PICTURE,
_SET_EMITTER_SUBSCRIPTIONS
} from './actionTypes';
import { ENTER_PICTURE_IN_PICTURE } from './actionTypes';
/**
* Enters (or rather initiates entering) picture-in-picture.
@ -47,22 +44,3 @@ export function enterPictureInPicture() {
}
};
}
/**
* Sets the {@code EventEmitter} subscriptions utilized by the feature
* picture-in-picture.
*
* @param {Array<Object>} emitterSubscriptions - The {@code EventEmitter}
* subscriptions to be set.
* @protected
* @returns {{
* type: _SET_EMITTER_SUBSCRIPTIONS,
* emitterSubscriptions: Array<Object>
* }}
*/
export function _setEmitterSubscriptions(emitterSubscriptions: ?Array<Object>) {
return {
type: _SET_EMITTER_SUBSCRIPTIONS,
emitterSubscriptions
};
}

@ -1,6 +1,3 @@
export * from './actions';
export * from './actionTypes';
export * from './components';
import './middleware';
import './reducer';

@ -1,70 +0,0 @@
// @flow
import { DeviceEventEmitter } from 'react-native';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../app';
import { MiddlewareRegistry } from '../../base/redux';
import { enterPictureInPicture, _setEmitterSubscriptions } from './actions';
import { _SET_EMITTER_SUBSCRIPTIONS } from './actionTypes';
/**
* Middleware that handles Picture-in-Picture requests. Currently it enters
* the native PiP mode on Android, when requested.
*
* @param {Store} store - Redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
return _appWillMount(store, next, action);
case APP_WILL_UNMOUNT:
store.dispatch(_setEmitterSubscriptions(undefined));
break;
case _SET_EMITTER_SUBSCRIPTIONS: {
// Remove the current/old EventEmitter subscriptions.
const { emitterSubscriptions } = store.getState()['features/pip'];
if (emitterSubscriptions) {
for (const emitterSubscription of emitterSubscriptions) {
// XXX We may be removing an EventEmitter subscription which is
// in both the old and new Array of EventEmitter subscriptions!
// Thankfully, we don't have such a practical use case at the
// time of this writing.
emitterSubscription.remove();
}
}
break;
}
}
return next(action);
});
/**
* Notifies the feature pip that the action {@link APP_WILL_MOUNT} is being
* dispatched within a specific redux {@code store}.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action {@code APP_WILL_MOUNT} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {*} The value returned by {@code next(action)}.
*/
function _appWillMount({ dispatch }, next, action) {
dispatch(_setEmitterSubscriptions([
// Android's onUserLeaveHint activity lifecycle callback
DeviceEventEmitter.addListener(
'onUserLeaveHint',
() => dispatch(enterPictureInPicture()))
]));
return next(action);
}

@ -1,17 +0,0 @@
// @flow
import { ReducerRegistry } from '../../base/redux';
import { _SET_EMITTER_SUBSCRIPTIONS } from './actionTypes';
ReducerRegistry.register('features/pip', (state = {}, action) => {
switch (action.type) {
case _SET_EMITTER_SUBSCRIPTIONS:
return {
...state,
emitterSubscriptions: action.emitterSubscriptions
};
}
return state;
});
Loading…
Cancel
Save