diff --git a/.gitignore b/.gitignore index 1387dd5e90c2..fab8dfa58f75 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,9 @@ tsconfig.json react-native-sdk/*.tgz react-native-sdk/android/src !react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetReactNativePackage.java +!react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java +!react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JMOngoingConferenceModule.java +!react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/RNOngoingNotification.java react-native-sdk/images react-native-sdk/ios react-native-sdk/lang diff --git a/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JMOngoingConferenceModule.java b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JMOngoingConferenceModule.java new file mode 100644 index 000000000000..bc8c3fde54cd --- /dev/null +++ b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JMOngoingConferenceModule.java @@ -0,0 +1,41 @@ +package org.jitsi.meet.sdk; +import android.app.Activity; +import android.content.Context; +import androidx.annotation.NonNull; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.module.annotations.ReactModule; + +/** + * This class implements a ReactModule and it's + * responsible for launching/aborting a service when a conference is in progress. + */ +@ReactModule(name = JMOngoingConferenceModule.NAME) +class JMOngoingConferenceModule extends ReactContextBaseJavaModule { + + public static final String NAME = "JMOngoingConference"; + + public JMOngoingConferenceModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @ReactMethod + public void launch() { + Context context = getReactApplicationContext(); + Activity currentActivity = getCurrentActivity(); + JitsiMeetOngoingConferenceService.launch(context, currentActivity); + } + + @ReactMethod + public void abort() { + Context context = getReactApplicationContext(); + JitsiMeetOngoingConferenceService.abort(context); + } + + @NonNull + @Override + public String getName() { + return NAME; + } +} diff --git a/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java new file mode 100644 index 000000000000..597f1248f941 --- /dev/null +++ b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java @@ -0,0 +1,161 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.meet.sdk; +import static android.Manifest.permission.POST_NOTIFICATIONS; +import static android.Manifest.permission.RECORD_AUDIO; + +import android.app.Activity; +import android.app.Notification; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; + +import com.facebook.react.modules.core.PermissionListener; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * This class implements an Android {@link Service}, a foreground one specifically, and it's + * responsible for presenting an ongoing notification when a conference is in progress. + * The service will help keep the app running while in the background. + * + * See: https://developer.android.com/guide/components/services + */ +public class JitsiMeetOngoingConferenceService extends Service { + private static final String TAG = JitsiMeetOngoingConferenceService.class.getSimpleName(); + + private static final int PERMISSIONS_REQUEST_CODE = (int) (Math.random() * Short.MAX_VALUE); + + static final int NOTIFICATION_ID = new Random().nextInt(99999) + 10000; + + private static PermissionListener permissionListener; + + + public static void doLaunch(Context context, Activity currentActivity) { + + RNOngoingNotification.createOngoingConferenceNotificationChannel(currentActivity); + + Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class); + + ComponentName componentName; + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + componentName = context.startForegroundService(intent); + } else { + componentName = context.startService(intent); + } + } catch (RuntimeException e) { + // Avoid crashing due to ForegroundServiceStartNotAllowedException (API level 31). + // See: https://developer.android.com/guide/components/foreground-services#background-start-restrictions + JitsiMeetLogger.w(TAG + " Ongoing conference service not started", e); + return; + } + if (componentName == null) { + JitsiMeetLogger.w(TAG + " Ongoing conference service not started"); + } + } + + public static void launch(Context context, Activity currentActivity) { + List permissionsList = new ArrayList<>(); + + PermissionListener listener = new PermissionListener() { + @Override + public boolean onRequestPermissionsResult(int i, String[] strings, int[] results) { + int counter = 0; + + if (results.length > 0) { + for (int result : results) { + if (result == PackageManager.PERMISSION_GRANTED) { + counter++; + } + } + + if (counter == results.length){ + doLaunch(context, currentActivity); + JitsiMeetLogger.w(TAG + " Service launched, permissions were granted"); + } else { + JitsiMeetLogger.w(TAG + " Couldn't launch service, permissions were not granted"); + } + } + + return true; + } + }; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionsList.add(POST_NOTIFICATIONS); + permissionsList.add(RECORD_AUDIO); + } + + String[] permissionsArray = new String[ permissionsList.size() ]; + permissionsArray = permissionsList.toArray( permissionsArray ); + + if (permissionsArray.length > 0) { + try { + currentActivity.requestPermissions(permissionsArray, PERMISSIONS_REQUEST_CODE); + } catch (Exception e) { + JitsiMeetLogger.e(e, "Error requesting permissions"); + listener.onRequestPermissionsResult(PERMISSIONS_REQUEST_CODE, permissionsArray, new int[0]); + } + } else { + doLaunch(context, currentActivity); + JitsiMeetLogger.w(TAG + " Service launched"); + } + } + + public static void abort(Context context) { + Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class); + context.stopService(intent); + } + + @Override + public void onCreate() { + super.onCreate(); + Notification notification = RNOngoingNotification.buildOngoingConferenceNotification(this); + if (notification == null) { + stopSelf(); + JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null"); + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK | ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_NOT_STICKY; + } +} diff --git a/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetReactNativePackage.java b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetReactNativePackage.java index fb6f46d58341..ff2c86f6813c 100644 --- a/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetReactNativePackage.java +++ b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/JitsiMeetReactNativePackage.java @@ -21,6 +21,7 @@ public List createNativeModules(@NonNull ReactApplicationContext r new AndroidSettingsModule(reactContext), new AppInfoModule(reactContext), new AudioModeModule(reactContext), + new JMOngoingConferenceModule(reactContext), new JavaScriptSandboxModule(reactContext), new LocaleDetector(reactContext), new LogBridgeModule(reactContext), diff --git a/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/RNOngoingNotification.java b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/RNOngoingNotification.java new file mode 100644 index 000000000000..28ab23f0dc32 --- /dev/null +++ b/react-native-sdk/android/src/main/java/org/jitsi/meet/sdk/RNOngoingNotification.java @@ -0,0 +1,87 @@ +/* + * Copyright @ 2019-present 8x8, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jitsi.meet.sdk; +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import org.jitsi.meet.sdk.log.JitsiMeetLogger; +import java.util.Random; +/** + * Helper class for creating the ongoing notification which is used with + * {@link JitsiMeetOngoingConferenceService}. It allows the user to easily get back to the app + * and to hangup from within the notification itself. + */ +class RNOngoingNotification { + private static final String TAG = RNOngoingNotification.class.getSimpleName(); + + static void createOngoingConferenceNotificationChannel(Activity currentActivity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + if (currentActivity == null) { + JitsiMeetLogger.w(TAG + " Cannot create notification channel: no current context"); + return; + } + + NotificationManager notificationManager + = (NotificationManager) currentActivity.getSystemService(Context.NOTIFICATION_SERVICE); + + NotificationChannel channel + = notificationManager.getNotificationChannel("OngoingConferenceChannel"); + + if (channel != null) { + // The channel was already created, no need to do it again. + return; + } + + channel = new NotificationChannel("OngoingConferenceChannel", currentActivity.getString(R.string.ongoing_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT); + channel.enableLights(false); + channel.enableVibration(false); + channel.setShowBadge(false); + + notificationManager.createNotificationChannel(channel); + } + static Notification buildOngoingConferenceNotification(Context context) { + if (context == null) { + JitsiMeetLogger.w(TAG + " Cannot create notification: no current context"); + return null; + } + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "OngoingConferenceChannel"); + + builder + .setCategory(NotificationCompat.CATEGORY_CALL) + .setContentTitle(context.getString(R.string.ongoing_notification_title)) + .setContentText(context.getString(R.string.ongoing_notification_text)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setOngoing(true) + .setWhen(System.currentTimeMillis()) + .setUsesChronometer(true) + .setAutoCancel(false) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setSmallIcon(context.getResources().getIdentifier("ic_notification", "drawable", context.getPackageName())); + + return builder.build(); + } +} diff --git a/react/features/mobile/react-native-sdk/middleware.js b/react/features/mobile/react-native-sdk/middleware.js index a8922e730229..de5c859d88b3 100644 --- a/react/features/mobile/react-native-sdk/middleware.js +++ b/react/features/mobile/react-native-sdk/middleware.js @@ -1,3 +1,5 @@ +import { NativeModules, Platform } from 'react-native'; + import { getAppProp } from '../../base/app/functions'; import { CONFERENCE_BLURRED, @@ -10,6 +12,7 @@ import { import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../../base/media/actionTypes'; import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../../base/participants/actionTypes'; import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry'; +import StateListenerRegistry from '../../base/redux/StateListenerRegistry'; import { READY_TO_CLOSE } from '../external-api/actionTypes'; import { participantToParticipantInfo } from '../external-api/functions'; import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture/actionTypes'; @@ -17,6 +20,7 @@ import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture/actionTypes'; import { isExternalAPIAvailable } from './functions'; const externalAPIEnabled = isExternalAPIAvailable(); +const { JMOngoingConference } = NativeModules; /** @@ -84,3 +88,21 @@ const externalAPIEnabled = isExternalAPIAvailable(); return result; }); + +/** + * Before enabling media projection service control on Android, + * we need to check if native modules are being used or not. + */ +Platform.OS === 'android' && !externalAPIEnabled && StateListenerRegistry.register( + state => state['features/base/conference'].conference, + (conference, previousConference) => { + if (!conference) { + JMOngoingConference.abort(); + } else if (conference && !previousConference) { + JMOngoingConference.launch(); + } else if (conference !== previousConference) { + JMOngoingConference.abort(); + JMOngoingConference.launch(); + } + } +);