Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-native-sdk/android): force permissions approval in order to launch RNOngoingNotification #15400

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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;

import org.jitsi.meet.sdk.log.JitsiMeetLogger;


/**
* 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();
JMOngoingConferenceService.launch(context, currentActivity);
JitsiMeetLogger.w(NAME + " Module launch");
}

@ReactMethod
public void abort() {
Context context = getReactApplicationContext();
JMOngoingConferenceService.abort(context);
JitsiMeetLogger.w(NAME + " Module abort");
}

@NonNull
@Override
public String getName() {
return NAME;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* 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.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.Arrays;
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 JMOngoingConferenceService extends Service {
private static final String TAG = JMOngoingConferenceService.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;

public static void doLaunch(Context context) {

RNOngoingNotification.createOngoingConferenceNotificationChannel(context);

Intent intent = new Intent(context, JMOngoingConferenceService.class);

try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent);
} else {
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;
}
}

public static void launch(Context context, Activity currentActivity) {
List<String> 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);
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);
JitsiMeetLogger.w(TAG + " Requesting permissions: " + Arrays.toString(permissionsArray));
} catch (Exception e) {
JitsiMeetLogger.e(e, "Error requesting permissions");
listener.onRequestPermissionsResult(PERMISSIONS_REQUEST_CODE, permissionsArray, new int[0]);
}
} else {
doLaunch(context);
JitsiMeetLogger.w(TAG + " No permissions needed, launching service");
}
}

public static void abort(Context context) {
Intent intent = new Intent(context, JMOngoingConferenceService.class);
context.stopService(intent);
}

@Override
public void onCreate() {
super.onCreate();
JitsiMeetLogger.w(TAG + " Building ongoing conference notification");

Notification notification = RNOngoingNotification.buildOngoingConferenceNotification(this);
if (notification == null) {
stopSelf();
JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null");
} else {
JitsiMeetLogger.w(TAG + " Starting service in foreground with notification");

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public List<NativeModule> 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),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;

import android.content.Context;

import android.os.Build;

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 JMOngoingConferenceService}. 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(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
JitsiMeetLogger.w(TAG + " Android version is lower than Oreo, skipping notification channel creation");
return;
}
if (context == null) {
JitsiMeetLogger.w(TAG + " Cannot create notification channel: no current context");
return;
}

NotificationManager notificationManager
= (NotificationManager) context.getSystemService(Service.NOTIFICATION_SERVICE);

NotificationChannel channel
= notificationManager.getNotificationChannel("OngoingConferenceChannel");

if (channel != null) {
JitsiMeetLogger.w(TAG + " Notification channel already exists");
return;
}

channel = new NotificationChannel("OngoingConferenceChannel", context.getString(R.string.ongoing_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT);
channel.enableLights(false);
channel.enableVibration(false);
channel.setShowBadge(false);

notificationManager.createNotificationChannel(channel);
JitsiMeetLogger.w(TAG + " Notification channel created successfully");
}
static Notification buildOngoingConferenceNotification(Context context) {
if (context == null) {
JitsiMeetLogger.w(TAG + " Cannot create notification: no current context");
return null;
}

JitsiMeetLogger.w(TAG + " Creating notification with context: " + context);

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())),
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE);

return builder.build();
}
}
22 changes: 22 additions & 0 deletions react/features/mobile/react-native-sdk/middleware.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { NativeModules, Platform } from 'react-native';

import { getAppProp } from '../../base/app/functions';
import {
CONFERENCE_BLURRED,
Expand All @@ -10,13 +12,15 @@ 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';

import { isExternalAPIAvailable } from './functions';

const externalAPIEnabled = isExternalAPIAvailable();
const { JMOngoingConference } = NativeModules;


/**
Expand Down Expand Up @@ -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();
}
}
);
Loading