From 491f79370a883265b7cf143278230a958944dad2 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 11 Mar 2024 17:53:35 +0100 Subject: [PATCH] Permission handling improvement (#1028) --- .../08-permissions-and-moderation.mdx | 14 ++ .../api/stream-video-android-core.api | 7 +- .../io/getstream/video/android/core/Call.kt | 13 ++ .../video/android/core/StreamVideoBuilder.kt | 5 + .../video/android/core/StreamVideoImpl.kt | 3 + .../internal/service/CallService.kt | 92 ++++++---- .../android/DefaultStreamPermissionCheck.kt | 30 ++++ .../permission/android/PermissionUtilities.kt | 104 +++++++++++ .../android/StreamPermissionCheck.kt | 57 +++++++ .../android/PermissionUtilitiesKtTest.kt | 111 ++++++++++++ .../api/stream-video-android-ui-compose.api | 21 +++ .../permission/LaunchPermissionRequest.kt | 161 ++++++++++++++++++ 12 files changed, 582 insertions(+), 36 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/DefaultStreamPermissionCheck.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilities.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/StreamPermissionCheck.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilitiesKtTest.kt create mode 100644 stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/LaunchPermissionRequest.kt diff --git a/docusaurus/docs/Android/03-guides/08-permissions-and-moderation.mdx b/docusaurus/docs/Android/03-guides/08-permissions-and-moderation.mdx index d85e802905..5b4fb08077 100644 --- a/docusaurus/docs/Android/03-guides/08-permissions-and-moderation.mdx +++ b/docusaurus/docs/Android/03-guides/08-permissions-and-moderation.mdx @@ -67,6 +67,20 @@ val microphonePermissionState = rememberMicrophonePermissionState(call = call) microphonePermissionState.launchPermissionRequest() ``` +:::note +The permissions are required and any usage of the `Call` object without them may result in a crash. +::: + +In order to notify an inconsistency the SDK will log a warning when `Call.join()` is being called without the required permissions. +This is completely ok, if you have a [call type](./05-call-types.mdx) which does not require streaming audio or video from the users device (e.g. `audio_room` or live broadcast where the user is only a guest and listens in to the stream). + +The SDK by default will check for runtime permissions based on call capabilities, so if your call requires audio to be sent, the SDK will expect that the `android.Manifest.permission.RECORD_AUDIO` is granted. + +:::warning +If you are not overriding the `runForegroundServiceForCalls` flag to `false` in the `StreamVideoBuilder` the resulting foreground service that starts for [keeping the call alive](./06-keeping-the-call-alive.mdx) can not run without the permissions and will crash with a detailed message. +::: + +If you wish to override the behavior on which permissions are required for your calls you can provide a new implementation of `StreamPermissionCheck` to the `StreamVideoBuilder`. ### Moderation Capabilities You can block a user or remove them from a call diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 4f92200699..cff9a83461 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -808,7 +808,8 @@ public final class io/getstream/video/android/core/StreamVideoBuilder { public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;Z)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;)V public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/permission/android/StreamPermissionCheck;)V + public synthetic fun (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLjava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun build ()Lio/getstream/video/android/core/StreamVideo; public final fun getScope ()Lkotlinx/coroutines/CoroutineScope; } @@ -4173,6 +4174,10 @@ public final class io/getstream/video/android/core/permission/PermissionRequest public fun toString ()Ljava/lang/String; } +public abstract interface class io/getstream/video/android/core/permission/android/StreamPermissionCheck { + public abstract fun checkAndroidPermissions (Landroid/content/Context;Lio/getstream/video/android/core/Call;)Z +} + public final class io/getstream/video/android/core/socket/CoordinatorSocket : io/getstream/video/android/core/socket/PersistentSocket { public fun (Ljava/lang/String;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lokhttp3/OkHttpClient;Lio/getstream/video/android/core/internal/network/NetworkStateProvider;)V public synthetic fun (Ljava/lang/String;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lokhttp3/OkHttpClient;Lio/getstream/video/android/core/internal/network/NetworkStateProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 3cb4b64843..5467d1530a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -303,6 +303,19 @@ public class Call( ring: Boolean = false, notify: Boolean = false, ): Result { + val permissionPass = + clientImpl.permissionCheck.checkAndroidPermissions(clientImpl.context, this) + // Check android permissions and log a warning to make sure developers requested adequate permissions prior to using the call. + if (!permissionPass) { + logger.w { + "\n[Call.join()] called without having the required permissions.\n" + + "This will work only if you have [runForegroundServiceForCalls = false] in the StreamVideoBuilder.\n" + + "The reason is that [Call.join()] will by default start an ongoing call foreground service,\n" + + "To start this service and send the appropriate audio/video tracks the permissions are required,\n" + + "otherwise the service will fail to start, resulting in a crash.\n" + + "You can re-define your permissions and their expected state by overriding the [permissionCheck] in [StreamVideoBuilder]\n" + } + } // if we are a guest user, make sure we wait for the token before running the join flow clientImpl.guestUserJob?.await() // the join flow should retry up to 3 times diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index 789a387ec1..32f604b108 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -29,6 +29,8 @@ import io.getstream.video.android.core.logging.LoggingLevel import io.getstream.video.android.core.notifications.NotificationConfig import io.getstream.video.android.core.notifications.internal.StreamNotificationManager import io.getstream.video.android.core.notifications.internal.storage.DeviceTokenStorage +import io.getstream.video.android.core.permission.android.DefaultStreamPermissionCheck +import io.getstream.video.android.core.permission.android.StreamPermissionCheck import io.getstream.video.android.core.sounds.Sounds import io.getstream.video.android.model.ApiKey import io.getstream.video.android.model.User @@ -68,6 +70,7 @@ import java.util.UUID * @property runForegroundServiceForCalls If set to true, when there is an active call the SDK will run a foreground service to keep the process alive. (default: true) * @property localSfuAddress Local SFU address (IP:port) to be used for testing. Leave null if not needed. * @property sounds Overwrite the default SDK sounds. See [Sounds]. + * @property permissionCheck used to check for system permission based on call capabilities. See [StreamPermissionCheck]. */ public class StreamVideoBuilder @JvmOverloads constructor( context: Context, @@ -87,6 +90,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val runForegroundServiceForCalls: Boolean = true, private val localSfuAddress: String? = null, private val sounds: Sounds = Sounds(), + private val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), ) { private val context: Context = context.applicationContext @@ -166,6 +170,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( runForegroundService = runForegroundServiceForCalls, testSfuAddress = localSfuAddress, sounds = sounds, + permissionCheck = permissionCheck, ) if (user.type == UserType.Guest) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt index 3be370d569..a68f682155 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt @@ -43,6 +43,8 @@ import io.getstream.video.android.core.model.UpdateUserPermissionsData import io.getstream.video.android.core.model.toRequest import io.getstream.video.android.core.notifications.NotificationHandler import io.getstream.video.android.core.notifications.internal.StreamNotificationManager +import io.getstream.video.android.core.permission.android.DefaultStreamPermissionCheck +import io.getstream.video.android.core.permission.android.StreamPermissionCheck import io.getstream.video.android.core.socket.ErrorResponse import io.getstream.video.android.core.socket.PersistentSocket import io.getstream.video.android.core.socket.SocketState @@ -137,6 +139,7 @@ internal class StreamVideoImpl internal constructor( internal val runForegroundService: Boolean = true, internal val testSfuAddress: String? = null, internal val sounds: Sounds, + internal val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), ) : StreamVideo, NotificationHandler by streamNotificationManager { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index f01994c606..5ed2955f5b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -179,48 +179,59 @@ internal class CallService : Service() { callDisplayName = intent?.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME) val trigger = intent?.getStringExtra(TRIGGER_KEY) val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoImpl + val started = if (callId != null && streamVideo != null && trigger != null) { - val notificationData: Pair = - when (trigger) { - TRIGGER_ONGOING_CALL -> Pair( - first = streamVideo.getOngoingCallNotification( - callId = callId!!, - ), - second = callId.hashCode(), - ) + val type = callId!!.type + val id = callId!!.id + val call = streamVideo.call(type, id) + val permissionCheckPass = + streamVideo.permissionCheck.checkAndroidPermissions(applicationContext, call) + if (!permissionCheckPass) { + // Crash early with a meaningful message if Call is used without system permissions. + throw IllegalStateException( + "\nCallService attempted to start without required permissions (e.g. android.manifest.permission.RECORD_AUDIO).\n" + "This can happen if you call [Call.join()] without the required permissions being granted by the user.\n" + "If you are using compose and [LaunchCallPermissions] ensure that you rely on the [onRequestResult] callback\n" + "to ensure that the permission is granted prior to calling [Call.join()] or similar.\n" + "Optionally you can use [LaunchPermissionRequest] to ensure permissions are granted.\n" + "If you are not using the [stream-video-android-ui-compose] library,\n" + "ensure that permissions are granted prior calls to [Call.join()].\n" + "You can re-define your permissions and their expected state by overriding the [permissionCheck] in [StreamVideoBuilder]\n", + ) + } - TRIGGER_INCOMING_CALL -> Pair( - first = streamVideo.getRingingCallNotification( - ringingState = RingingState.Incoming(), - callId = callId!!, - callDisplayName = callDisplayName!!, - ), - second = INCOMING_CALL_NOTIFICATION_ID, - ) + val notificationData: Pair = when (trigger) { + TRIGGER_ONGOING_CALL -> Pair( + first = streamVideo.getOngoingCallNotification( + callId = callId!!, + ), + second = callId.hashCode(), + ) - TRIGGER_OUTGOING_CALL -> Pair( - first = streamVideo.getRingingCallNotification( - ringingState = RingingState.Outgoing(), - callId = callId!!, - callDisplayName = getString( - R.string.stream_video_ongoing_call_notification_description, - ), + TRIGGER_INCOMING_CALL -> Pair( + first = streamVideo.getRingingCallNotification( + ringingState = RingingState.Incoming(), + callId = callId!!, + callDisplayName = callDisplayName!!, + ), + second = INCOMING_CALL_NOTIFICATION_ID, + ) + + TRIGGER_OUTGOING_CALL -> Pair( + first = streamVideo.getRingingCallNotification( + ringingState = RingingState.Outgoing(), + callId = callId!!, + callDisplayName = getString( + R.string.stream_video_ongoing_call_notification_description, ), - second = INCOMING_CALL_NOTIFICATION_ID, // Same for incoming and outgoing - ) + ), + second = INCOMING_CALL_NOTIFICATION_ID, // Same for incoming and outgoing + ) - else -> Pair(null, callId.hashCode()) - } + else -> Pair(null, callId.hashCode()) + } val notification = notificationData.first if (notification != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val foregroundServiceType = - when (trigger) { - TRIGGER_ONGOING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE - TRIGGER_INCOMING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - TRIGGER_OUTGOING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - else -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - } + val foregroundServiceType = when (trigger) { + TRIGGER_ONGOING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + TRIGGER_INCOMING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + TRIGGER_OUTGOING_CALL -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + else -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } ServiceCompat.startForeground( this@CallService, callId.hashCode(), @@ -244,7 +255,12 @@ internal class CallService : Service() { if (!started) { logger.w { "Foreground service did not start!" } + // Call stopSelf() and return START_REDELIVER_INTENT. + // Because of stopSelf() the service is not restarted. + // Because START_REDELIVER_INTENT is returned + // the exception RemoteException: Service did not call startForeground... is not thrown. stopService() + return START_REDELIVER_INTENT } else { initializeCallAndSocket(streamVideo!!, callId!!) @@ -256,8 +272,8 @@ internal class CallService : Service() { } observeCallState(callId!!, streamVideo) registerToggleCameraBroadcastReceiver() + return START_NOT_STICKY } - return START_NOT_STICKY } private fun updateRingingCall( @@ -285,6 +301,7 @@ internal class CallService : Service() { stopCallSound() // Stops sound sooner than Active. More responsive. } } + is RingingState.Outgoing -> { if (!it.acceptedByCallee) { playCallSound(streamVideo.sounds.outgoingCallSound) @@ -292,16 +309,20 @@ internal class CallService : Service() { stopCallSound() // Stops sound sooner than Active. More responsive. } } + is RingingState.Active -> { // Handle Active to make it more reliable stopCallSound() } + is RingingState.RejectedByAll -> { stopCallSound() stopService() } + is RingingState.TimeoutNoAnswer -> { stopCallSound() } + else -> { // Do nothing } @@ -424,6 +445,7 @@ internal class CallService : Service() { // Optionally (no-op if already stopping) stopSelf() } + private fun registerToggleCameraBroadcastReceiver() { if (!isToggleCameraBroadcastReceiverRegistered) { try { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/DefaultStreamPermissionCheck.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/DefaultStreamPermissionCheck.kt new file mode 100644 index 0000000000..270571f7b2 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/DefaultStreamPermissionCheck.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * 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 io.getstream.video.android.core.permission.android + +import android.content.Context +import io.getstream.video.android.core.Call + +/** + * Default stream permission check. + */ +internal class DefaultStreamPermissionCheck : StreamPermissionCheck { + override fun checkAndroidPermissions( + context: Context, + call: Call, + ): Boolean = checkPermissionsExpectations(context, call) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilities.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilities.kt new file mode 100644 index 0000000000..033de05dfb --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilities.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * 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 io.getstream.video.android.core.permission.android + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import io.getstream.video.android.core.Call +import org.openapitools.client.models.OwnCapability + +/** + * Default mapper for stream calls. + */ +internal val defaultPermissionMapper: (OwnCapability) -> String? = { capability -> + when (capability) { + is OwnCapability.SendAudio -> { + android.Manifest.permission.RECORD_AUDIO + } + + is OwnCapability.SendVideo -> { + android.Manifest.permission.CAMERA + } + + else -> { + // Do not add any permission + null + } + } +} + +/** + * Default expectation for all permissions that we may inquire is granted. + */ +internal val defaultPermissionExpectation: (permission: String) -> Int = + { _ -> PackageManager.PERMISSION_GRANTED } + +/** + * Default check against the system if the permission is granted. + */ +internal fun defaultPermissionSystemCheck(context: Context): (permission: String) -> Int = + { permission -> ContextCompat.checkSelfPermission(context, permission) } + +/** + * Maps a list of [OwnCapability] to list of [String] where the strings, are android manifest permissions. + * used the [defaultPermissionMapper]. + * + * Additional mapper can be supplied for different mapping. + * + * @return the list of permissions, based on the [OwnCapability] list. + */ +internal fun List.mapPermissions(mapper: (OwnCapability) -> String?): List = + this.mapNotNull { + mapper.invoke(it) + }.toSet().toList() // Remove duplicates. + +/** + * Check permission against the system. + * + * @param systemPermissionCheck usually equal to { ContextCompat.checkSelfPermission(context,string) }. + * @param permissionExpectation = + */ +internal fun List.checkAllPermissions( + systemPermissionCheck: (String) -> Int, + permissionExpectation: (String) -> Int = defaultPermissionExpectation, +): Boolean { + for (permission in this) { + if (systemPermissionCheck.invoke(permission) != permissionExpectation.invoke(permission)) { + // If any permission is not according to expectation + return false + } + } + // If all permission are according to expectation function, assume true. + return true +} + +/** + * Check android permissions. for a call + * + * @param context the android contex.t + * @param call the [Call]. + * @param permissionMapper mapper used to map list of [OwnCapability] into android.Manifest.* permissions. + * @param permissionExpectation an expectation for each mapped permission one of [PackageManager.PERMISSION_GRANTED] or [PackageManager.PERMISSION_DENIED] + */ +internal fun checkPermissionsExpectations( + context: Context, + call: Call, + permissionMapper: (OwnCapability) -> String? = defaultPermissionMapper, + permissionExpectation: (String) -> Int = defaultPermissionExpectation, +) = call.state.ownCapabilities.value.mapPermissions(permissionMapper) + .checkAllPermissions(defaultPermissionSystemCheck(context), permissionExpectation) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/StreamPermissionCheck.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/StreamPermissionCheck.kt new file mode 100644 index 0000000000..fe062235db --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/permission/android/StreamPermissionCheck.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * 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 io.getstream.video.android.core.permission.android + +import android.content.Context +import io.getstream.video.android.core.Call + +/** + * Android permission check for [Call] + * Depending on the call capabilities, [checkAndroidPermissions] will return true if all the android permissions are granted required by the call. + * By default having a capability to stream audio for an audio call will require the [android.Manifest.permission.RECORD_AUDIO] permission. + * This check ensures that the required permission based on the call configuration are granted. + * + * The default implementation checks for two permissions: + * + * [android.Manifest.permission.RECORD_AUDIO] if the call has [org.openapitools.client.models.OwnCapability.SendAudio] capability + * + * and + * + * [android.Manifest.permission.CAMERA] if the call has [org.openapitools.client.models.OwnCapability.SendVideo] capability. + * + * This means that editing the configuration of the call has effect on which permissions are checked. + * It is possible to provide a separate implementation to this interface to override this behavior. + * + * NOTE: + * If the [io.getstream.video.android.core.StreamVideoBuilder.runForegroundServiceForCalls] is true + * the foreground service that starts will crash if [checkAndroidPermissions] returns false. + * + * @see [io.getstream.video.android.core.StreamVideoBuilder] + */ +interface StreamPermissionCheck { + + /** + * Return true if the user granted all the permissions so the [Call] can run. + * + * e.g. if the user granted the android.Manifest.permission.RECORD_AUDIO and the Call.state.ownCapability has "SendAudio" + * then we are safe to join and use the [call] since the permission for recording audio is granted and the user can stream the audio track. + */ + fun checkAndroidPermissions( + context: Context, + call: Call, + ): Boolean +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilitiesKtTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilitiesKtTest.kt new file mode 100644 index 0000000000..80dd9fbea3 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/permission/android/PermissionUtilitiesKtTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * 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 io.getstream.video.android.core.permission.android + +import android.content.pm.PackageManager +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import org.openapitools.client.models.OwnCapability +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PermissionUtilitiesKtTest { + + @Test + fun `Correctly map the permissions based on the default mapper`() { + // Given + val capabilities = listOf(OwnCapability.SendAudio, OwnCapability.SendVideo) + // When + val defaultMapped = capabilities.mapPermissions(defaultPermissionMapper) + // Then + assertArrayEquals( + arrayOf(android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAMERA), + defaultMapped.toTypedArray(), + ) + } + + @Test + fun `Correctly map the permissions based on the custom mapper`() { + // Given + val capabilities = listOf(OwnCapability.SendAudio, OwnCapability.SendVideo) + val expectedPermission = android.Manifest.permission.ACCEPT_HANDOVER + // When + val mapped = capabilities.mapPermissions { + // All capabilities map to this permission, for testing + expectedPermission + } + // Then + assertArrayEquals(arrayOf(expectedPermission), mapped.toTypedArray()) + } + + @Test + fun `Check permission expectation base on default mapper and expectations`() { + // Given + val capabilities = listOf(OwnCapability.SendAudio, OwnCapability.SendVideo) + val permissions = capabilities.mapPermissions(defaultPermissionMapper) + + // When + val actual = permissions.checkAllPermissions(systemPermissionCheck = { + // return as if the permission is granted + PackageManager.PERMISSION_GRANTED + }) + + // Then + assertTrue(actual) + } + + @Test + fun `Check permission expectation base on default mapper and expectations when permissions are denied`() { + // Given + val capabilities = listOf(OwnCapability.SendAudio, OwnCapability.SendVideo) + val permissions = capabilities.mapPermissions(defaultPermissionMapper) + + // When + val actual = permissions.checkAllPermissions(systemPermissionCheck = { + // return as if the permission is granted + PackageManager.PERMISSION_DENIED + }) + + // Then + assertFalse(actual) + } + + @Test + fun `Check permission expectation base on default mapper and custom expectations`() { + // Given + val capabilities = listOf(OwnCapability.SendAudio, OwnCapability.SendVideo) + val permissions = capabilities.mapPermissions(defaultPermissionMapper) + + // When + val actual = permissions.checkAllPermissions(systemPermissionCheck = { + // return as if the permission is granted + PackageManager.PERMISSION_GRANTED + }, { + when (it) { + // Expect granted permission for record audio + android.Manifest.permission.RECORD_AUDIO -> PackageManager.PERMISSION_GRANTED + // Denied for everything else. + else -> PackageManager.PERMISSION_DENIED + } + }) + + // Then + // We expect false because the mock system check returns GRANTED for all permissions, + // where we expect granted only for the RECORD_AUDIO + assertFalse(actual) + } +} diff --git a/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api b/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api index e8ef082622..1a3d942d35 100644 --- a/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api +++ b/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api @@ -7,6 +7,27 @@ public final class io/getstream/video/android/compose/permission/CallPermissions public static final fun rememberCallPermissionsState (Lio/getstream/video/android/core/Call;Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)Lio/getstream/video/android/compose/permission/VideoPermissionsState; } +public final class io/getstream/video/android/compose/permission/ComposableSingletons$LaunchPermissionRequestKt { + public static final field INSTANCE Lio/getstream/video/android/compose/permission/ComposableSingletons$LaunchPermissionRequestKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function5; + public static field lambda-3 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$stream_video_android_ui_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$stream_video_android_ui_compose_release ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-3$stream_video_android_ui_compose_release ()Lkotlin/jvm/functions/Function3; +} + +public final class io/getstream/video/android/compose/permission/LaunchPermissionRequestKt { + public static final fun LaunchPermissionRequest (Ljava/util/List;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V +} + +public abstract interface class io/getstream/video/android/compose/permission/LaunchPermissionRequestScope { + public abstract fun AllPermissionsGranted (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public abstract fun NoneGranted (Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public abstract fun SomeGranted (Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;I)V +} + public final class io/getstream/video/android/compose/permission/SinglePermissionKt { public static final fun LaunchCameraPermissions (Lio/getstream/video/android/core/Call;Landroidx/compose/runtime/Composer;I)V public static final fun LaunchMicrophonePermissions (Lio/getstream/video/android/core/Call;Landroidx/compose/runtime/Composer;I)V diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/LaunchPermissionRequest.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/LaunchPermissionRequest.kt new file mode 100644 index 0000000000..cdc559d803 --- /dev/null +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/permission/LaunchPermissionRequest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * 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 io.getstream.video.android.compose.permission + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +/** + * An API to ensure that the list of [permissions] is granted. + * + * Usage + * ```kotlin + * LaunchPermissionRequest(listOf(android.Manifest.RECORD_AUDIO, android.Manifest.CAMERA) { + * Granted { + * // All permissions granted + * } + * SomeGranted { granted, notGranted, showRationale -> + * // Some of the permissions were granted, you can check which ones. + * } + * NotGranted { + * // None of the permissions were granted. + * } + * } + * + * + * ``` + * + * @param permissions the required permissions. + * @param content the composable content used to register the + */ +@OptIn(ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class) +@Composable +public fun LaunchPermissionRequest( + permissions: List, + content: @Composable LaunchPermissionRequestScope.() -> Unit, +) { + val permissionsState = rememberMultiplePermissionsState( + permissions = permissions, + ) + // Init scope + val ensurePermissionScope = LaunchPermissionRequestScopeImpl() + // Register callbacks + content(ensurePermissionScope) + + if (permissionsState.allPermissionsGranted) { + // All permissions are granted, call the "granted" content + ensurePermissionScope.grantedContent() + } else { + val anyPermissionWasGranted = permissionsState.permissions.any { + it.status == PermissionStatus.Granted + } + if (anyPermissionWasGranted) { + val granted = permissionsState.permissions.mapNotNull { + if (it.status == PermissionStatus.Granted) { + it.permission + } else { + null + } + } + val notGranted = permissionsState.permissions.mapNotNull { + if (it.status is PermissionStatus.Denied) { + it.permission + } else { + null + } + } + + ensurePermissionScope.someGrantedContent( + granted, + notGranted, + permissionsState.shouldShowRationale, + ) + } else { + ensurePermissionScope.notGrantedContent(permissionsState.shouldShowRationale) + } + } + LaunchedEffect(key1 = true) { + permissionsState.launchMultiplePermissionRequest() + } +} + +/** + * Scope for the [LaunchPermissionRequest] composable. + * Used to register the content callbacks for when the permissions are available or not. + */ +public interface LaunchPermissionRequestScope { + /** + * Called after the request, when the user has granted all the requested permissions for this scope. + */ + @Composable + public fun AllPermissionsGranted(content: @Composable () -> Unit) + + /** + * Some permissions were granted, while others were not. + * Use the [content] parameters to decide what to do in certain cases if some permissions are optional to you. + */ + @Composable + public fun SomeGranted( + content: @Composable ( + granted: List, + notGranted: List, + showRationale: Boolean, + ) -> Unit, + ) + + /** + * None of the permissions were granted. + */ + @Composable + public fun NoneGranted(content: @Composable (showRationale: Boolean) -> Unit) +} + +private class LaunchPermissionRequestScopeImpl : LaunchPermissionRequestScope { + + var grantedContent: @Composable () -> Unit = { } + var someGrantedContent: @Composable ( + granted: List, + notGranted: List, + showRationale: Boolean, + ) -> Unit = + { _, _, _ -> } + var notGrantedContent: @Composable (showRationale: Boolean) -> Unit = { } + + @Composable + override fun AllPermissionsGranted(content: @Composable () -> Unit) { + grantedContent = content + } + + @Composable + override fun SomeGranted( + content: @Composable ( + granted: List, + notGranted: List, + showRationale: Boolean, + ) -> Unit, + ) { + someGrantedContent = content + } + + @Composable + override fun NoneGranted(content: @Composable (showRationale: Boolean) -> Unit) { + notGrantedContent = content + } +}