Skip to content

Commit

Permalink
Permission handling improvement (#1028)
Browse files Browse the repository at this point in the history
  • Loading branch information
aleksandar-apostolov authored Mar 11, 2024
1 parent 7db7e80 commit 491f793
Show file tree
Hide file tree
Showing 12 changed files with 582 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion stream-video-android-core/api/stream-video-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,8 @@ public final class io/getstream/video/android/core/StreamVideoBuilder {
public fun <init> (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 <init> (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 <init> (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 <init> (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 <init> (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 <init> (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;
}
Expand Down Expand Up @@ -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 <init> (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 <init> (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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,19 @@ public class Call(
ring: Boolean = false,
notify: Boolean = false,
): Result<RtcSession> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -166,6 +170,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(
runForegroundService = runForegroundServiceForCalls,
testSfuAddress = localSfuAddress,
sounds = sounds,
permissionCheck = permissionCheck,
)

if (user.type == UserType.Guest) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Notification?, Int> =
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<Notification?, Int> = 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(),
Expand All @@ -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!!)

Expand All @@ -256,8 +272,8 @@ internal class CallService : Service() {
}
observeCallState(callId!!, streamVideo)
registerToggleCameraBroadcastReceiver()
return START_NOT_STICKY
}
return START_NOT_STICKY
}

private fun updateRingingCall(
Expand Down Expand Up @@ -285,23 +301,28 @@ internal class CallService : Service() {
stopCallSound() // Stops sound sooner than Active. More responsive.
}
}

is RingingState.Outgoing -> {
if (!it.acceptedByCallee) {
playCallSound(streamVideo.sounds.outgoingCallSound)
} else {
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
}
Expand Down Expand Up @@ -424,6 +445,7 @@ internal class CallService : Service() {
// Optionally (no-op if already stopping)
stopSelf()
}

private fun registerToggleCameraBroadcastReceiver() {
if (!isToggleCameraBroadcastReceiverRegistered) {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 491f793

Please sign in to comment.