From 9972e88b6710f1bde8fe88c7c9454cc95cdc45db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Ibarra=20Corretg=C3=A9?= Date: Thu, 12 Jul 2018 15:00:23 +0200 Subject: [PATCH] [Android] Split base functionality out of JitsiMeetView As the need for adding more views connected with our React code arises, having everything in JitsiMeetView is not going to scale. In order to pave the way for multiple apps / views feeding off the React side, the following changes have been made: - All base functionality related to creating a ReactRootView and layout are now in BaseReactView - All Activity lifecycle methods that need to be called by any activity holding a BaseReactView are now conveniently placed in ReactActivityLifecycleAdapter - ExternalAPIModule has been refactored to cater for multiple views: events are delivered to views, and its their resposibility to deal with them - Following on the previous point, ListenerUtils is a utility class for helping with the translation from events into listener methods --- .../org/jitsi/meet/sdk/BaseReactView.java | 171 ++++++++++ .../org/jitsi/meet/sdk/ExternalAPIModule.java | 194 +---------- .../org/jitsi/meet/sdk/JitsiMeetActivity.java | 25 +- .../org/jitsi/meet/sdk/JitsiMeetView.java | 323 ++++-------------- .../org/jitsi/meet/sdk/ListenerUtils.java | 150 ++++++++ .../sdk/ReactActivityLifecycleAdapter.java | 113 ++++++ .../jitsi/meet/sdk/invite/InviteModule.java | 3 +- 7 files changed, 539 insertions(+), 440 deletions(-) create mode 100644 android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java create mode 100644 android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java create mode 100644 android/sdk/src/main/java/org/jitsi/meet/sdk/ReactActivityLifecycleAdapter.java diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java new file mode 100644 index 000000000..0599b2c42 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java @@ -0,0 +1,171 @@ +package org.jitsi.meet.sdk; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; +import android.widget.FrameLayout; + +import com.facebook.react.ReactRootView; +import com.facebook.react.bridge.ReadableMap; +import com.rnimmersive.RNImmersiveModule; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * Base class for all views which are backed by a React Native view. + */ +public abstract class BaseReactView extends FrameLayout { + /** + * Background color used by {@code BaseReactView} and the React Native root + * view. + */ + protected static int BACKGROUND_COLOR = 0xFF111111; + + /** + * The unique identifier of this {@code BaseReactView} within the process + * for the purposes of {@link ExternalAPIModule}. The name scope was + * inspired by postis which we use on Web for the similar purposes of the + * iframe-based external API. + */ + protected final String externalAPIScope; + + /** + * React Native root view. + */ + private ReactRootView reactRootView; + + /** + * Collection with all created views. This is used for finding the right + * view when delivering events coming from the {@link ExternalAPIModule}; + */ + static final Set views + = Collections.newSetFromMap(new WeakHashMap()); + + /** + * Find a view which matches the given external API scope. + * + * @param externalAPIScope - Scope for the view we want to find. + * @return The found {@code BaseReactView}, or {@code null}. + */ + public static BaseReactView findViewByExternalAPIScope( + String externalAPIScope) { + synchronized (views) { + for (BaseReactView view : views) { + if (view.externalAPIScope.equals(externalAPIScope)) { + return view; + } + } + } + + return null; + } + + public BaseReactView(@NonNull Context context) { + super(context); + + setBackgroundColor(BACKGROUND_COLOR); + + ReactInstanceManagerHolder.initReactInstanceManager( + ((Activity) context).getApplication()); + + // Hook this BaseReactView into ExternalAPI. + externalAPIScope = UUID.randomUUID().toString(); + synchronized (views) { + views.add(this); + } + } + + /** + * Creates the {@code ReactRootView} for the given app name with the given + * props. Once created it's set as the view of this {@code FrameLayout}. + * + * @param appName - Name of the "app" (in React Native terms) which we want + * to load. + * @param props - Props (in React terms) to be passed to the app. + */ + public void createReactRootView(String appName, @Nullable Bundle props) { + if (props == null) { + props = new Bundle(); + } + + // Set externalAPIScope + props.putString("externalAPIScope", externalAPIScope); + + if (reactRootView == null) { + reactRootView = new ReactRootView(getContext()); + reactRootView.startReactApplication( + ReactInstanceManagerHolder.getReactInstanceManager(), + appName, + props); + reactRootView.setBackgroundColor(BACKGROUND_COLOR); + addView(reactRootView); + } else { + reactRootView.setAppProperties(props); + } + } + + /** + * Releases the React resources (specifically the {@link ReactRootView}) + * associated with this view. + * + * This method MUST be called when the Activity holding this view is + * destroyed, typically in the {@code onDestroy} method. + */ + public void dispose() { + if (reactRootView != null) { + removeView(reactRootView); + reactRootView.unmountReactApplication(); + reactRootView = null; + } + } + + /** + * Abstract method called by {@link ExternalAPIModule} when an event is + * received for this view. + * + * @param name - Name of the event. + * @param data - Event data. + */ + public abstract void onExternalAPIEvent(String name, ReadableMap data); + + /** + * Called when the window containing this view gains or loses focus. + * + * @param hasFocus If the window of this view now has focus, {@code true}; + * otherwise, {@code false}. + */ + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + + // https://github.com/mockingbot/react-native-immersive#restore-immersive-state + + // FIXME The singleton pattern employed by RNImmersiveModule is not + // advisable because a react-native mobule is consumable only after its + // BaseJavaModule#initialize() has completed and here we have no + // knowledge of whether the precondition is really met. + RNImmersiveModule immersive = RNImmersiveModule.getInstance(); + + if (hasFocus && immersive != null) { + try { + immersive.emitImmersiveStateChangeEvent(); + } catch (RuntimeException re) { + // FIXME I don't know how to check myself whether + // BaseJavaModule#initialize() has been invoked and thus + // RNImmersiveModule is consumable. A safe workaround is to + // swallow the failure because the whole full-screen/immersive + // functionality is brittle anyway, akin to the icing on the + // cake, and has been working without onWindowFocusChanged for a + // very long time. + Log.e("RNImmersiveModule", + "emitImmersiveStateChangeEvent() failed!", re); + } + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java index 49888d0a0..bb1d92dd6 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java @@ -16,77 +16,18 @@ package org.jitsi.meet.sdk; +import android.util.Log; + import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.ReadableMapKeySetIterator; -import com.facebook.react.bridge.UiThreadUtil; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Pattern; /** - * Module implementing a simple API to enable a proximity sensor-controlled - * wake lock. When the lock is held, if the proximity sensor detects a nearby - * object it will dim the screen and disable touch controls. The functionality - * is used with the conference audio-only mode. + * Module implementing an API for sending events from JavaScript to native code. */ class ExternalAPIModule extends ReactContextBaseJavaModule { - /** - * The {@code Method}s of {@code JitsiMeetViewListener} by event name i.e. - * redux action types. - */ - private static final Map JITSI_MEET_VIEW_LISTENER_METHODS - = new HashMap<>(); - - static { - // Figure out the mapping between the JitsiMeetViewListener methods - // and the events i.e. redux action types. - Pattern onPattern = Pattern.compile("^on[A-Z]+"); - Pattern camelcasePattern = Pattern.compile("([a-z0-9]+)([A-Z0-9]+)"); - - for (Method method : JitsiMeetViewListener.class.getDeclaredMethods()) { - // * The method must be public (because it is declared by an - // interface). - // * The method must be/return void. - if (!Modifier.isPublic(method.getModifiers()) - || !Void.TYPE.equals(method.getReturnType())) { - continue; - } - - // * The method name must start with "on" followed by a - // capital/uppercase letter (in agreement with the camelcase - // coding style customary to Java in general and the projects of - // the Jitsi community in particular). - String name = method.getName(); - - if (!onPattern.matcher(name).find()) { - continue; - } - - // * The method must accept/have exactly 1 parameter of a type - // assignable from HashMap. - Class[] parameterTypes = method.getParameterTypes(); - - if (parameterTypes.length != 1 - || !parameterTypes[0].isAssignableFrom(HashMap.class)) { - continue; - } - - // Convert the method name to an event name. - name - = camelcasePattern.matcher(name.substring(2)) - .replaceAll("$1_$2") - .toUpperCase(Locale.ROOT); - JITSI_MEET_VIEW_LISTENER_METHODS.put(name, method); - } - } + private static final String TAG = ExternalAPIModule.class.getSimpleName(); /** * Initializes a new module instance. There shall be a single instance of @@ -109,39 +50,9 @@ class ExternalAPIModule extends ReactContextBaseJavaModule { return "ExternalAPI"; } - /** - * The internal processing for the URL of the current conference set on the - * associated {@link JitsiMeetView}. - * - * @param eventName the name of the external API event to be processed - * @param eventData the details/specifics of the event to process determined - * by/associated with the specified {@code eventName}. - * @param view the {@link JitsiMeetView} instance. - */ - private void maybeSetViewURL( - String eventName, - ReadableMap eventData, - JitsiMeetView view) { - switch(eventName) { - case "CONFERENCE_WILL_JOIN": - view.setURL(eventData.getString("url")); - break; - - case "CONFERENCE_FAILED": - case "CONFERENCE_WILL_LEAVE": - case "LOAD_CONFIG_ERROR": - String url = eventData.getString("url"); - - if (url != null && url.equals(view.getURL())) { - view.setURL(null); - } - break; - } - } - /** * Dispatches an event that occurred on the JavaScript side of the SDK to - * the specified {@link JitsiMeetView}'s listener. + * the specified {@link BaseReactView}'s listener. * * @param name The name of the event. * @param data The details/specifics of the event to send determined @@ -154,101 +65,18 @@ class ExternalAPIModule extends ReactContextBaseJavaModule { final String scope) { // The JavaScript App needs to provide uniquely identifying information // to the native ExternalAPI module so that the latter may match the - // former to the native JitsiMeetView which hosts it. - JitsiMeetView view = JitsiMeetView.findViewByExternalAPIScope(scope); + // former to the native BaseReactView which hosts it. + BaseReactView view = BaseReactView.findViewByExternalAPIScope(scope); if (view == null) { return; } - // XXX The JitsiMeetView property URL was introduced in order to address - // an exception in the Picture-in-Picture functionality which arose - // because of delays related to bridging between JavaScript and Java. To - // reduce these delays do not wait for the call to be transfered to the - // UI thread. - maybeSetViewURL(name, data, view); - - // Make sure JitsiMeetView's listener is invoked on the UI thread. It - // was requested by SDK consumers. - if (UiThreadUtil.isOnUiThread()) { - sendEventOnUiThread(name, data, scope); - } else { - UiThreadUtil.runOnUiThread(new Runnable() { - @Override - public void run() { - sendEventOnUiThread(name, data, scope); - } - }); + try { + view.onExternalAPIEvent(name, data); + } catch(Exception e) { + Log.e(TAG, "onExternalAPIEvent: error sending event", e); } } - /** - * Dispatches an event that occurred on the JavaScript side of the SDK to - * the specified {@link JitsiMeetView}'s listener on the UI thread. - * - * @param name The name of the event. - * @param data The details/specifics of the event to send determined - * by/associated with the specified {@code name}. - * @param scope - */ - private void sendEventOnUiThread(final String name, - final ReadableMap data, - final String scope) { - // The JavaScript App needs to provide uniquely identifying information - // to the native ExternalAPI module so that the latter may match the - // former to the native JitsiMeetView which hosts it. - JitsiMeetView view = JitsiMeetView.findViewByExternalAPIScope(scope); - - if (view == null) { - return; - } - - JitsiMeetViewListener listener = view.getListener(); - - if (listener == null) { - return; - } - - Method method = JITSI_MEET_VIEW_LISTENER_METHODS.get(name); - - if (method != null) { - try { - method.invoke(listener, toHashMap(data)); - } 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); - } - } - } - - /** - * Initializes a new {@code HashMap} instance with the key-value - * associations of a specific {@code ReadableMap}. - * - * @param readableMap the {@code ReadableMap} specifying the key-value - * associations with which the new {@code HashMap} instance is to be - * initialized. - * @return a new {@code HashMap} instance initialized with the key-value - * associations of the specified {@code readableMap}. - */ - private HashMap toHashMap(ReadableMap readableMap) { - HashMap hashMap = new HashMap<>(); - - for (ReadableMapKeySetIterator i = readableMap.keySetIterator(); - i.hasNextKey();) { - String key = i.nextKey(); - - hashMap.put(key, readableMap.getString(key)); - } - - return hashMap; - } } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java index fe12022e4..e7a051b13 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java @@ -177,7 +177,7 @@ public class JitsiMeetActivity extends AppCompatActivity { @Override public void onBackPressed() { - if (!JitsiMeetView.onBackPressed()) { + if (!ReactActivityLifecycleAdapter.onBackPressed()) { // JitsiMeetView didn't handle the invocation of the back button. // Generally, an Activity extender would very likely want to invoke // Activity#onBackPressed(). For the sake of consistency with @@ -220,7 +220,7 @@ public class JitsiMeetActivity extends AppCompatActivity { view = null; } - JitsiMeetView.onHostDestroy(this); + ReactActivityLifecycleAdapter.onHostDestroy(this); } // ReactAndroid/src/main/java/com/facebook/react/ReactActivity.java @@ -242,7 +242,20 @@ public class JitsiMeetActivity extends AppCompatActivity { @Override public void onNewIntent(Intent intent) { - JitsiMeetView.onNewIntent(intent); + // XXX At least twice we received bug reports about malfunctioning + // loadURL in the Jitsi Meet SDK while the Jitsi Meet app seemed to + // functioning as expected in our testing. But that was to be expected + // because the app does not exercise loadURL. In order to increase the + // test coverage of loadURL, channel deep linking through loadURL. + Uri uri; + + if (Intent.ACTION_VIEW.equals(intent.getAction()) + && (uri = intent.getData()) != null + && JitsiMeetView.loadURLStringInViews(uri.toString())) { + return; + } + + ReactActivityLifecycleAdapter.onNewIntent(intent); } @Override @@ -250,21 +263,21 @@ public class JitsiMeetActivity extends AppCompatActivity { super.onResume(); defaultBackButtonImpl = new DefaultHardwareBackBtnHandlerImpl(this); - JitsiMeetView.onHostResume(this, defaultBackButtonImpl); + ReactActivityLifecycleAdapter.onHostResume(this, defaultBackButtonImpl); } @Override public void onStop() { super.onStop(); - JitsiMeetView.onHostPause(this); + ReactActivityLifecycleAdapter.onHostPause(this); defaultBackButtonImpl = null; } @Override protected void onUserLeaveHint() { if (view != null) { - view.onUserLeaveHint(); + view.enterPictureInPicture(); } } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java index ce7e8ec0f..e6d006107 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java @@ -16,57 +16,33 @@ package org.jitsi.meet.sdk; -import android.app.Activity; import android.content.Context; -import android.content.Intent; -import android.net.Uri; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; -import android.widget.FrameLayout; -import com.facebook.react.ReactInstanceManager; -import com.facebook.react.ReactRootView; -import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; -import com.rnimmersive.RNImmersiveModule; +import com.facebook.react.bridge.ReadableMap; import org.jitsi.meet.sdk.invite.InviteController; +import java.lang.reflect.Method; import java.net.URL; -import java.util.Collections; -import java.util.Set; -import java.util.UUID; -import java.util.WeakHashMap; +import java.util.Map; -public class JitsiMeetView extends FrameLayout { +public class JitsiMeetView extends BaseReactView { /** - * Background color used by {@code JitsiMeetView} and the React Native root - * view. + * The {@code Method}s of {@code JitsiMeetViewListener} by event name i.e. + * redux action types. */ - private static final int BACKGROUND_COLOR = 0xFF111111; + private static final Map LISTENER_METHODS + = ListenerUtils.slurpListenerMethods(JitsiMeetViewListener.class); /** * The {@link Log} tag which identifies the source of the log messages of * {@code JitsiMeetView}. */ - private final static String TAG = JitsiMeetView.class.getSimpleName(); - - private static final Set views - = Collections.newSetFromMap(new WeakHashMap()); - - public static JitsiMeetView findViewByExternalAPIScope( - String externalAPIScope) { - synchronized (views) { - for (JitsiMeetView view : views) { - if (view.externalAPIScope.equals(externalAPIScope)) { - return view; - } - } - } - - return null; - } + private static final String TAG = JitsiMeetView.class.getSimpleName(); /** * Loads a specific URL {@code String} in all existing @@ -78,130 +54,19 @@ public class JitsiMeetView extends FrameLayout { * at least one {@code JitsiMeetView}, then {@code true}; otherwise, * {@code false}. */ - private static boolean loadURLStringInViews(String urlString) { - synchronized (views) { - if (!views.isEmpty()) { - for (JitsiMeetView view : views) { - view.loadURLString(urlString); - } + public static boolean loadURLStringInViews(String urlString) { + boolean loaded = false; - return true; + synchronized (views) { + for (BaseReactView view : views) { + if (view instanceof JitsiMeetView) { + ((JitsiMeetView)view).loadURLString(urlString); + loaded = true; + } } } - return false; - } - - /** - * Activity lifecycle method which should be called from - * {@code Activity.onBackPressed} so we can do the required internal - * processing. - * - * @return {@code true} if the back-press was processed; {@code false}, - * otherwise. If {@code false}, the application should call the parent's - * implementation. - */ - public static boolean onBackPressed() { - ReactInstanceManager reactInstanceManager - = ReactInstanceManagerHolder.getReactInstanceManager(); - - if (reactInstanceManager == null) { - return false; - } else { - reactInstanceManager.onBackPressed(); - return true; - } - } - - /** - * Activity lifecycle method which should be called from - * {@code Activity.onDestroy} so we can do the required internal - * processing. - * - * @param activity {@code Activity} being destroyed. - */ - public static void onHostDestroy(Activity activity) { - ReactInstanceManager reactInstanceManager - = ReactInstanceManagerHolder.getReactInstanceManager(); - - if (reactInstanceManager != null) { - reactInstanceManager.onHostDestroy(activity); - } - } - - /** - * Activity lifecycle method which should be called from - * {@code Activity.onPause} so we can do the required internal processing. - * - * @param activity {@code Activity} being paused. - */ - public static void onHostPause(Activity activity) { - ReactInstanceManager reactInstanceManager - = ReactInstanceManagerHolder.getReactInstanceManager(); - - if (reactInstanceManager != null) { - reactInstanceManager.onHostPause(activity); - } - } - - /** - * Activity lifecycle method which should be called from - * {@code Activity.onResume} so we can do the required internal processing. - * - * @param activity {@code Activity} being resumed. - */ - public static void onHostResume(Activity activity) { - onHostResume(activity, new DefaultHardwareBackBtnHandlerImpl(activity)); - } - - /** - * Activity lifecycle method which should be called from - * {@code Activity.onResume} so we can do the required internal processing. - * - * @param activity {@code Activity} being resumed. - * @param defaultBackButtonImpl a {@code DefaultHardwareBackBtnHandler} to - * handle invoking the back button if no {@code JitsiMeetView} handles it. - */ - public static void onHostResume( - Activity activity, - DefaultHardwareBackBtnHandler defaultBackButtonImpl) { - ReactInstanceManager reactInstanceManager - = ReactInstanceManagerHolder.getReactInstanceManager(); - - if (reactInstanceManager != null) { - reactInstanceManager.onHostResume(activity, defaultBackButtonImpl); - } - } - - /** - * Activity lifecycle method which should be called from - * {@code Activity.onNewIntent} so we can do the required internal - * processing. Note that this is only needed if the activity's "launchMode" - * was set to "singleTask". This is required for deep linking to work once - * the application is already running. - * - * @param intent {@code Intent} instance which was received. - */ - public static void onNewIntent(Intent intent) { - // XXX At least twice we received bug reports about malfunctioning - // loadURL in the Jitsi Meet SDK while the Jitsi Meet app seemed to - // functioning as expected in our testing. But that was to be expected - // because the app does not exercise loadURL. In order to increase the - // test coverage of loadURL, channel deep linking through loadURL. - Uri uri; - - if (Intent.ACTION_VIEW.equals(intent.getAction()) - && (uri = intent.getData()) != null - && loadURLStringInViews(uri.toString())) { - return; - } - - ReactInstanceManager reactInstanceManager - = ReactInstanceManagerHolder.getReactInstanceManager(); - - if (reactInstanceManager != null) { - reactInstanceManager.onNewIntent(intent); - } + return loaded; } /** @@ -211,14 +76,6 @@ public class JitsiMeetView extends FrameLayout { */ private URL defaultURL; - /** - * The unique identifier of this {@code JitsiMeetView} within the process - * for the purposes of {@link ExternalAPI}. The name scope was inspired by - * postis which we use on Web for the similar purposes of the iframe-based - * external API. - */ - private final String externalAPIScope; - /** * The entry point into the invite feature of Jitsi Meet. The Java * counterpart of the JavaScript {@code InviteButton}. @@ -238,11 +95,6 @@ public class JitsiMeetView extends FrameLayout { */ private Boolean pictureInPictureEnabled; - /** - * React Native root view. - */ - private ReactRootView reactRootView; - /** * The URL of the current conference. */ @@ -258,34 +110,33 @@ public class JitsiMeetView extends FrameLayout { public JitsiMeetView(@NonNull Context context) { super(context); - setBackgroundColor(BACKGROUND_COLOR); - - ReactInstanceManagerHolder.initReactInstanceManager( - ((Activity) context).getApplication()); - - // Hook this JitsiMeetView into ExternalAPI. - externalAPIScope = UUID.randomUUID().toString(); - synchronized (views) { - views.add(this); - } - // The entry point into the invite feature of Jitsi Meet. The Java // counterpart of the JavaScript InviteButton. inviteController = new InviteController(externalAPIScope); } /** - * Releases the React resources (specifically the {@link ReactRootView}) - * associated with this view. + * Enters Picture-In-Picture mode, if possible. This method is designed to + * be called from the {@code Activity.onUserLeaveHint} method. * - * This method MUST be called when the Activity holding this view is - * destroyed, typically in the {@code onDestroy} method. + * 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 dispose() { - if (reactRootView != null) { - removeView(reactRootView); - reactRootView.unmountReactApplication(); - reactRootView = null; + public void enterPictureInPicture() { + if (getPictureInPictureEnabled() && getURL() != null) { + PictureInPictureModule pipModule + = ReactInstanceManagerHolder.getNativeModule( + PictureInPictureModule.class); + + if (pipModule != null) { + try { + pipModule.enterPictureInPicture(); + } catch (RuntimeException re) { + Log.e(TAG, "onUserLeaveHint: failed to enter PiP mode", re); + } + } } } @@ -294,7 +145,7 @@ public class JitsiMeetView extends FrameLayout { * partial URL (e.g. a room name only) is specified to * {@link #loadURLString(String)} or {@link #loadURLObject(Bundle)}. If not * set or if set to {@code null}, the default built in JavaScript is used: - * {@link https://meet.jit.si} + * https://meet.jit.si * * @return The default base {@code URL} or {@code null}. */ @@ -337,7 +188,7 @@ public class JitsiMeetView extends FrameLayout { return PictureInPictureModule.isPictureInPictureSupported() && (pictureInPictureEnabled == null - || pictureInPictureEnabled.booleanValue()); + || pictureInPictureEnabled); } /** @@ -395,9 +246,6 @@ public class JitsiMeetView extends FrameLayout { props.putString("defaultURL", defaultURL.toString()); } - // externalAPIScope - props.putString("externalAPIScope", externalAPIScope); - // inviteController InviteController inviteController = getInviteController(); @@ -434,17 +282,7 @@ public class JitsiMeetView extends FrameLayout { // per loadURLObject: invocation. props.putLong("timestamp", System.currentTimeMillis()); - if (reactRootView == null) { - reactRootView = new ReactRootView(getContext()); - reactRootView.startReactApplication( - ReactInstanceManagerHolder.getReactInstanceManager(), - "App", - props); - reactRootView.setBackgroundColor(BACKGROUND_COLOR); - addView(reactRootView); - } else { - reactRootView.setAppProperties(props); - } + createReactRootView("App", props); } /** @@ -468,65 +306,50 @@ public class JitsiMeetView extends FrameLayout { } /** - * Activity lifecycle method which should be called from - * {@code Activity.onUserLeaveHint} so we can do the required internal - * processing. + * The internal processing for the URL of the current conference set on the + * associated {@link JitsiMeetView}. * - * 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. + * @param eventName the name of the external API event to be processed + * @param eventData the details/specifics of the event to process determined + * by/associated with the specified {@code eventName}. */ - public void onUserLeaveHint() { - if (getPictureInPictureEnabled() && getURL() != null) { - PictureInPictureModule pipModule - = ReactInstanceManagerHolder.getNativeModule( - PictureInPictureModule.class); + private void maybeSetViewURL(String eventName, ReadableMap eventData) { + switch(eventName) { + case "CONFERENCE_WILL_JOIN": + setURL(eventData.getString("url")); + break; - if (pipModule != null) { - try { - pipModule.enterPictureInPicture(); - } catch (RuntimeException re) { - Log.e(TAG, "onUserLeaveHint: failed to enter PiP mode", re); - } + case "CONFERENCE_FAILED": + case "CONFERENCE_WILL_LEAVE": + case "LOAD_CONFIG_ERROR": + String url = eventData.getString("url"); + + if (url != null && url.equals(getURL())) { + setURL(null); } + break; } } /** - * Called when the window containing this view gains or loses focus. + * Handler for {@link ExternalAPIModule} events. * - * @param hasFocus If the window of this view now has focus, {@code true}; - * otherwise, {@code false}. + * @param name - Name of the event. + * @param data - Event data. */ @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); + public void onExternalAPIEvent(String name, ReadableMap data) { + // XXX The JitsiMeetView property URL was introduced in order to address + // an exception in the Picture-in-Picture functionality which arose + // because of delays related to bridging between JavaScript and Java. To + // reduce these delays do not wait for the call to be transferred to the + // UI thread. + maybeSetViewURL(name, data); - // https://github.com/mockingbot/react-native-immersive#restore-immersive-state - - // FIXME The singleton pattern employed by RNImmersiveModule is not - // advisable because a react-native mobule is consumable only after its - // BaseJavaModule#initialize() has completed and here we have no - // knowledge of whether the precondition is really met. - RNImmersiveModule immersive = RNImmersiveModule.getInstance(); - - if (hasFocus && immersive != null) { - try { - immersive.emitImmersiveStateChangeEvent(); - } catch (RuntimeException re) { - // FIXME I don't know how to check myself whether - // BaseJavaModule#initialize() has been invoked and thus - // RNImmersiveModule is consumable. A safe workaround is to - // swallow the failure because the whole full-screen/immersive - // functionality is brittle anyway, akin to the icing on the - // cake, and has been working without onWindowFocusChanged for a - // very long time. - Log.e( - TAG, - "RNImmersiveModule#emitImmersiveStateChangeEvent() failed!", - re); - } + JitsiMeetViewListener listener = getListener(); + if (listener != null) { + ListenerUtils.runListenerMethod( + listener, LISTENER_METHODS, name, data); } } @@ -563,7 +386,7 @@ public class JitsiMeetView extends FrameLayout { * {@code true}; otherwise, {@code false}. */ public void setPictureInPictureEnabled(boolean pictureInPictureEnabled) { - this.pictureInPictureEnabled = Boolean.valueOf(pictureInPictureEnabled); + this.pictureInPictureEnabled = pictureInPictureEnabled; } /** diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java new file mode 100644 index 000000000..f00396c55 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java @@ -0,0 +1,150 @@ +package org.jitsi.meet.sdk; + +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.UiThreadUtil; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Utility methods for helping with transforming {@link ExternalAPIModule} + * events into listener methods. Used with descendants of {@link BaseReactView}. + */ +public final class ListenerUtils { + /** + * Extracts the methods defined in a listener and creates a mapping of this + * form: event name -> method. + * + * @param listener - The listener whose methods we want to slurp. + * @return A mapping with event names - methods. + */ + public static Map slurpListenerMethods(Class listener) { + final Map methods = new HashMap<>(); + + // Figure out the mapping between the listener methods + // and the events i.e. redux action types. + Pattern onPattern = Pattern.compile("^on[A-Z]+"); + Pattern camelcasePattern = Pattern.compile("([a-z0-9]+)([A-Z0-9]+)"); + + for (Method method : listener.getDeclaredMethods()) { + // * The method must be public (because it is declared by an + // interface). + // * The method must be/return void. + if (!Modifier.isPublic(method.getModifiers()) + || !Void.TYPE.equals(method.getReturnType())) { + continue; + } + + // * The method name must start with "on" followed by a + // capital/uppercase letter (in agreement with the camelcase + // coding style customary to Java in general and the projects of + // the Jitsi community in particular). + String name = method.getName(); + + if (!onPattern.matcher(name).find()) { + continue; + } + + // * The method must accept/have exactly 1 parameter of a type + // assignable from HashMap. + Class[] parameterTypes = method.getParameterTypes(); + + if (parameterTypes.length != 1 + || !parameterTypes[0].isAssignableFrom(HashMap.class)) { + continue; + } + + // Convert the method name to an event name. + name + = camelcasePattern.matcher(name.substring(2)) + .replaceAll("$1_$2") + .toUpperCase(Locale.ROOT); + methods.put(name, method); + } + + return methods; + } + + /** + * Executes the right listener method for the given event. + * NOTE: This function will run asynchronously on the UI thread. + * + * @param listener - The listener on which the method will be called. + * @param listenerMethods - Mapping with event names and the matching + * methods. + * @param eventName - Name of the event. + * @param eventData - Data associated with the event. + */ + public static void runListenerMethod( + final Object listener, + final Map listenerMethods, + final String eventName, + final ReadableMap eventData) { + // Make sure listener methods are invoked on the UI thread. It + // was requested by SDK consumers. + if (UiThreadUtil.isOnUiThread()) { + runListenerMethodOnUiThread( + listener, listenerMethods, eventName, eventData); + } else { + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + runListenerMethodOnUiThread( + listener, listenerMethods, eventName, eventData); + } + }); + } + } + + /** + * Helper companion for {@link ListenerUtils#runListenerMethod} which runs + * in the UI thread. + */ + private static void runListenerMethodOnUiThread( + Object listener, + Map listenerMethods, + String eventName, + ReadableMap eventData) { + UiThreadUtil.assertOnUiThread(); + + Method method = listenerMethods.get(eventName); + if (method != null) { + try { + method.invoke(listener, toHashMap(eventData)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Initializes a new {@code HashMap} instance with the key-value + * associations of a specific {@code ReadableMap}. + * + * @param readableMap the {@code ReadableMap} specifying the key-value + * associations with which the new {@code HashMap} instance is to be + * initialized. + * @return a new {@code HashMap} instance initialized with the key-value + * associations of the specified {@code readableMap}. + */ + private static HashMap toHashMap(ReadableMap readableMap) { + HashMap hashMap = new HashMap<>(); + + for (ReadableMapKeySetIterator i = readableMap.keySetIterator(); + i.hasNextKey();) { + String key = i.nextKey(); + + hashMap.put(key, readableMap.getString(key)); + } + + return hashMap; + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactActivityLifecycleAdapter.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactActivityLifecycleAdapter.java new file mode 100644 index 000000000..fa5c4255e --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactActivityLifecycleAdapter.java @@ -0,0 +1,113 @@ +package org.jitsi.meet.sdk; + +import android.app.Activity; +import android.content.Intent; + +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; + +/** + * Helper class to encapsulate the work which needs to be done on Activity + * lifecycle methods in order for the React side to be aware of it. + */ +class ReactActivityLifecycleAdapter { + /** + * Activity lifecycle method which should be called from + * {@code Activity.onBackPressed} so we can do the required internal + * processing. + * + * @return {@code true} if the back-press was processed; {@code false}, + * otherwise. If {@code false}, the application should call the parent's + * implementation. + */ + public static boolean onBackPressed() { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager == null) { + return false; + } else { + reactInstanceManager.onBackPressed(); + return true; + } + } + + /** + * Activity lifecycle method which should be called from + * {@code Activity.onDestroy} so we can do the required internal + * processing. + * + * @param activity {@code Activity} being destroyed. + */ + public static void onHostDestroy(Activity activity) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onHostDestroy(activity); + } + } + + /** + * Activity lifecycle method which should be called from + * {@code Activity.onPause} so we can do the required internal processing. + * + * @param activity {@code Activity} being paused. + */ + public static void onHostPause(Activity activity) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onHostPause(activity); + } + } + + /** + * Activity lifecycle method which should be called from + * {@code Activity.onResume} so we can do the required internal processing. + * + * @param activity {@code Activity} being resumed. + */ + public static void onHostResume(Activity activity) { + onHostResume(activity, new DefaultHardwareBackBtnHandlerImpl(activity)); + } + + /** + * Activity lifecycle method which should be called from + * {@code Activity.onResume} so we can do the required internal processing. + * + * @param activity {@code Activity} being resumed. + * @param defaultBackButtonImpl a {@code DefaultHardwareBackBtnHandler} to + * handle invoking the back button if no {@code JitsiMeetView} handles it. + */ + public static void onHostResume( + Activity activity, + DefaultHardwareBackBtnHandler defaultBackButtonImpl) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onHostResume(activity, defaultBackButtonImpl); + } + } + + /** + * Activity lifecycle method which should be called from + * {@code Activity.onNewIntent} so we can do the required internal + * processing. Note that this is only needed if the activity's "launchMode" + * was set to "singleTask". This is required for deep linking to work once + * the application is already running. + * + * @param intent {@code Intent} instance which was received. + */ + public static void onNewIntent(Intent intent) { + ReactInstanceManager reactInstanceManager + = ReactInstanceManagerHolder.getReactInstanceManager(); + + if (reactInstanceManager != null) { + reactInstanceManager.onNewIntent(intent); + } + } + +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/invite/InviteModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/invite/InviteModule.java index edb435d4b..3d886db4a 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/invite/InviteModule.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/invite/InviteModule.java @@ -24,6 +24,7 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.UiThreadUtil; +import org.jitsi.meet.sdk.BaseReactView; import org.jitsi.meet.sdk.JitsiMeetView; /** @@ -67,7 +68,7 @@ public class InviteModule extends ReactContextBaseJavaModule { private InviteController findInviteControllerByExternalAPIScope( String externalAPIScope) { JitsiMeetView view - = JitsiMeetView.findViewByExternalAPIScope(externalAPIScope); + = (JitsiMeetView)BaseReactView.findViewByExternalAPIScope(externalAPIScope); return view == null ? null : view.getInviteController(); }