From 927ea98e03e4603afcb995c1ab1763ddd612156e Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Wed, 11 Sep 2024 10:39:56 +0300 Subject: [PATCH 01/18] Use device ringtone - with fallbacks --- .../api/stream-video-android-core.api | 19 +++----- .../video/android/core/StreamVideoBuilder.kt | 2 +- .../internal/service/CallService.kt | 26 +++++----- .../video/android/core/sounds/Sounds.kt | 47 ++++++++++++++++--- 4 files changed, 60 insertions(+), 34 deletions(-) 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 78171e6a4a..9c1edce460 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -4472,18 +4472,13 @@ public final class io/getstream/video/android/core/socket/SocketState$NotConnect } public final class io/getstream/video/android/core/sounds/Sounds { - public fun ()V - public fun (Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/Integer; - public final fun component2 ()Ljava/lang/Integer; - public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/video/android/core/sounds/Sounds; - public static synthetic fun copy$default (Lio/getstream/video/android/core/sounds/Sounds;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/Sounds; - public fun equals (Ljava/lang/Object;)Z - public final fun getIncomingCallSound ()Ljava/lang/Integer; - public final fun getOutgoingCallSound ()Ljava/lang/Integer; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; + public static final field Companion Lio/getstream/video/android/core/sounds/Sounds$Companion; + public static final field DEVICE_INCOMING_RINGTONE I + public fun (Landroid/content/Context;Ljava/lang/Integer;Ljava/lang/Integer;)V + public synthetic fun (Landroid/content/Context;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/getstream/video/android/core/sounds/Sounds$Companion { } public final class io/getstream/video/android/core/utils/AndroidUtilsKt { 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 180dc5ccb0..34394ae278 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 @@ -99,7 +99,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val runForegroundServiceForCalls: Boolean = true, private val callServiceConfig: CallServiceConfig? = null, private val localSfuAddress: String? = null, - private val sounds: Sounds = Sounds(), + private val sounds: Sounds = Sounds(context), private val crashOnMissingPermission: Boolean = false, private val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), private val audioUsage: Int = defaultAudioUsage, 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 9beb88a79f..ac967ea7fd 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 @@ -25,8 +25,8 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.ServiceInfo import android.media.MediaPlayer +import android.net.Uri import android.os.IBinder -import androidx.annotation.RawRes import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import io.getstream.log.StreamLog @@ -424,7 +424,7 @@ internal open class CallService : Service() { when (it) { is RingingState.Incoming -> { if (!it.acceptedByMe) { - playCallSound(streamVideo.sounds.incomingCallSound) + playCallSound(streamVideo.sounds.incomingCallSoundUri, mediaPlayer) } else { stopCallSound() // Stops sound sooner than Active. More responsive. } @@ -432,7 +432,7 @@ internal open class CallService : Service() { is RingingState.Outgoing -> { if (!it.acceptedByCallee) { - playCallSound(streamVideo.sounds.outgoingCallSound) + playCallSound(streamVideo.sounds.outgoingCallSoundUri, mediaPlayer) } else { stopCallSound() // Stops sound sooner than Active. More responsive. } @@ -490,13 +490,13 @@ internal open class CallService : Service() { } } - private fun playCallSound(@RawRes sound: Int?) { - sound?.let { + private fun playCallSound(soundUri: Uri?, mediaPlayer: MediaPlayer?) { + if (soundUri != null && mediaPlayer != null) { try { - mediaPlayer?.let { - if (!it.isPlaying) { - setMediaPlayerDataSource(it, sound) - it.start() + with(mediaPlayer) { + if (!isPlaying) { + setMediaPlayerDataSource(this, soundUri) + start() } } } catch (e: IllegalStateException) { @@ -505,13 +505,9 @@ internal open class CallService : Service() { } } - private fun setMediaPlayerDataSource(mediaPlayer: MediaPlayer, @RawRes resId: Int) { + private fun setMediaPlayerDataSource(mediaPlayer: MediaPlayer, uri: Uri) { mediaPlayer.reset() - val afd = resources.openRawResourceFd(resId) - if (afd != null) { - mediaPlayer.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) - afd.close() - } + mediaPlayer.setDataSource(applicationContext, uri) mediaPlayer.isLooping = true mediaPlayer.prepare() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt index ececedd5a9..3417750424 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt @@ -16,16 +16,51 @@ package io.getstream.video.android.core.sounds +import android.content.Context +import android.media.RingtoneManager +import android.net.Uri import androidx.annotation.RawRes +import io.getstream.log.taggedLogger import io.getstream.video.android.core.R /** * Contains all the sounds that the SDK uses. * - * @param incomingCallSound Resource used as a ringtone for incoming calls. - * @param outgoingCallSound Resource used as a ringing tone for outgoing calls. + * @param incomingCallSoundResId Resource to be used as a ringtone for incoming calls. Set to [DEVICE_INCOMING_RINGTONE] to use the device ringtone. + * @param outgoingCallSoundResId Resource to be used as a ringing tone for outgoing calls. */ -data class Sounds( - @RawRes val incomingCallSound: Int? = R.raw.call_incoming_sound, - @RawRes val outgoingCallSound: Int? = R.raw.call_outgoing_sound, -) +class Sounds( + private val context: Context, + @RawRes private val incomingCallSoundResId: Int? = DEVICE_INCOMING_RINGTONE, + @RawRes private val outgoingCallSoundResId: Int? = R.raw.call_outgoing_sound, +) { + private val logger by taggedLogger("StreamVideo:Sounds") + + internal val incomingCallSoundUri: Uri? + get() = if (incomingCallSoundResId == DEVICE_INCOMING_RINGTONE) { + RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE) ?: run { + logger.w { "Device ringtone was null. Falling back to default incoming call sound." } + parseSoundUri(R.raw.call_incoming_sound) + } + } else { + parseSoundUri(incomingCallSoundResId) + } + + internal val outgoingCallSoundUri: Uri? + get() = if (outgoingCallSoundResId == DEVICE_INCOMING_RINGTONE) { + logger.w { + "Cannot assign DEVICE_INCOMING_RINGTONE to Sounds#outgoingCallSoundResId. Falling back to default outgoing call sound." + } + parseSoundUri(R.raw.call_outgoing_sound) + } else { + parseSoundUri(outgoingCallSoundResId) + } + + private fun parseSoundUri(@RawRes soundResId: Int?) = soundResId?.let { + Uri.parse("android.resource://${context.packageName}/$soundResId") + } + + companion object { + @RawRes const val DEVICE_INCOMING_RINGTONE = -1 + } +} From 5d8ef1ee393369607565974273c6986a4552c743 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:45:45 +0300 Subject: [PATCH 02/18] Update sound related docs --- .../05-ui-cookbook/05-incoming-and-outgoing-call.mdx | 5 +---- docusaurus/docs/Android/06-advanced/01-ringing.mdx | 10 ++++++++++ .../io/getstream/video/android/core/sounds/Sounds.kt | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docusaurus/docs/Android/05-ui-cookbook/05-incoming-and-outgoing-call.mdx b/docusaurus/docs/Android/05-ui-cookbook/05-incoming-and-outgoing-call.mdx index e405a5d0d8..58b13cdb66 100644 --- a/docusaurus/docs/Android/05-ui-cookbook/05-incoming-and-outgoing-call.mdx +++ b/docusaurus/docs/Android/05-ui-cookbook/05-incoming-and-outgoing-call.mdx @@ -87,8 +87,5 @@ fun MyRingingCallScreen() { So you'll be able to render your own composable or navigate to a different screen depending on the call state. ## Sounds -The SDK plays default sounds for incoming and outgoing calls. You can customize the sounds by passing your own instance of the `Sounds` class to `StreamVideoBuilder`. -`Sounds` has two properties, `incomingCallSound` and `outgoingCallSound`. You need to assign raw resource identifiers to these properties. These identifiers correspond to audio files in your project's `res/raw` directory. - -To disable sounds, pass `null` to `incomingCallSound` or `outgoingCallSound`. \ No newline at end of file +The SDK plays sounds for incoming and outgoing calls. Read [here](../06-advanced/01-ringing.mdx#sounds) for more details. \ No newline at end of file diff --git a/docusaurus/docs/Android/06-advanced/01-ringing.mdx b/docusaurus/docs/Android/06-advanced/01-ringing.mdx index 26d86eae60..0f34e9682c 100644 --- a/docusaurus/docs/Android/06-advanced/01-ringing.mdx +++ b/docusaurus/docs/Android/06-advanced/01-ringing.mdx @@ -116,3 +116,13 @@ For this you need to leave the call by using: call.leave() ``` +### Sounds + +The SDK plays sounds for incoming and outgoing calls. You can customize these sounds by passing your own instance of the `Sounds` class to the `StreamVideoBuilder` `sounds` constructor parameter. + +`Sounds` has two resource properties, `incomingCallSoundResId` and `outgoingCallSoundResId`. These properties can have the following values: +- By default, their values are the device ringtone for incoming and a default ringing tone provided by the SDK for outgoing. +- You can pass raw resource identifiers that correspond to audio files in your project's `res/raw` directory. +- To disable (mute) one of the sounds, pass `null` to the corresponding property. +- You can pass the `Sounds.DEVICE_INCOMING_RINGTONE` special value to `incomingCallSoundResId` to use the device ringtone. +- Special cases: if the device ringtone is evaluated to `null` or the outgoing sound is set to `DEVICE_INCOMING_RINGTONE`, a corresponding default sound will be used. \ No newline at end of file diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt index 3417750424..9e812e0f72 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt @@ -26,6 +26,7 @@ import io.getstream.video.android.core.R /** * Contains all the sounds that the SDK uses. * + * @param context Context to be used for retrieving the sounds. * @param incomingCallSoundResId Resource to be used as a ringtone for incoming calls. Set to [DEVICE_INCOMING_RINGTONE] to use the device ringtone. * @param outgoingCallSoundResId Resource to be used as a ringing tone for outgoing calls. */ From ebd96373a8d169f4e410b54a6867b630aa16f219 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Tue, 17 Sep 2024 13:56:54 +0300 Subject: [PATCH 03/18] Revert changes to Sounds and StreamVideoBuilder --- .../video/android/core/StreamVideoBuilder.kt | 2 +- .../video/android/core/sounds/Sounds.kt | 48 +++---------------- 2 files changed, 7 insertions(+), 43 deletions(-) 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 34394ae278..180dc5ccb0 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 @@ -99,7 +99,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val runForegroundServiceForCalls: Boolean = true, private val callServiceConfig: CallServiceConfig? = null, private val localSfuAddress: String? = null, - private val sounds: Sounds = Sounds(context), + private val sounds: Sounds = Sounds(), private val crashOnMissingPermission: Boolean = false, private val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), private val audioUsage: Int = defaultAudioUsage, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt index 9e812e0f72..ececedd5a9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt @@ -16,52 +16,16 @@ package io.getstream.video.android.core.sounds -import android.content.Context -import android.media.RingtoneManager -import android.net.Uri import androidx.annotation.RawRes -import io.getstream.log.taggedLogger import io.getstream.video.android.core.R /** * Contains all the sounds that the SDK uses. * - * @param context Context to be used for retrieving the sounds. - * @param incomingCallSoundResId Resource to be used as a ringtone for incoming calls. Set to [DEVICE_INCOMING_RINGTONE] to use the device ringtone. - * @param outgoingCallSoundResId Resource to be used as a ringing tone for outgoing calls. + * @param incomingCallSound Resource used as a ringtone for incoming calls. + * @param outgoingCallSound Resource used as a ringing tone for outgoing calls. */ -class Sounds( - private val context: Context, - @RawRes private val incomingCallSoundResId: Int? = DEVICE_INCOMING_RINGTONE, - @RawRes private val outgoingCallSoundResId: Int? = R.raw.call_outgoing_sound, -) { - private val logger by taggedLogger("StreamVideo:Sounds") - - internal val incomingCallSoundUri: Uri? - get() = if (incomingCallSoundResId == DEVICE_INCOMING_RINGTONE) { - RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE) ?: run { - logger.w { "Device ringtone was null. Falling back to default incoming call sound." } - parseSoundUri(R.raw.call_incoming_sound) - } - } else { - parseSoundUri(incomingCallSoundResId) - } - - internal val outgoingCallSoundUri: Uri? - get() = if (outgoingCallSoundResId == DEVICE_INCOMING_RINGTONE) { - logger.w { - "Cannot assign DEVICE_INCOMING_RINGTONE to Sounds#outgoingCallSoundResId. Falling back to default outgoing call sound." - } - parseSoundUri(R.raw.call_outgoing_sound) - } else { - parseSoundUri(outgoingCallSoundResId) - } - - private fun parseSoundUri(@RawRes soundResId: Int?) = soundResId?.let { - Uri.parse("android.resource://${context.packageName}/$soundResId") - } - - companion object { - @RawRes const val DEVICE_INCOMING_RINGTONE = -1 - } -} +data class Sounds( + @RawRes val incomingCallSound: Int? = R.raw.call_incoming_sound, + @RawRes val outgoingCallSound: Int? = R.raw.call_outgoing_sound, +) From 67d2d2a9df2aec2b6ca30b8e1aa89840802796de Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:25:03 +0300 Subject: [PATCH 04/18] Add SoundConfig interface --- .../api/stream-video-android-core.api | 65 +++++++++- .../internal/service/CallService.kt | 10 +- .../video/android/core/sounds/SoundConfig.kt | 117 ++++++++++++++++++ .../video/android/core/sounds/Sounds.kt | 50 +++++++- 4 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt 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 9c1edce460..f1286c1c7c 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -4471,14 +4471,69 @@ public final class io/getstream/video/android/core/socket/SocketState$NotConnect public fun toString ()Ljava/lang/String; } -public final class io/getstream/video/android/core/sounds/Sounds { - public static final field Companion Lio/getstream/video/android/core/sounds/Sounds$Companion; - public static final field DEVICE_INCOMING_RINGTONE I +public class io/getstream/video/android/core/sounds/DeviceRingtoneSoundConfig : io/getstream/video/android/core/sounds/StreamResSoundConfig { + public fun (Landroid/content/Context;)V + public final fun getContext ()Landroid/content/Context; + public fun getIncomingCallSoundUri ()Landroid/net/Uri; +} + +public class io/getstream/video/android/core/sounds/MutedSoundConfig : io/getstream/video/android/core/sounds/SoundConfig { + public fun ()V + public fun getIncomingCallSoundUri ()Landroid/net/Uri; + public fun getOutgoingCallSoundUri ()Landroid/net/Uri; +} + +public class io/getstream/video/android/core/sounds/ResSoundConfig : io/getstream/video/android/core/sounds/SoundConfig { public fun (Landroid/content/Context;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (Landroid/content/Context;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getIncomingCallSoundUri ()Landroid/net/Uri; + public fun getOutgoingCallSoundUri ()Landroid/net/Uri; + protected final fun parseSoundUri (Landroid/content/Context;Ljava/lang/Integer;)Landroid/net/Uri; } -public final class io/getstream/video/android/core/sounds/Sounds$Companion { +public abstract interface class io/getstream/video/android/core/sounds/SoundConfig { + public abstract fun getIncomingCallSoundUri ()Landroid/net/Uri; + public abstract fun getOutgoingCallSoundUri ()Landroid/net/Uri; +} + +public final class io/getstream/video/android/core/sounds/SoundConfigKt { + public static final fun deviceRingtoneSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; + public static final fun mutedSoundConfig ()Lio/getstream/video/android/core/sounds/SoundConfig; + public static final fun streamResourcesSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; +} + +public final class io/getstream/video/android/core/sounds/Sounds { + public fun ()V + public fun (Lio/getstream/video/android/core/sounds/SoundConfig;)V + public fun (Ljava/lang/Integer;Ljava/lang/Integer;)V + public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/Integer; + public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/video/android/core/sounds/Sounds; + public static synthetic fun copy$default (Lio/getstream/video/android/core/sounds/Sounds;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/Sounds; + public fun equals (Ljava/lang/Object;)Z + public final fun getIncomingCallSound ()Ljava/lang/Integer; + public final fun getOutgoingCallSound ()Ljava/lang/Integer; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public class io/getstream/video/android/core/sounds/StreamResSoundConfig : io/getstream/video/android/core/sounds/ResSoundConfig { + public fun (Landroid/content/Context;)V +} + +public final class io/getstream/video/android/core/sounds/UriSoundConfig : io/getstream/video/android/core/sounds/SoundConfig { + public fun (Landroid/net/Uri;Landroid/net/Uri;)V + public final fun component1 ()Landroid/net/Uri; + public final fun component2 ()Landroid/net/Uri; + public final fun copy (Landroid/net/Uri;Landroid/net/Uri;)Lio/getstream/video/android/core/sounds/UriSoundConfig; + public static synthetic fun copy$default (Lio/getstream/video/android/core/sounds/UriSoundConfig;Landroid/net/Uri;Landroid/net/Uri;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/UriSoundConfig; + public fun equals (Ljava/lang/Object;)Z + public fun getIncomingCallSoundUri ()Landroid/net/Uri; + public final fun getIncomingCallSoundUriValue ()Landroid/net/Uri; + public fun getOutgoingCallSoundUri ()Landroid/net/Uri; + public final fun getOutgoingCallSoundUriValue ()Landroid/net/Uri; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class io/getstream/video/android/core/utils/AndroidUtilsKt { 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 ac967ea7fd..15a6d0996f 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 @@ -424,7 +424,10 @@ internal open class CallService : Service() { when (it) { is RingingState.Incoming -> { if (!it.acceptedByMe) { - playCallSound(streamVideo.sounds.incomingCallSoundUri, mediaPlayer) + playCallSound( + streamVideo.sounds.getIncomingCallSoundUri(applicationContext), + mediaPlayer, + ) } else { stopCallSound() // Stops sound sooner than Active. More responsive. } @@ -432,7 +435,10 @@ internal open class CallService : Service() { is RingingState.Outgoing -> { if (!it.acceptedByCallee) { - playCallSound(streamVideo.sounds.outgoingCallSoundUri, mediaPlayer) + playCallSound( + streamVideo.sounds.getOutgoingCallSoundUri(applicationContext), + mediaPlayer, + ) } else { stopCallSound() // Stops sound sooner than Active. More responsive. } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt new file mode 100644 index 0000000000..d8c8eb7007 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt @@ -0,0 +1,117 @@ +/* + * 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.sounds + +import android.content.Context +import android.media.RingtoneManager +import android.net.Uri +import androidx.annotation.RawRes +import io.getstream.video.android.core.R + +/** + * Returns a sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. + * + * @param context Context used for retrieving the sounds. + */ +fun deviceRingtoneSoundConfig(context: Context): SoundConfig = DeviceRingtoneSoundConfig(context) + +/** + * Sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. + */ +open class DeviceRingtoneSoundConfig(val context: Context) : StreamResSoundConfig(context) { + override val incomingCallSoundUri: Uri? + get() = RingtoneManager.getActualDefaultRingtoneUri( + context, + RingtoneManager.TYPE_RINGTONE, + ) ?: super.incomingCallSoundUri +} + +/** + * Returns a sound config that uses the SDK default sounds for incoming and outgoing calls. + * + * @param context Context used for retrieving the sounds. + */ +fun streamResourcesSoundConfig( + context: Context, +): SoundConfig = StreamResSoundConfig(context = context) + +/** + * Sound config that uses the SDK default sound resources for incoming and outgoing calls. + */ +open class StreamResSoundConfig(context: Context) : ResSoundConfig( + context = context, + incomingCallSoundResId = R.raw.call_incoming_sound, + outgoingCallSoundResId = R.raw.call_outgoing_sound, +) + +/** + * Returns a sound config that mutes (disables) all sounds. + */ +fun mutedSoundConfig(): SoundConfig = MutedSoundConfig() + +/** + * Sound config that mutes (disables) all sounds. + */ +open class MutedSoundConfig : SoundConfig { + override val incomingCallSoundUri: Uri? = null + override val outgoingCallSoundUri: Uri? = null +} + +/** + * A class that represents a sound config that uses raw resources to specify the sounds. + */ +open class ResSoundConfig( + context: Context, + @RawRes incomingCallSoundResId: Int?, + @RawRes outgoingCallSoundResId: Int?, +) : SoundConfig { + + override val incomingCallSoundUri: Uri? = parseSoundUri(context, incomingCallSoundResId) + override val outgoingCallSoundUri: Uri? = parseSoundUri(context, outgoingCallSoundResId) + + protected fun parseSoundUri(context: Context, soundResId: Int?): Uri? = soundResId?.let { + Uri.parse("android.resource://${context.packageName}/$soundResId") + } +} + +/** + * A class that represents a sound config that uses URIs to specify the sounds. + */ +data class UriSoundConfig( + val incomingCallSoundUriValue: Uri?, + val outgoingCallSoundUriValue: Uri?, +) : SoundConfig { + + override val incomingCallSoundUri: Uri? = incomingCallSoundUriValue + override val outgoingCallSoundUri: Uri? = outgoingCallSoundUriValue +} + +/** + * Generic sound configuration. + * + * @see deviceRingtoneSoundConfig + * @see streamResourcesSoundConfig + * @see mutedSoundConfig + * @see DeviceRingtoneSoundConfig + * @see StreamResSoundConfig + * @see ResSoundConfig + * @see UriSoundConfig + */ +interface SoundConfig { + val incomingCallSoundUri: Uri? + val outgoingCallSoundUri: Uri? +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt index ececedd5a9..ddac951cf0 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt @@ -16,8 +16,11 @@ package io.getstream.video.android.core.sounds +import android.content.Context +import android.net.Uri import androidx.annotation.RawRes import io.getstream.video.android.core.R +import org.jetbrains.annotations.ApiStatus /** * Contains all the sounds that the SDK uses. @@ -25,7 +28,50 @@ import io.getstream.video.android.core.R * @param incomingCallSound Resource used as a ringtone for incoming calls. * @param outgoingCallSound Resource used as a ringing tone for outgoing calls. */ -data class Sounds( +data class Sounds +@Deprecated( + message = "Deprecated. Use constructor with SoundConfig parameter instead.", + replaceWith = ReplaceWith("Sounds(soundConfig: SoundConfig)"), + level = DeprecationLevel.WARNING, +) +@ApiStatus.ScheduledForRemoval(inVersion = "1.0.16") +constructor( @RawRes val incomingCallSound: Int? = R.raw.call_incoming_sound, @RawRes val outgoingCallSound: Int? = R.raw.call_outgoing_sound, -) +) { + + private var soundConfig: SoundConfig? = null + private set + + /** + * Configure sounds by passing a [SoundConfig]. + * + * @see deviceRingtoneSoundConfig + * @see streamResourcesSoundConfig + * @see mutedSoundConfig + * @see SoundConfig + */ + constructor(soundConfig: SoundConfig) : this() { + this.soundConfig = soundConfig + } + + internal fun getIncomingCallSoundUri(context: Context): Uri? = soundConfig.let { soundConfig -> + if (soundConfig != null) { + soundConfig.incomingCallSoundUri + } else { + incomingCallSound?.let { + Uri.parse("android.resource://${context.packageName}/$it") + } + } + } + + internal fun getOutgoingCallSoundUri(context: Context): Uri? = soundConfig.let { soundConfig -> + if (soundConfig != null) { + soundConfig.outgoingCallSoundUri + } else { + outgoingCallSound?.let { + Uri.parse("android.resource://${context.packageName}/$it") + } + } + } +} From 6dabab8903b18620b0f444aca526a0ccfdc8c7fa Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Tue, 17 Sep 2024 17:13:57 +0300 Subject: [PATCH 05/18] Make deviceRingtoneSoundConfig default --- .../android/util/StreamVideoInitHelper.kt | 25 +++++++++++++------ .../video/android/core/StreamVideoBuilder.kt | 3 ++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index 7d11d48e0e..c9c059f5ab 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -18,6 +18,7 @@ package io.getstream.video.android.util import android.annotation.SuppressLint import android.content.Context +import android.net.Uri import android.util.Log import io.getstream.android.push.firebase.FirebasePushDeviceGenerator import io.getstream.chat.android.client.ChatClient @@ -31,6 +32,10 @@ import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoBuilder import io.getstream.video.android.core.logging.LoggingLevel import io.getstream.video.android.core.notifications.NotificationConfig +import io.getstream.video.android.core.sounds.DeviceRingtoneSoundConfig +import io.getstream.video.android.core.sounds.SoundConfig +import io.getstream.video.android.core.sounds.Sounds +import io.getstream.video.android.core.sounds.deviceRingtoneSoundConfig import io.getstream.video.android.data.services.stream.GetAuthDataResponse import io.getstream.video.android.data.services.stream.StreamService import io.getstream.video.android.datastore.delegate.StreamUserDataStore @@ -191,13 +196,6 @@ object StreamVideoInitHelper { apiKey = apiKey, user = user, token = token, - loggingLevel = loggingLevel, - ensureSingleInstance = false, - notificationConfig = NotificationConfig( - pushDeviceGenerators = listOf( - FirebasePushDeviceGenerator(providerName = "firebase"), - ), - ), tokenProvider = { val email = user.custom?.get("email") val authData = StreamService.instance.getAuthData( @@ -206,7 +204,20 @@ object StreamVideoInitHelper { ) authData.token }, + loggingLevel = loggingLevel, + notificationConfig = NotificationConfig( + pushDeviceGenerators = listOf( + FirebasePushDeviceGenerator(providerName = "firebase"), + ), + ), + ensureSingleInstance = false, appName = "Stream Video Demo App", ).build() } + + private fun customSoundConfig(context: Context): SoundConfig { + return object : DeviceRingtoneSoundConfig(context) { + override val outgoingCallSoundUri: Uri? = null + } + } } 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 180dc5ccb0..dbcbb8985d 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 @@ -34,6 +34,7 @@ import io.getstream.video.android.core.notifications.internal.storage.DeviceToke 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.core.sounds.deviceRingtoneSoundConfig import io.getstream.video.android.model.ApiKey import io.getstream.video.android.model.User import io.getstream.video.android.model.UserToken @@ -99,7 +100,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val runForegroundServiceForCalls: Boolean = true, private val callServiceConfig: CallServiceConfig? = null, private val localSfuAddress: String? = null, - private val sounds: Sounds = Sounds(), + private val sounds: Sounds = Sounds(deviceRingtoneSoundConfig(context)), private val crashOnMissingPermission: Boolean = false, private val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), private val audioUsage: Int = defaultAudioUsage, From 15a0d229da82ea9277356f06ace74562cdc61f67 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Tue, 17 Sep 2024 17:36:01 +0300 Subject: [PATCH 06/18] Safe-call get device ringtone method --- .../video/android/core/sounds/SoundConfig.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt index d8c8eb7007..46a4ae13b5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt @@ -21,6 +21,7 @@ import android.media.RingtoneManager import android.net.Uri import androidx.annotation.RawRes import io.getstream.video.android.core.R +import io.getstream.video.android.core.utils.safeCall /** * Returns a sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. @@ -34,10 +35,12 @@ fun deviceRingtoneSoundConfig(context: Context): SoundConfig = DeviceRingtoneSou */ open class DeviceRingtoneSoundConfig(val context: Context) : StreamResSoundConfig(context) { override val incomingCallSoundUri: Uri? - get() = RingtoneManager.getActualDefaultRingtoneUri( - context, - RingtoneManager.TYPE_RINGTONE, - ) ?: super.incomingCallSoundUri + get() = safeCall(default = null) { + RingtoneManager.getActualDefaultRingtoneUri( + context, + RingtoneManager.TYPE_RINGTONE, + ) + } ?: super.incomingCallSoundUri } /** From 76e48904caf742683288f9d9acf1f97583b50d52 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Tue, 17 Sep 2024 17:57:43 +0300 Subject: [PATCH 07/18] Clean up --- .../video/android/util/StreamVideoInitHelper.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index c9c059f5ab..d7d2ed73e5 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -18,7 +18,6 @@ package io.getstream.video.android.util import android.annotation.SuppressLint import android.content.Context -import android.net.Uri import android.util.Log import io.getstream.android.push.firebase.FirebasePushDeviceGenerator import io.getstream.chat.android.client.ChatClient @@ -32,10 +31,6 @@ import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoBuilder import io.getstream.video.android.core.logging.LoggingLevel import io.getstream.video.android.core.notifications.NotificationConfig -import io.getstream.video.android.core.sounds.DeviceRingtoneSoundConfig -import io.getstream.video.android.core.sounds.SoundConfig -import io.getstream.video.android.core.sounds.Sounds -import io.getstream.video.android.core.sounds.deviceRingtoneSoundConfig import io.getstream.video.android.data.services.stream.GetAuthDataResponse import io.getstream.video.android.data.services.stream.StreamService import io.getstream.video.android.datastore.delegate.StreamUserDataStore @@ -214,10 +209,4 @@ object StreamVideoInitHelper { appName = "Stream Video Demo App", ).build() } - - private fun customSoundConfig(context: Context): SoundConfig { - return object : DeviceRingtoneSoundConfig(context) { - override val outgoingCallSoundUri: Uri? = null - } - } } From 1bebed906ba8a5fc80484f0dcace3c7e77110348 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:59:49 +0300 Subject: [PATCH 08/18] Shorten list of public sound configs --- .../video/android/core/sounds/SoundConfig.kt | 73 ++++++++----------- 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt index 46a4ae13b5..bb48b3412d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt @@ -24,23 +24,43 @@ import io.getstream.video.android.core.R import io.getstream.video.android.core.utils.safeCall /** - * Returns a sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. + * Base sound configuration. * - * @param context Context used for retrieving the sounds. + * @see deviceRingtoneSoundConfig + * @see streamResourcesSoundConfig + * @see mutedSoundConfig + * @see ResSoundConfig + * @see UriSoundConfig */ -fun deviceRingtoneSoundConfig(context: Context): SoundConfig = DeviceRingtoneSoundConfig(context) +interface SoundConfig { + + val incomingCallSoundUri: Uri? + val outgoingCallSoundUri: Uri? + + fun parseSoundUri(context: Context, soundResId: Int?): Uri? = soundResId?.let { + Uri.parse("android.resource://${context.packageName}/$soundResId") + } +} + /** - * Sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. + * Returns a sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. + * + * @param context Context used for retrieving the sounds. */ -open class DeviceRingtoneSoundConfig(val context: Context) : StreamResSoundConfig(context) { +fun deviceRingtoneSoundConfig(context: Context): SoundConfig = object : SoundConfig { + + private val streamResSoundConfig = streamResourcesSoundConfig(context) + override val incomingCallSoundUri: Uri? get() = safeCall(default = null) { RingtoneManager.getActualDefaultRingtoneUri( context, RingtoneManager.TYPE_RINGTONE, ) - } ?: super.incomingCallSoundUri + } ?: streamResSoundConfig.incomingCallSoundUri + + override val outgoingCallSoundUri: Uri? = streamResSoundConfig.outgoingCallSoundUri } /** @@ -50,26 +70,17 @@ open class DeviceRingtoneSoundConfig(val context: Context) : StreamResSoundConfi */ fun streamResourcesSoundConfig( context: Context, -): SoundConfig = StreamResSoundConfig(context = context) +): SoundConfig = object : SoundConfig { -/** - * Sound config that uses the SDK default sound resources for incoming and outgoing calls. - */ -open class StreamResSoundConfig(context: Context) : ResSoundConfig( - context = context, - incomingCallSoundResId = R.raw.call_incoming_sound, - outgoingCallSoundResId = R.raw.call_outgoing_sound, -) + override val incomingCallSoundUri: Uri? = parseSoundUri(context, R.raw.call_incoming_sound) + override val outgoingCallSoundUri: Uri? = parseSoundUri(context, R.raw.call_outgoing_sound) +} /** * Returns a sound config that mutes (disables) all sounds. */ -fun mutedSoundConfig(): SoundConfig = MutedSoundConfig() +fun mutedSoundConfig(): SoundConfig = object : SoundConfig { -/** - * Sound config that mutes (disables) all sounds. - */ -open class MutedSoundConfig : SoundConfig { override val incomingCallSoundUri: Uri? = null override val outgoingCallSoundUri: Uri? = null } @@ -77,7 +88,7 @@ open class MutedSoundConfig : SoundConfig { /** * A class that represents a sound config that uses raw resources to specify the sounds. */ -open class ResSoundConfig( +class ResSoundConfig( context: Context, @RawRes incomingCallSoundResId: Int?, @RawRes outgoingCallSoundResId: Int?, @@ -85,10 +96,6 @@ open class ResSoundConfig( override val incomingCallSoundUri: Uri? = parseSoundUri(context, incomingCallSoundResId) override val outgoingCallSoundUri: Uri? = parseSoundUri(context, outgoingCallSoundResId) - - protected fun parseSoundUri(context: Context, soundResId: Int?): Uri? = soundResId?.let { - Uri.parse("android.resource://${context.packageName}/$soundResId") - } } /** @@ -102,19 +109,3 @@ data class UriSoundConfig( override val incomingCallSoundUri: Uri? = incomingCallSoundUriValue override val outgoingCallSoundUri: Uri? = outgoingCallSoundUriValue } - -/** - * Generic sound configuration. - * - * @see deviceRingtoneSoundConfig - * @see streamResourcesSoundConfig - * @see mutedSoundConfig - * @see DeviceRingtoneSoundConfig - * @see StreamResSoundConfig - * @see ResSoundConfig - * @see UriSoundConfig - */ -interface SoundConfig { - val incomingCallSoundUri: Uri? - val outgoingCallSoundUri: Uri? -} From 5ebb707a62e7b44d9dac5e16a75d303e2ce902ec Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:07:46 +0300 Subject: [PATCH 09/18] Refactor SoundConfig factory methods --- .../api/stream-video-android-core.api | 50 +----- .../video/android/core/StreamVideoBuilder.kt | 4 +- .../video/android/core/sounds/SoundConfig.kt | 169 ++++++++++-------- .../video/android/core/sounds/Sounds.kt | 7 +- 4 files changed, 107 insertions(+), 123 deletions(-) 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 f1286c1c7c..0a2d929a21 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -4471,34 +4471,19 @@ public final class io/getstream/video/android/core/socket/SocketState$NotConnect public fun toString ()Ljava/lang/String; } -public class io/getstream/video/android/core/sounds/DeviceRingtoneSoundConfig : io/getstream/video/android/core/sounds/StreamResSoundConfig { - public fun (Landroid/content/Context;)V - public final fun getContext ()Landroid/content/Context; - public fun getIncomingCallSoundUri ()Landroid/net/Uri; -} - -public class io/getstream/video/android/core/sounds/MutedSoundConfig : io/getstream/video/android/core/sounds/SoundConfig { - public fun ()V - public fun getIncomingCallSoundUri ()Landroid/net/Uri; - public fun getOutgoingCallSoundUri ()Landroid/net/Uri; -} - -public class io/getstream/video/android/core/sounds/ResSoundConfig : io/getstream/video/android/core/sounds/SoundConfig { - public fun (Landroid/content/Context;Ljava/lang/Integer;Ljava/lang/Integer;)V - public fun getIncomingCallSoundUri ()Landroid/net/Uri; - public fun getOutgoingCallSoundUri ()Landroid/net/Uri; - protected final fun parseSoundUri (Landroid/content/Context;Ljava/lang/Integer;)Landroid/net/Uri; -} - public abstract interface class io/getstream/video/android/core/sounds/SoundConfig { + public static final field Companion Lio/getstream/video/android/core/sounds/SoundConfig$Companion; public abstract fun getIncomingCallSoundUri ()Landroid/net/Uri; public abstract fun getOutgoingCallSoundUri ()Landroid/net/Uri; } -public final class io/getstream/video/android/core/sounds/SoundConfigKt { - public static final fun deviceRingtoneSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; - public static final fun mutedSoundConfig ()Lio/getstream/video/android/core/sounds/SoundConfig; - public static final fun streamResourcesSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; +public final class io/getstream/video/android/core/sounds/SoundConfig$Companion { + public final fun createCustomSoundConfig (Ljava/lang/Object;Ljava/lang/Object;Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; + public static synthetic fun createCustomSoundConfig$default (Lio/getstream/video/android/core/sounds/SoundConfig$Companion;Ljava/lang/Object;Ljava/lang/Object;Landroid/content/Context;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/SoundConfig; + public final fun createDeviceRingtoneSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; + public final fun createEmptySoundConfig ()Lio/getstream/video/android/core/sounds/SoundConfig; + public final fun createStreamResourcesSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; + public final fun getSoundUriFromRes (Landroid/content/Context;Ljava/lang/Integer;)Landroid/net/Uri; } public final class io/getstream/video/android/core/sounds/Sounds { @@ -4517,25 +4502,6 @@ public final class io/getstream/video/android/core/sounds/Sounds { public fun toString ()Ljava/lang/String; } -public class io/getstream/video/android/core/sounds/StreamResSoundConfig : io/getstream/video/android/core/sounds/ResSoundConfig { - public fun (Landroid/content/Context;)V -} - -public final class io/getstream/video/android/core/sounds/UriSoundConfig : io/getstream/video/android/core/sounds/SoundConfig { - public fun (Landroid/net/Uri;Landroid/net/Uri;)V - public final fun component1 ()Landroid/net/Uri; - public final fun component2 ()Landroid/net/Uri; - public final fun copy (Landroid/net/Uri;Landroid/net/Uri;)Lio/getstream/video/android/core/sounds/UriSoundConfig; - public static synthetic fun copy$default (Lio/getstream/video/android/core/sounds/UriSoundConfig;Landroid/net/Uri;Landroid/net/Uri;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/UriSoundConfig; - public fun equals (Ljava/lang/Object;)Z - public fun getIncomingCallSoundUri ()Landroid/net/Uri; - public final fun getIncomingCallSoundUriValue ()Landroid/net/Uri; - public fun getOutgoingCallSoundUri ()Landroid/net/Uri; - public final fun getOutgoingCallSoundUriValue ()Landroid/net/Uri; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class io/getstream/video/android/core/utils/AndroidUtilsKt { public static final fun safeCall (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public static final fun shouldShowRequestPermissionsRationale (Landroid/app/Activity;[Ljava/lang/String;)Z 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 dbcbb8985d..51db557c96 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 @@ -33,8 +33,8 @@ import io.getstream.video.android.core.notifications.internal.service.callServic 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.SoundConfig import io.getstream.video.android.core.sounds.Sounds -import io.getstream.video.android.core.sounds.deviceRingtoneSoundConfig import io.getstream.video.android.model.ApiKey import io.getstream.video.android.model.User import io.getstream.video.android.model.UserToken @@ -100,7 +100,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val runForegroundServiceForCalls: Boolean = true, private val callServiceConfig: CallServiceConfig? = null, private val localSfuAddress: String? = null, - private val sounds: Sounds = Sounds(deviceRingtoneSoundConfig(context)), + private val sounds: Sounds = Sounds(SoundConfig.createDeviceRingtoneSoundConfig(context)), private val crashOnMissingPermission: Boolean = false, private val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), private val audioUsage: Int = defaultAudioUsage, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt index bb48b3412d..62df1c7734 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt @@ -19,93 +19,110 @@ package io.getstream.video.android.core.sounds import android.content.Context import android.media.RingtoneManager import android.net.Uri -import androidx.annotation.RawRes import io.getstream.video.android.core.R import io.getstream.video.android.core.utils.safeCall /** * Base sound configuration. * - * @see deviceRingtoneSoundConfig - * @see streamResourcesSoundConfig - * @see mutedSoundConfig - * @see ResSoundConfig - * @see UriSoundConfig + * @see createDeviceRingtoneSoundConfig + * @see createStreamResourcesSoundConfig + * @see createEmptySoundConfig + * @see createCustomSoundConfig */ interface SoundConfig { val incomingCallSoundUri: Uri? val outgoingCallSoundUri: Uri? - fun parseSoundUri(context: Context, soundResId: Int?): Uri? = soundResId?.let { - Uri.parse("android.resource://${context.packageName}/$soundResId") + companion object { + + /** + * Returns a sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. + * + * @param context Context used for retrieving the sounds. + */ + fun createDeviceRingtoneSoundConfig(context: Context): SoundConfig = object : SoundConfig { + + private val streamResSoundConfig = createStreamResourcesSoundConfig(context) + + override val incomingCallSoundUri: Uri? + get() = safeCall(default = null) { + RingtoneManager.getActualDefaultRingtoneUri( + context, + RingtoneManager.TYPE_RINGTONE, + ) + } ?: streamResSoundConfig.incomingCallSoundUri + + override val outgoingCallSoundUri: Uri? = streamResSoundConfig.outgoingCallSoundUri + } + + /** + * Returns a sound config that uses the SDK default sounds for incoming and outgoing calls. + * + * @param context Context used for retrieving the sounds. + */ + fun createStreamResourcesSoundConfig(context: Context): SoundConfig = object : SoundConfig { + + override val incomingCallSoundUri: Uri? = + getSoundUriFromRes(context, R.raw.call_incoming_sound) + override val outgoingCallSoundUri: Uri? = + getSoundUriFromRes(context, R.raw.call_outgoing_sound) + } + + fun getSoundUriFromRes(context: Context, soundResId: Int?): Uri? = soundResId?.let { + safeCall(default = null) { + Uri.parse("android.resource://${context.packageName}/$soundResId") + } + } + + /** + * Returns a sound config that mutes (disables) all sounds. + */ + fun createEmptySoundConfig(): SoundConfig = object : SoundConfig { + + override val incomingCallSoundUri: Uri? = null + override val outgoingCallSoundUri: Uri? = null + } + + /** + * Returns a sound config that uses custom sounds for incoming and outgoing calls. + * + * @param incomingCallSound The incoming call sound. Can be a resource ID or a URI. + * @param outgoingCallSound The outgoing call sound. Can be a resource ID or a URI. + * @param context Context used for retrieving the sounds. Mandatory when one of the sound parameters is a resource ID. + * + * @return A sound config with the provided sounds. + * + * @throws IllegalArgumentException If one of the sound parameters is a resource ID and the context is not provided. + */ + fun createCustomSoundConfig( + incomingCallSound: Any?, + outgoingCallSound: Any?, + context: Context? = null, + ) = object : SoundConfig { + + override val incomingCallSoundUri: Uri? = when (incomingCallSound) { + is Uri -> incomingCallSound + is Int -> { + requireNotNull( + context, + ) { "Context is required when incomingCallSound is a resource ID." } + getSoundUriFromRes(context, incomingCallSound) + } + else -> null + } + + override val outgoingCallSoundUri: Uri? = when (outgoingCallSound) { + is Uri -> outgoingCallSound + is Int -> { + requireNotNull( + context, + ) { "Context is required when outgoingCallSound is a resource ID." } + getSoundUriFromRes(context, outgoingCallSound) + } + else -> null + } + } } } - - -/** - * Returns a sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. - * - * @param context Context used for retrieving the sounds. - */ -fun deviceRingtoneSoundConfig(context: Context): SoundConfig = object : SoundConfig { - - private val streamResSoundConfig = streamResourcesSoundConfig(context) - - override val incomingCallSoundUri: Uri? - get() = safeCall(default = null) { - RingtoneManager.getActualDefaultRingtoneUri( - context, - RingtoneManager.TYPE_RINGTONE, - ) - } ?: streamResSoundConfig.incomingCallSoundUri - - override val outgoingCallSoundUri: Uri? = streamResSoundConfig.outgoingCallSoundUri -} - -/** - * Returns a sound config that uses the SDK default sounds for incoming and outgoing calls. - * - * @param context Context used for retrieving the sounds. - */ -fun streamResourcesSoundConfig( - context: Context, -): SoundConfig = object : SoundConfig { - - override val incomingCallSoundUri: Uri? = parseSoundUri(context, R.raw.call_incoming_sound) - override val outgoingCallSoundUri: Uri? = parseSoundUri(context, R.raw.call_outgoing_sound) -} - -/** - * Returns a sound config that mutes (disables) all sounds. - */ -fun mutedSoundConfig(): SoundConfig = object : SoundConfig { - - override val incomingCallSoundUri: Uri? = null - override val outgoingCallSoundUri: Uri? = null -} - -/** - * A class that represents a sound config that uses raw resources to specify the sounds. - */ -class ResSoundConfig( - context: Context, - @RawRes incomingCallSoundResId: Int?, - @RawRes outgoingCallSoundResId: Int?, -) : SoundConfig { - - override val incomingCallSoundUri: Uri? = parseSoundUri(context, incomingCallSoundResId) - override val outgoingCallSoundUri: Uri? = parseSoundUri(context, outgoingCallSoundResId) -} - -/** - * A class that represents a sound config that uses URIs to specify the sounds. - */ -data class UriSoundConfig( - val incomingCallSoundUriValue: Uri?, - val outgoingCallSoundUriValue: Uri?, -) : SoundConfig { - - override val incomingCallSoundUri: Uri? = incomingCallSoundUriValue - override val outgoingCallSoundUri: Uri? = outgoingCallSoundUriValue -} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt index ddac951cf0..e42d26ce94 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt @@ -46,9 +46,10 @@ constructor( /** * Configure sounds by passing a [SoundConfig]. * - * @see deviceRingtoneSoundConfig - * @see streamResourcesSoundConfig - * @see mutedSoundConfig + * @see SoundConfig.createDeviceRingtoneSoundConfig + * @see SoundConfig.createStreamResourcesSoundConfig + * @see SoundConfig.createEmptySoundConfig + * @see SoundConfig.createCustomSoundConfig * @see SoundConfig */ constructor(soundConfig: SoundConfig) : this() { From c4ca820b2072d80aa3a7f5e9299aa8b54b2ceef3 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:39:04 +0300 Subject: [PATCH 10/18] Update removal version for deprecated Sounds ctor --- .../kotlin/io/getstream/video/android/core/sounds/Sounds.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt index e42d26ce94..13c38897b3 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt @@ -34,7 +34,7 @@ data class Sounds replaceWith = ReplaceWith("Sounds(soundConfig: SoundConfig)"), level = DeprecationLevel.WARNING, ) -@ApiStatus.ScheduledForRemoval(inVersion = "1.0.16") +@ApiStatus.ScheduledForRemoval(inVersion = "1.0.18") constructor( @RawRes val incomingCallSound: Int? = R.raw.call_incoming_sound, @RawRes val outgoingCallSound: Int? = R.raw.call_outgoing_sound, From 9e0ed948b08914b052f5cf8dc999af34118782a9 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:39:12 +0300 Subject: [PATCH 11/18] Update docs & clean-up --- .../android/util/StreamVideoInitHelper.kt | 14 ++-- .../docs/Android/06-advanced/01-ringing.mdx | 70 +++++++++++++++++-- .../video/android/core/sounds/SoundConfig.kt | 7 +- .../video/android/core/sounds/Sounds.kt | 9 +-- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index d7d2ed73e5..7d11d48e0e 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -191,6 +191,13 @@ object StreamVideoInitHelper { apiKey = apiKey, user = user, token = token, + loggingLevel = loggingLevel, + ensureSingleInstance = false, + notificationConfig = NotificationConfig( + pushDeviceGenerators = listOf( + FirebasePushDeviceGenerator(providerName = "firebase"), + ), + ), tokenProvider = { val email = user.custom?.get("email") val authData = StreamService.instance.getAuthData( @@ -199,13 +206,6 @@ object StreamVideoInitHelper { ) authData.token }, - loggingLevel = loggingLevel, - notificationConfig = NotificationConfig( - pushDeviceGenerators = listOf( - FirebasePushDeviceGenerator(providerName = "firebase"), - ), - ), - ensureSingleInstance = false, appName = "Stream Video Demo App", ).build() } diff --git a/docusaurus/docs/Android/06-advanced/01-ringing.mdx b/docusaurus/docs/Android/06-advanced/01-ringing.mdx index 0f34e9682c..eb8d079792 100644 --- a/docusaurus/docs/Android/06-advanced/01-ringing.mdx +++ b/docusaurus/docs/Android/06-advanced/01-ringing.mdx @@ -120,9 +120,67 @@ call.leave() The SDK plays sounds for incoming and outgoing calls. You can customize these sounds by passing your own instance of the `Sounds` class to the `StreamVideoBuilder` `sounds` constructor parameter. -`Sounds` has two resource properties, `incomingCallSoundResId` and `outgoingCallSoundResId`. These properties can have the following values: -- By default, their values are the device ringtone for incoming and a default ringing tone provided by the SDK for outgoing. -- You can pass raw resource identifiers that correspond to audio files in your project's `res/raw` directory. -- To disable (mute) one of the sounds, pass `null` to the corresponding property. -- You can pass the `Sounds.DEVICE_INCOMING_RINGTONE` special value to `incomingCallSoundResId` to use the device ringtone. -- Special cases: if the device ringtone is evaluated to `null` or the outgoing sound is set to `DEVICE_INCOMING_RINGTONE`, a corresponding default sound will be used. \ No newline at end of file +#### Deprecated method for customizing sounds + +`Sounds` has two properties, `incomingCallSound` and `outgoingCallSound`. You need to assign raw resource identifiers to these properties. These identifiers correspond to audio files in your project's `res/raw` directory. + +To disable sounds, pass `null` to `incomingCallSound` or `outgoingCallSound`. + +:::caution +This constructor of the `Sounds` class is deprecated. It should be replaced with the `Sounds(SoundConfig)` constructor. See the recommended method below. +::: + +#### Recommended method for customizing sounds + +`Sounds` has one property, `soundConfig` of type `SoundConfig`. + +The `SoundConfig` interface defines two properties: +- `incomingCallSoundUri`: The URI for the incoming call sound. +- `outgoingCallSoundUri`: The URI for the outgoing call sound. + +Also, `SoundConfig` provides several static factory methods to create sound configurations: +- `createDeviceRingtoneSoundConfig` - This method returns a `SoundConfig` that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. +- `createStreamResourcesSoundConfig` - This method returns a `SoundConfig` that uses the SDK's default sounds for both incoming and outgoing calls. +- `createEmptySoundConfig` - Returns a `SoundConfig` that mutes (disables) all sounds. +- `createCustomSoundConfig` - This method allows you to specify custom sounds for incoming and outgoing calls. The sounds can be either `URI`s or resource ID `Int`s. If you use resource IDs, you **must** provide a context. + +:::note +By default, if you don't pass anything to the `sounds` builder parameter, the `createDeviceRingtoneSoundConfig` configuration is used. +::: + +##### Usage examples + +```kotlin +val soundConfig = SoundConfig.createCustomSoundConfig( + incomingCallSound = R.raw.custom_sound, + outgoingCallSound = customSoundUri, + context = context +) + +StreamVideoBuilder( + // ... + sounds = Sounds(soundConfig), + // ... +) +``` + +```kotlin +val baseSoundConfig = SoundConfig.createDeviceRingtoneSoundConfig(context) + +val mixedSoundConfig1 = SoundConfig.createCustomSoundConfig( + // Use device ringtone for incoming calls + incomingCallSound = baseSoundConfig.incomingCallSoundUri, + // Use custom sound for outgoing calls + outgoingCallSound = R.raw.call_busy_sound, + // Context needed as we're using a res ID + context = context +) + +val mixedSoundConfig2 = SoundConfig.createCustomSoundConfig( + // Use device ringtone for incoming calls + incomingCallSound = baseSoundConfig.incomingCallSoundUri, + // Mute the outgoing call sound + outgoingCallSound = null, + // Context not needed as we're not using any res ID +) +``` \ No newline at end of file diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt index 62df1c7734..fbe4aebaa2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt @@ -23,7 +23,7 @@ import io.getstream.video.android.core.R import io.getstream.video.android.core.utils.safeCall /** - * Base sound configuration. + * Interface representing a sound configuration. * * @see createDeviceRingtoneSoundConfig * @see createStreamResourcesSoundConfig @@ -70,6 +70,11 @@ interface SoundConfig { getSoundUriFromRes(context, R.raw.call_outgoing_sound) } + /** + * Utility method that returns a sound URI from a resource ID. + * + * @return The sound URI or null if the resource ID is null or an exception occurred. + */ fun getSoundUriFromRes(context: Context, soundResId: Int?): Uri? = soundResId?.let { safeCall(default = null) { Uri.parse("android.resource://${context.packageName}/$soundResId") diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt index 13c38897b3..6bd632944a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt @@ -41,7 +41,6 @@ constructor( ) { private var soundConfig: SoundConfig? = null - private set /** * Configure sounds by passing a [SoundConfig]. @@ -60,9 +59,7 @@ constructor( if (soundConfig != null) { soundConfig.incomingCallSoundUri } else { - incomingCallSound?.let { - Uri.parse("android.resource://${context.packageName}/$it") - } + incomingCallSound?.let { SoundConfig.getSoundUriFromRes(context, it) } } } @@ -70,9 +67,7 @@ constructor( if (soundConfig != null) { soundConfig.outgoingCallSoundUri } else { - outgoingCallSound?.let { - Uri.parse("android.resource://${context.packageName}/$it") - } + outgoingCallSound?.let { SoundConfig.getSoundUriFromRes(context, it) } } } } From 77f07ed4ab621606aa11ae3267dca50bfdb486f6 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Fri, 20 Sep 2024 17:16:02 +0300 Subject: [PATCH 12/18] Request/abandon audio focus --- .../internal/service/CallService.kt | 78 ++++++++++++++++--- 1 file changed, 68 insertions(+), 10 deletions(-) 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 15a6d0996f..5fb3f32413 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 @@ -24,8 +24,12 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.ServiceInfo +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager import android.media.MediaPlayer import android.net.Uri +import android.os.Build import android.os.IBinder import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -77,6 +81,8 @@ internal open class CallService : Service() { // Call sounds private var mediaPlayer: MediaPlayer? = null + private var audioManager: AudioManager? = null + private var audioFocusRequest: AudioFocusRequest? = null internal companion object { private const val TAG = "CallServiceCompanion" @@ -498,17 +504,54 @@ internal open class CallService : Service() { private fun playCallSound(soundUri: Uri?, mediaPlayer: MediaPlayer?) { if (soundUri != null && mediaPlayer != null) { - try { - with(mediaPlayer) { - if (!isPlaying) { - setMediaPlayerDataSource(this, soundUri) - start() + requestAudioFocus( + context = applicationContext, + onGranted = { + try { + with(mediaPlayer) { + if (!isPlaying) { + setMediaPlayerDataSource(this, soundUri) + start() + } + } + } catch (e: IllegalStateException) { + logger.d { "Error playing call sound." } } - } - } catch (e: IllegalStateException) { - logger.d { "Error playing call sound." } + }, + ) + } + } + + private fun requestAudioFocus(context: Context, onGranted: () -> Unit) { + if (audioManager == null) { + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + + val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (audioFocusRequest == null) { + audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + .setAcceptsDelayedFocusGain(false) + .build() } + + audioFocusRequest?.let { + audioManager?.requestAudioFocus(it) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + } ?: false + } else { + audioManager?.requestAudioFocus( + null, + AudioManager.STREAM_RING, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + ) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED } + + if (isGranted) onGranted() } private fun setMediaPlayerDataSource(mediaPlayer: MediaPlayer, uri: Uri) { @@ -523,6 +566,18 @@ internal open class CallService : Service() { if (mediaPlayer?.isPlaying == true) mediaPlayer?.stop() } catch (e: IllegalStateException) { logger.d { "Error stopping call sound. MediaPlayer might have already been released." } + } finally { + abandonAudioFocus() + } + } + + private fun abandonAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { + audioManager?.abandonAudioFocusRequest(it) + } + } else { + audioManager?.abandonAudioFocus(null) } } @@ -656,7 +711,7 @@ internal open class CallService : Service() { unregisterToggleCameraBroadcastReceiver() // Call sounds - clearMediaPlayer() + releaseAudioResources() // Stop any jobs serviceScope.cancel() @@ -665,9 +720,12 @@ internal open class CallService : Service() { stopSelf() } - private fun clearMediaPlayer() { + private fun releaseAudioResources() { mediaPlayer?.release() mediaPlayer = null + + audioManager = null + audioFocusRequest = null } // This service does not return a Binder From 8569235e02ddb815b6e07da1798619aec5803db2 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:51:47 +0300 Subject: [PATCH 13/18] Use Ringtone or MediaPlayer to play sound --- .../internal/service/CallService.kt | 77 ++++++++++++++----- 1 file changed, 58 insertions(+), 19 deletions(-) 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 5fb3f32413..a5aa6492c3 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 @@ -28,9 +28,12 @@ import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.media.MediaPlayer +import android.media.Ringtone +import android.media.RingtoneManager import android.net.Uri import android.os.Build import android.os.IBinder +import androidx.annotation.RequiresApi import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import io.getstream.log.StreamLog @@ -83,6 +86,7 @@ internal open class CallService : Service() { private var mediaPlayer: MediaPlayer? = null private var audioManager: AudioManager? = null private var audioFocusRequest: AudioFocusRequest? = null + private var ringtone: Ringtone? = null internal companion object { private const val TAG = "CallServiceCompanion" @@ -432,7 +436,6 @@ internal open class CallService : Service() { if (!it.acceptedByMe) { playCallSound( streamVideo.sounds.getIncomingCallSoundUri(applicationContext), - mediaPlayer, ) } else { stopCallSound() // Stops sound sooner than Active. More responsive. @@ -443,7 +446,6 @@ internal open class CallService : Service() { if (!it.acceptedByCallee) { playCallSound( streamVideo.sounds.getOutgoingCallSoundUri(applicationContext), - mediaPlayer, ) } else { stopCallSound() // Stops sound sooner than Active. More responsive. @@ -502,23 +504,20 @@ internal open class CallService : Service() { } } - private fun playCallSound(soundUri: Uri?, mediaPlayer: MediaPlayer?) { - if (soundUri != null && mediaPlayer != null) { + private fun playCallSound(soundUri: Uri?) { + try { requestAudioFocus( context = applicationContext, onGranted = { - try { - with(mediaPlayer) { - if (!isPlaying) { - setMediaPlayerDataSource(this, soundUri) - start() - } - } - } catch (e: IllegalStateException) { - logger.d { "Error playing call sound." } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + playWithRingtone(soundUri) + } else { + playWithMediaPlayer(soundUri) } }, ) + } catch (e: Exception) { + logger.d { "[Sounds] Error playing call sound: ${e.message}" } } } @@ -529,7 +528,8 @@ internal open class CallService : Service() { val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (audioFocusRequest == null) { - audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + audioFocusRequest = AudioFocusRequest + .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) @@ -551,9 +551,37 @@ internal open class CallService : Service() { ) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED } + logger.d { "[Sounds] Audio focus " + if (isGranted) "granted" else "not granted" } if (isGranted) onGranted() } + @RequiresApi(Build.VERSION_CODES.P) + private fun playWithRingtone(soundUri: Uri?) { + soundUri?.let { + if (ringtone?.isPlaying == true) ringtone?.stop() + ringtone = RingtoneManager.getRingtone(applicationContext, soundUri) + if (ringtone?.isPlaying == false) { + ringtone?.isLooping = true + ringtone?.play() + + logger.d { "[Sounds] Sound playing with Ringtone" } + } + } + } + + private fun playWithMediaPlayer(soundUri: Uri?) { + soundUri?.let { + mediaPlayer?.let { mediaPlayer -> + if (!mediaPlayer.isPlaying) { + setMediaPlayerDataSource(mediaPlayer, soundUri) + mediaPlayer.start() + + logger.d { "[Sounds] Sound playing with MediaPlayer" } + } + } + } + } + private fun setMediaPlayerDataSource(mediaPlayer: MediaPlayer, uri: Uri) { mediaPlayer.reset() mediaPlayer.setDataSource(applicationContext, uri) @@ -563,9 +591,15 @@ internal open class CallService : Service() { private fun stopCallSound() { try { - if (mediaPlayer?.isPlaying == true) mediaPlayer?.stop() - } catch (e: IllegalStateException) { - logger.d { "Error stopping call sound. MediaPlayer might have already been released." } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + logger.d { "[Sounds] Stopping Ringtone sound" } + if (ringtone?.isPlaying == true) ringtone?.stop() + } else { + logger.d { "[Sounds] Stopping MediaPlayer sound" } + if (mediaPlayer?.isPlaying == true) mediaPlayer?.stop() + } + } catch (e: Exception) { + logger.d { "[Sounds] Error stopping call sound: ${e.message}" } } finally { abandonAudioFocus() } @@ -711,7 +745,7 @@ internal open class CallService : Service() { unregisterToggleCameraBroadcastReceiver() // Call sounds - releaseAudioResources() + cleanAudioResources() // Stop any jobs serviceScope.cancel() @@ -720,7 +754,12 @@ internal open class CallService : Service() { stopSelf() } - private fun releaseAudioResources() { + private fun cleanAudioResources() { + logger.d { "[Sounds] Cleaning audio resources" } + + if (ringtone?.isPlaying == true) ringtone?.stop() + ringtone = null + mediaPlayer?.release() mediaPlayer = null From 071c423351642ce93a527015a905e861f8caab18 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 23 Sep 2024 17:19:03 +0200 Subject: [PATCH 14/18] Update with new name and small refactoring of the factory methods --- .../video/android/core/StreamVideoBuilder.kt | 5 +- .../android/core/sounds/RingingConfig.kt | 143 ++++++++++++++++++ .../video/android/core/sounds/SoundConfig.kt | 133 ---------------- .../video/android/core/sounds/Sounds.kt | 73 --------- 4 files changed, 146 insertions(+), 208 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt 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 51db557c96..a19e1c9399 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 @@ -33,8 +33,9 @@ import io.getstream.video.android.core.notifications.internal.service.callServic 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.SoundConfig import io.getstream.video.android.core.sounds.Sounds +import io.getstream.video.android.core.sounds.defaultResourcesRingingConfig +import io.getstream.video.android.core.sounds.toSounds import io.getstream.video.android.model.ApiKey import io.getstream.video.android.model.User import io.getstream.video.android.model.UserToken @@ -100,7 +101,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val runForegroundServiceForCalls: Boolean = true, private val callServiceConfig: CallServiceConfig? = null, private val localSfuAddress: String? = null, - private val sounds: Sounds = Sounds(SoundConfig.createDeviceRingtoneSoundConfig(context)), + private val sounds: Sounds = defaultResourcesRingingConfig(context).toSounds(), private val crashOnMissingPermission: Boolean = false, private val permissionCheck: StreamPermissionCheck = DefaultStreamPermissionCheck(), private val audioUsage: Int = defaultAudioUsage, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt new file mode 100644 index 0000000000..64319f2a7d --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt @@ -0,0 +1,143 @@ +/* + * 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.sounds + +import android.content.Context +import android.media.RingtoneManager +import android.net.Uri +import androidx.annotation.RawRes +import io.getstream.video.android.core.R +import io.getstream.video.android.core.utils.safeCall +import org.jetbrains.annotations.ApiStatus + +// Interface & API +/** + * Interface representing a ringing configuration. + * + * @see deviceRingtoneRingingConfig + * @see defaultResourcesRingingConfig + * @see emptyRingingConfig + * @see resRingingConfig + * @see uriRingingConfig + */ +public interface RingingConfig { + val incomingCallSoundUri: Uri? + val outgoingCallSoundUri: Uri? +} + +/** + * Contains all the sounds that the SDK uses. + */ +@Deprecated( + message = "Sounds will be deprecated in the future and replaced with RingingConfig.", + replaceWith = ReplaceWith("SoundConfig"), + level = DeprecationLevel.WARNING, +) +public data class Sounds(val ringingConfig: RingingConfig) { + @ApiStatus.ScheduledForRemoval(inVersion = "1.0.18") + @Deprecated( + message = "Deprecated. Use constructor with SoundConfig parameter instead.", + replaceWith = ReplaceWith("defaultResourcesRingingConfig(context).toSounds()"), + level = DeprecationLevel.ERROR, + ) + constructor( + @RawRes incomingCallSound: Int = R.raw.call_incoming_sound, + @RawRes outgoingCallSound: Int = R.raw.call_outgoing_sound, + ) : this(resRingingConfig(incomingCallSound, outgoingCallSound)) +} + +// Factories +/** + * Returns a ringing config that uses the SDK default sounds for incoming and outgoing calls. + * + * @param context Context used for retrieving the sounds. + */ +public fun defaultResourcesRingingConfig(context: Context): RingingConfig = object : RingingConfig { + override val incomingCallSoundUri: Uri? = R.raw.call_incoming_sound.toUriOrNUll(context) + override val outgoingCallSoundUri: Uri? = R.raw.call_outgoing_sound.toUriOrNUll(context) +} + +/** + * Returns a ringing config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. + * + * @param context Context used for retrieving the sounds. + */ +public fun deviceRingtoneRingingConfig(context: Context): RingingConfig = object : RingingConfig { + private val streamResSoundConfig = defaultResourcesRingingConfig(context) + override val incomingCallSoundUri: Uri? + get() = safeCall(default = null) { + RingtoneManager.getActualDefaultRingtoneUri( + context, + RingtoneManager.TYPE_RINGTONE, + ) + } ?: streamResSoundConfig.incomingCallSoundUri + override val outgoingCallSoundUri: Uri? = streamResSoundConfig.outgoingCallSoundUri +} + +/** + * Returns a ringing config that uses custom sounds for incoming and outgoing calls. + * + * @param incomingCallSound The incoming call sound. Can be a resource ID or a URI. + * @param outgoingCallSound The outgoing call sound. Can be a resource ID or a URI. + * @param context Context used for retrieving the sounds. Mandatory when one of the sound parameters is a resource ID. + * + * @return A ringing config with the provided sounds. + * + * @throws IllegalArgumentException If one of the sound parameters is a resource ID and the context is not provided. + */ +public fun resRingingConfig( + incomingCallSound: Int, + outgoingCallSound: Int, + context: Context? = null, +) = object : RingingConfig { + override val incomingCallSoundUri: Uri? = incomingCallSound.toUriOrNUll(context) + override val outgoingCallSoundUri: Uri? = outgoingCallSound.toUriOrNUll(context) +} + +/** + * Returns a ringing config that uses custom URIs for incoming and outgoing calls. + */ +public fun uriRingingConfig( + incomingCallSoundUri: Uri, + outgoingCallSoundUri: Uri, +) = object : RingingConfig { + override val incomingCallSoundUri: Uri = incomingCallSoundUri + override val outgoingCallSoundUri: Uri = outgoingCallSoundUri +} + +/** + * Returns a ringing config that mutes (disables) all sounds. + */ +public fun emptyRingingConfig(): RingingConfig = object : RingingConfig { + override val incomingCallSoundUri: Uri? = null + override val outgoingCallSoundUri: Uri? = null +} + +/** + * Converts a ringing config to a [Sounds] object. + */ +public fun RingingConfig.toSounds() = Sounds(this) + +// Internal utilities +private fun Int?.toUriOrNUll(context: Context?): Uri? = + safeCall(default = null) { + if (this != null && context != null) { + Uri.parse("android.resource://${context.packageName}/$this") + } else { + null + } + } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt deleted file mode 100644 index fbe4aebaa2..0000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/SoundConfig.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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.sounds - -import android.content.Context -import android.media.RingtoneManager -import android.net.Uri -import io.getstream.video.android.core.R -import io.getstream.video.android.core.utils.safeCall - -/** - * Interface representing a sound configuration. - * - * @see createDeviceRingtoneSoundConfig - * @see createStreamResourcesSoundConfig - * @see createEmptySoundConfig - * @see createCustomSoundConfig - */ -interface SoundConfig { - - val incomingCallSoundUri: Uri? - val outgoingCallSoundUri: Uri? - - companion object { - - /** - * Returns a sound config that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. - * - * @param context Context used for retrieving the sounds. - */ - fun createDeviceRingtoneSoundConfig(context: Context): SoundConfig = object : SoundConfig { - - private val streamResSoundConfig = createStreamResourcesSoundConfig(context) - - override val incomingCallSoundUri: Uri? - get() = safeCall(default = null) { - RingtoneManager.getActualDefaultRingtoneUri( - context, - RingtoneManager.TYPE_RINGTONE, - ) - } ?: streamResSoundConfig.incomingCallSoundUri - - override val outgoingCallSoundUri: Uri? = streamResSoundConfig.outgoingCallSoundUri - } - - /** - * Returns a sound config that uses the SDK default sounds for incoming and outgoing calls. - * - * @param context Context used for retrieving the sounds. - */ - fun createStreamResourcesSoundConfig(context: Context): SoundConfig = object : SoundConfig { - - override val incomingCallSoundUri: Uri? = - getSoundUriFromRes(context, R.raw.call_incoming_sound) - override val outgoingCallSoundUri: Uri? = - getSoundUriFromRes(context, R.raw.call_outgoing_sound) - } - - /** - * Utility method that returns a sound URI from a resource ID. - * - * @return The sound URI or null if the resource ID is null or an exception occurred. - */ - fun getSoundUriFromRes(context: Context, soundResId: Int?): Uri? = soundResId?.let { - safeCall(default = null) { - Uri.parse("android.resource://${context.packageName}/$soundResId") - } - } - - /** - * Returns a sound config that mutes (disables) all sounds. - */ - fun createEmptySoundConfig(): SoundConfig = object : SoundConfig { - - override val incomingCallSoundUri: Uri? = null - override val outgoingCallSoundUri: Uri? = null - } - - /** - * Returns a sound config that uses custom sounds for incoming and outgoing calls. - * - * @param incomingCallSound The incoming call sound. Can be a resource ID or a URI. - * @param outgoingCallSound The outgoing call sound. Can be a resource ID or a URI. - * @param context Context used for retrieving the sounds. Mandatory when one of the sound parameters is a resource ID. - * - * @return A sound config with the provided sounds. - * - * @throws IllegalArgumentException If one of the sound parameters is a resource ID and the context is not provided. - */ - fun createCustomSoundConfig( - incomingCallSound: Any?, - outgoingCallSound: Any?, - context: Context? = null, - ) = object : SoundConfig { - - override val incomingCallSoundUri: Uri? = when (incomingCallSound) { - is Uri -> incomingCallSound - is Int -> { - requireNotNull( - context, - ) { "Context is required when incomingCallSound is a resource ID." } - getSoundUriFromRes(context, incomingCallSound) - } - else -> null - } - - override val outgoingCallSoundUri: Uri? = when (outgoingCallSound) { - is Uri -> outgoingCallSound - is Int -> { - requireNotNull( - context, - ) { "Context is required when outgoingCallSound is a resource ID." } - getSoundUriFromRes(context, outgoingCallSound) - } - else -> null - } - } - } -} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt deleted file mode 100644 index 6bd632944a..0000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/Sounds.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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.sounds - -import android.content.Context -import android.net.Uri -import androidx.annotation.RawRes -import io.getstream.video.android.core.R -import org.jetbrains.annotations.ApiStatus - -/** - * Contains all the sounds that the SDK uses. - * - * @param incomingCallSound Resource used as a ringtone for incoming calls. - * @param outgoingCallSound Resource used as a ringing tone for outgoing calls. - */ -data class Sounds -@Deprecated( - message = "Deprecated. Use constructor with SoundConfig parameter instead.", - replaceWith = ReplaceWith("Sounds(soundConfig: SoundConfig)"), - level = DeprecationLevel.WARNING, -) -@ApiStatus.ScheduledForRemoval(inVersion = "1.0.18") -constructor( - @RawRes val incomingCallSound: Int? = R.raw.call_incoming_sound, - @RawRes val outgoingCallSound: Int? = R.raw.call_outgoing_sound, -) { - - private var soundConfig: SoundConfig? = null - - /** - * Configure sounds by passing a [SoundConfig]. - * - * @see SoundConfig.createDeviceRingtoneSoundConfig - * @see SoundConfig.createStreamResourcesSoundConfig - * @see SoundConfig.createEmptySoundConfig - * @see SoundConfig.createCustomSoundConfig - * @see SoundConfig - */ - constructor(soundConfig: SoundConfig) : this() { - this.soundConfig = soundConfig - } - - internal fun getIncomingCallSoundUri(context: Context): Uri? = soundConfig.let { soundConfig -> - if (soundConfig != null) { - soundConfig.incomingCallSoundUri - } else { - incomingCallSound?.let { SoundConfig.getSoundUriFromRes(context, it) } - } - } - - internal fun getOutgoingCallSoundUri(context: Context): Uri? = soundConfig.let { soundConfig -> - if (soundConfig != null) { - soundConfig.outgoingCallSoundUri - } else { - outgoingCallSound?.let { SoundConfig.getSoundUriFromRes(context, it) } - } - } -} From 8ad85b0d260adc8bb8bf492b0676bf298011a250 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 23 Sep 2024 17:26:40 +0200 Subject: [PATCH 15/18] Update compilation errors --- .../core/notifications/internal/service/CallService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9e1fc84544..e1385b962d 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 @@ -440,7 +440,7 @@ internal open class CallService : Service() { is RingingState.Incoming -> { if (!it.acceptedByMe) { playCallSound( - streamVideo.sounds.getIncomingCallSoundUri(applicationContext), + streamVideo.sounds.ringingConfig.incomingCallSoundUri, ) } else { stopCallSound() // Stops sound sooner than Active. More responsive. @@ -450,7 +450,7 @@ internal open class CallService : Service() { is RingingState.Outgoing -> { if (!it.acceptedByCallee) { playCallSound( - streamVideo.sounds.getOutgoingCallSoundUri(applicationContext), + streamVideo.sounds.ringingConfig.outgoingCallSoundUri, ) } else { stopCallSound() // Stops sound sooner than Active. More responsive. From 901e2b25594ece42e883fc7db21d0e631df3144e Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 23 Sep 2024 17:27:46 +0200 Subject: [PATCH 16/18] Spotless and ApiDump --- .../api/stream-video-android-core.api | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) 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 66f310a032..786eaae325 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -4476,33 +4476,30 @@ public final class io/getstream/video/android/core/socket/SocketState$NotConnect public fun toString ()Ljava/lang/String; } -public abstract interface class io/getstream/video/android/core/sounds/SoundConfig { - public static final field Companion Lio/getstream/video/android/core/sounds/SoundConfig$Companion; +public abstract interface class io/getstream/video/android/core/sounds/RingingConfig { public abstract fun getIncomingCallSoundUri ()Landroid/net/Uri; public abstract fun getOutgoingCallSoundUri ()Landroid/net/Uri; } -public final class io/getstream/video/android/core/sounds/SoundConfig$Companion { - public final fun createCustomSoundConfig (Ljava/lang/Object;Ljava/lang/Object;Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; - public static synthetic fun createCustomSoundConfig$default (Lio/getstream/video/android/core/sounds/SoundConfig$Companion;Ljava/lang/Object;Ljava/lang/Object;Landroid/content/Context;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/SoundConfig; - public final fun createDeviceRingtoneSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; - public final fun createEmptySoundConfig ()Lio/getstream/video/android/core/sounds/SoundConfig; - public final fun createStreamResourcesSoundConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/SoundConfig; - public final fun getSoundUriFromRes (Landroid/content/Context;Ljava/lang/Integer;)Landroid/net/Uri; +public final class io/getstream/video/android/core/sounds/RingingConfigKt { + public static final fun defaultResourcesRingingConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/RingingConfig; + public static final fun deviceRingtoneRingingConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/RingingConfig; + public static final fun emptyRingingConfig ()Lio/getstream/video/android/core/sounds/RingingConfig; + public static final fun resRingingConfig (IILandroid/content/Context;)Lio/getstream/video/android/core/sounds/RingingConfig; + public static synthetic fun resRingingConfig$default (IILandroid/content/Context;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/RingingConfig; + public static final fun toSounds (Lio/getstream/video/android/core/sounds/RingingConfig;)Lio/getstream/video/android/core/sounds/Sounds; + public static final fun uriRingingConfig (Landroid/net/Uri;Landroid/net/Uri;)Lio/getstream/video/android/core/sounds/RingingConfig; } public final class io/getstream/video/android/core/sounds/Sounds { - public fun ()V - public fun (Lio/getstream/video/android/core/sounds/SoundConfig;)V - public fun (Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/Integer; - public final fun component2 ()Ljava/lang/Integer; - public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/video/android/core/sounds/Sounds; - public static synthetic fun copy$default (Lio/getstream/video/android/core/sounds/Sounds;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/Sounds; + public fun (II)V + public synthetic fun (IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/video/android/core/sounds/RingingConfig;)V + public final fun component1 ()Lio/getstream/video/android/core/sounds/RingingConfig; + public final fun copy (Lio/getstream/video/android/core/sounds/RingingConfig;)Lio/getstream/video/android/core/sounds/Sounds; + public static synthetic fun copy$default (Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingConfig;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/Sounds; public fun equals (Ljava/lang/Object;)Z - public final fun getIncomingCallSound ()Ljava/lang/Integer; - public final fun getOutgoingCallSound ()Ljava/lang/Integer; + public final fun getRingingConfig ()Lio/getstream/video/android/core/sounds/RingingConfig; public fun hashCode ()I public fun toString ()Ljava/lang/String; } From af1d11428c790bf0ac083e33ef0aed5788104d50 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Tue, 24 Sep 2024 12:37:01 +0200 Subject: [PATCH 17/18] Update docs, remove unused MP3 in resources, make `context` non-nullable --- .../docs/Android/06-advanced/01-ringing.mdx | 170 +++++++++++------- .../api/stream-video-android-core.api | 3 +- .../android/core/sounds/RingingConfig.kt | 16 +- .../src/main/res/raw/call_busy_sound.mp3 | Bin 81989 -> 0 bytes 4 files changed, 116 insertions(+), 73 deletions(-) delete mode 100644 stream-video-android-core/src/main/res/raw/call_busy_sound.mp3 diff --git a/docusaurus/docs/Android/06-advanced/01-ringing.mdx b/docusaurus/docs/Android/06-advanced/01-ringing.mdx index eb8d079792..11998b9854 100644 --- a/docusaurus/docs/Android/06-advanced/01-ringing.mdx +++ b/docusaurus/docs/Android/06-advanced/01-ringing.mdx @@ -2,49 +2,70 @@ title: Ringing description: How to ring the call and notify all members --- + The `Call` object provides several options to ring and notify users about a call. + ### Create and start a ringing call -To create a ring call, we need to set the `ring` flag to `true` and provide the list of members we want to call. It is important to note that the caller should also be included in the list of members. +To create a ring call, we need to set the `ring` flag to `true` and provide the list of members we +want to call. It is important to note that the caller should also be included in the list of +members. For this, you can use the `create` method from the `Call` object. + ```kotlin val call = client.call("default", "123") call.create(ring = true, members = listOf("caller-id", "receiver-1", "receiver-2")) ``` -When ring is `true`, a push notification will be sent to the members, provided you have the required setup for push notifications. -For more details around push notifications, please check [this page](./02-push-notifications/01-overview.mdx). + +When ring is `true`, a push notification will be sent to the members, provided you have the required +setup for push notifications. +For more details around push notifications, please +check [this page](./02-push-notifications/01-overview.mdx). If ring is `false`, no push notification will be sent. ### Ring an existing call + If you are sure that a call exists, you can use the `get` method instead: + ```kotlin val call = client.call("default", "123") call.get() call.ring() ``` -The `get()` - `ring()` combination is better used for when calls are created and managed externally via another system. + +The `get()` - `ring()` combination is better used for when calls are created and managed externally +via another system. ### Monitor the outgoing call state + The state of the ringing call is available via the `StreamVideo` client. + ```kotlin val client = StreamVideo.instance() val ringingCall = client.state.ringingCall ``` + This will give you a `StateFlow` which can be monitored. + ```kotlin ringingCall.collectLatest { call -> - // There is a ringing call + // There is a ringing call } ``` + or simply just get a current value. + ```kotlin val call = ringingCall.value ``` + ### Canceling an outgoing call + To cancel an outgoing call you can simply `reject` the call from the caller side. The `reject()` method will notify the endpoint that the call is being rejected and corresponding events will be sent. In order to cleanup on the caller side, a call to `leave()` is required. -These two usually go together, unless there is a specific reason to keep the channel open for further +These two usually go together, unless there is a specific reason to keep the channel open for +further events. ```kotlin @@ -60,42 +81,56 @@ will receive a push notification about an incoming call. By default the SDK will show the push notification (with a call style) with an option to either accept or decline the call. -When the user clicks on a push notification. There is an intent fired `ACTION_REJECT_CALL` or `ACTION_ACCEPT_CALL`. +When the user clicks on a push notification. There is an intent fired `ACTION_REJECT_CALL` +or `ACTION_ACCEPT_CALL`. The -You can learn more about how to setup [push notifications in the docs](./02-push-notifications/01-overview.mdx). +You can learn more about how to +setup [push notifications in the docs](./02-push-notifications/01-overview.mdx). The docs also explain how to customize the notifications. ### Accept an incoming call The compose SDK provides built-in components to render and handle an incoming call. -One of them is `StreamCallActivity`. This abstract activity handles everything that is needed for a call. -Stream also provides a default compose implementation of this activity called `ComposeStreamCallActivity`. +One of them is `StreamCallActivity`. This abstract activity handles everything that is needed for a +call. +Stream also provides a default compose implementation of this activity +called `ComposeStreamCallActivity`. -These components are already predefined and registered in the SDK. If you want to customize them you can easily extend them as any other activity in Android. +These components are already predefined and registered in the SDK. If you want to customize them you +can easily extend them as any other activity in Android. For more details check: + * [UI Component docs for incoming calls](../04-ui-components/04-call/04-ringing-call.mdx) -* UI Cookbook how to build [your own incoming call UI](../05-ui-cookbook/05-incoming-and-outgoing-call.mdx) +* UI Cookbook how to + build [your own incoming call UI](../05-ui-cookbook/05-incoming-and-outgoing-call.mdx) + +The Stream SDK provides a way to accept a call within the code so if you are building a new UI, you +can do this via the SDK API. -The Stream SDK provides a way to accept a call within the code so if you are building a new UI, you can do this via the SDK API. ```kotlin call.accept() call.join() ``` + The above calls are all you need to accept and join a call. Its important to note that if there is already an ongoing call you first have to leave that call. + ```kotlin val client = StreamVideo.instance() val activeCall = client.start.activeCall.value if (activeCall != null) { - activeCall.leave() + activeCall.leave() } ``` + All this needs to be done with a component that handles the accept action. + ```xml + ``` @@ -105,6 +140,7 @@ Clicking the notification will automatically reject the call. There are certain instances that you might want to do this manually in your code. Stream offers a simple API to do this. + ```kotlin call.reject() ``` @@ -112,75 +148,81 @@ call.reject() Note that rejecting the call will notify the caller and other members that the participant rejected the call. However it will not clean up the local `call` state. For this you need to leave the call by using: + ```kotlin call.leave() ``` -### Sounds +### Ringing sound + +The SDK plays sounds for incoming and outgoing calls. The SDK has bundled two sounds for this +purpose. -The SDK plays sounds for incoming and outgoing calls. You can customize these sounds by passing your own instance of the `Sounds` class to the `StreamVideoBuilder` `sounds` constructor parameter. +### Customizing the ringing sound -#### Deprecated method for customizing sounds +The ringing sound that is played can be customized in two ways. -`Sounds` has two properties, `incomingCallSound` and `outgoingCallSound`. You need to assign raw resource identifiers to these properties. These identifiers correspond to audio files in your project's `res/raw` directory. +1. Override the bundled resources inside your application. +2. Provide your own `RingingConfig` to the `StreamVideoBuilder`. -To disable sounds, pass `null` to `incomingCallSound` or `outgoingCallSound`. +#### Override the bundled resources + +The resources are: `/raw/call_incoming_sound.mp3` and `/raw/call_outgoing_sound.mp3`. + +#### Provide your own `RingingConfig` + +You can customize this by configuring a `RingingConfig`. +Currently the SDK accepts a `Sounds` object instead so once you have a `RingingConfig` you can +create a `Sounds` object via `ringingConfig.toSounds()` and pass it to the `StreamVideoBuilder`. :::caution -This constructor of the `Sounds` class is deprecated. It should be replaced with the `Sounds(SoundConfig)` constructor. See the recommended method below. +The `Sounds` class is deprecated and will entirely be replaced by the `RingingConfig` in the future. +Current `Sounds` constructor which accepts two integers will always return an `emptyRingingConfig()` +with muted sounds. ::: -#### Recommended method for customizing sounds +The `RingingConfig` interface defines two properties: -`Sounds` has one property, `soundConfig` of type `SoundConfig`. - -The `SoundConfig` interface defines two properties: - `incomingCallSoundUri`: The URI for the incoming call sound. - `outgoingCallSoundUri`: The URI for the outgoing call sound. -Also, `SoundConfig` provides several static factory methods to create sound configurations: -- `createDeviceRingtoneSoundConfig` - This method returns a `SoundConfig` that uses the device ringtone for incoming calls and the SDK default ringing tone for outgoing calls. -- `createStreamResourcesSoundConfig` - This method returns a `SoundConfig` that uses the SDK's default sounds for both incoming and outgoing calls. -- `createEmptySoundConfig` - Returns a `SoundConfig` that mutes (disables) all sounds. -- `createCustomSoundConfig` - This method allows you to specify custom sounds for incoming and outgoing calls. The sounds can be either `URI`s or resource ID `Int`s. If you use resource IDs, you **must** provide a context. +You can implement this interface and provide your own values for the properties. (e.g. a user chosen +URIs). +After which you need to provide it to a `Sounds` constructor (e.g. Sounds(ringingConfig)) and then +pass that object to the builder. +If one of the Uri's is null the SDK will simply not play that sound and log this fact instead. -:::note -By default, if you don't pass anything to the `sounds` builder parameter, the `createDeviceRingtoneSoundConfig` configuration is used. -::: - -##### Usage examples +To create a `RingingConfig` with only an incoming sound you can extend it in a following manner +where the `outgoingCallSoundUri` is `null`: ```kotlin -val soundConfig = SoundConfig.createCustomSoundConfig( - incomingCallSound = R.raw.custom_sound, - outgoingCallSound = customSoundUri, - context = context -) - -StreamVideoBuilder( - // ... - sounds = Sounds(soundConfig), - // ... -) +class IncomingOnlyRingingConfig : RingingConfig { + override val incomingCallSoundUri: Uri = + Uri.parse("android.resource://$packageName/${R.raw.custom_incoming_sound}") + override val outgoingCallSoundUri: Uri? = null +} ``` +`RingingConfig` can also be created via several factory methods: + +- `defaultResourcesRingingConfig` - This method returns a `RingingConfig` that uses the SDK's + default sounds bundled with the SDK for both incoming and outgoing calls +- `resRingingConfig` - This method returns a `RingingConfig` that specifies a resource identifier for both incoming and outgoing calls. +- `uriRingingConfig(Uri, Uri)` - Returns a `RingingConfig` that is configured with two `Uri` objects for the corresponding sounds. +- `emptyRingingConfig` - The SDK will not play any sounds for incoming and outgoing calls. + +For further customization its best to provide your own `RingingConfig` implementation. +Such use cases may include a user setting to choose a sound or a custom sound that is not bundled. + +For example: ```kotlin -val baseSoundConfig = SoundConfig.createDeviceRingtoneSoundConfig(context) - -val mixedSoundConfig1 = SoundConfig.createCustomSoundConfig( - // Use device ringtone for incoming calls - incomingCallSound = baseSoundConfig.incomingCallSoundUri, - // Use custom sound for outgoing calls - outgoingCallSound = R.raw.call_busy_sound, - // Context needed as we're using a res ID - context = context -) - -val mixedSoundConfig2 = SoundConfig.createCustomSoundConfig( - // Use device ringtone for incoming calls - incomingCallSound = baseSoundConfig.incomingCallSoundUri, - // Mute the outgoing call sound - outgoingCallSound = null, - // Context not needed as we're not using any res ID -) -``` \ No newline at end of file +data class UserRingingConfig( + val incomingCallSoundUri: Uri, + val outgoingCallSoundUri: Uri +) : RingingConfig +``` + +:::note +If the sound resources cannot be found (Uri or resource ID) the SDK will simply not play any sound and +log the error. +::: \ No newline at end of file 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 786eaae325..efe1132d02 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -4485,8 +4485,7 @@ public final class io/getstream/video/android/core/sounds/RingingConfigKt { public static final fun defaultResourcesRingingConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/RingingConfig; public static final fun deviceRingtoneRingingConfig (Landroid/content/Context;)Lio/getstream/video/android/core/sounds/RingingConfig; public static final fun emptyRingingConfig ()Lio/getstream/video/android/core/sounds/RingingConfig; - public static final fun resRingingConfig (IILandroid/content/Context;)Lio/getstream/video/android/core/sounds/RingingConfig; - public static synthetic fun resRingingConfig$default (IILandroid/content/Context;ILjava/lang/Object;)Lio/getstream/video/android/core/sounds/RingingConfig; + public static final fun resRingingConfig (Landroid/content/Context;II)Lio/getstream/video/android/core/sounds/RingingConfig; public static final fun toSounds (Lio/getstream/video/android/core/sounds/RingingConfig;)Lio/getstream/video/android/core/sounds/Sounds; public static final fun uriRingingConfig (Landroid/net/Uri;Landroid/net/Uri;)Lio/getstream/video/android/core/sounds/RingingConfig; } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt index 64319f2a7d..ce34026fb9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt @@ -20,6 +20,7 @@ import android.content.Context import android.media.RingtoneManager import android.net.Uri import androidx.annotation.RawRes +import io.getstream.log.StreamLog import io.getstream.video.android.core.R import io.getstream.video.android.core.utils.safeCall import org.jetbrains.annotations.ApiStatus @@ -43,21 +44,21 @@ public interface RingingConfig { * Contains all the sounds that the SDK uses. */ @Deprecated( - message = "Sounds will be deprecated in the future and replaced with RingingConfig.", + message = "Sounds will be deprecated in the future and replaced with RingingConfig. It is recommended to use one of the factory methods along with toSounds() to create the Sounds object.", replaceWith = ReplaceWith("SoundConfig"), level = DeprecationLevel.WARNING, ) public data class Sounds(val ringingConfig: RingingConfig) { @ApiStatus.ScheduledForRemoval(inVersion = "1.0.18") @Deprecated( - message = "Deprecated. Use constructor with SoundConfig parameter instead.", + message = "Deprecated. This Constructor will now return a sound configuration with no sounds. Use constructor with SoundConfig parameter instead.", replaceWith = ReplaceWith("defaultResourcesRingingConfig(context).toSounds()"), level = DeprecationLevel.ERROR, ) constructor( @RawRes incomingCallSound: Int = R.raw.call_incoming_sound, @RawRes outgoingCallSound: Int = R.raw.call_outgoing_sound, - ) : this(resRingingConfig(incomingCallSound, outgoingCallSound)) + ) : this(emptyRingingConfig()) } // Factories @@ -91,18 +92,18 @@ public fun deviceRingtoneRingingConfig(context: Context): RingingConfig = object /** * Returns a ringing config that uses custom sounds for incoming and outgoing calls. * + * @param context Context used for retrieving the sounds. Mandatory when one of the sound parameters is a resource ID. * @param incomingCallSound The incoming call sound. Can be a resource ID or a URI. * @param outgoingCallSound The outgoing call sound. Can be a resource ID or a URI. - * @param context Context used for retrieving the sounds. Mandatory when one of the sound parameters is a resource ID. * * @return A ringing config with the provided sounds. * * @throws IllegalArgumentException If one of the sound parameters is a resource ID and the context is not provided. */ public fun resRingingConfig( + context: Context, incomingCallSound: Int, outgoingCallSound: Int, - context: Context? = null, ) = object : RingingConfig { override val incomingCallSoundUri: Uri? = incomingCallSound.toUriOrNUll(context) override val outgoingCallSoundUri: Uri? = outgoingCallSound.toUriOrNUll(context) @@ -133,11 +134,12 @@ public fun emptyRingingConfig(): RingingConfig = object : RingingConfig { public fun RingingConfig.toSounds() = Sounds(this) // Internal utilities -private fun Int?.toUriOrNUll(context: Context?): Uri? = +private fun Int?.toUriOrNUll(context: Context): Uri? = safeCall(default = null) { - if (this != null && context != null) { + if (this != null) { Uri.parse("android.resource://${context.packageName}/$this") } else { + StreamLog.w("RingingConfig") { "Resource ID is null. Returning null URI." } null } } diff --git a/stream-video-android-core/src/main/res/raw/call_busy_sound.mp3 b/stream-video-android-core/src/main/res/raw/call_busy_sound.mp3 deleted file mode 100644 index 7778f3e175832eeef3f17ce8cf527e748d813300..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81989 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 O%pHv(YK7#mH3tB{C1Lvj From 1a63fd7f7cea2085b3038057b64ffc47a63c4757 Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:08:47 +0300 Subject: [PATCH 18/18] Improve docs and names --- .../docs/Android/06-advanced/01-ringing.mdx | 39 ++++++++++--------- .../android/core/sounds/RingingConfig.kt | 29 +++++++------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docusaurus/docs/Android/06-advanced/01-ringing.mdx b/docusaurus/docs/Android/06-advanced/01-ringing.mdx index 11998b9854..c16b190b36 100644 --- a/docusaurus/docs/Android/06-advanced/01-ringing.mdx +++ b/docusaurus/docs/Android/06-advanced/01-ringing.mdx @@ -153,31 +153,34 @@ For this you need to leave the call by using: call.leave() ``` -### Ringing sound +## Ringing sounds -The SDK plays sounds for incoming and outgoing calls. The SDK has bundled two sounds for this +The SDK plays sounds for incoming and outgoing calls. The SDK bundles two sounds for this purpose. -### Customizing the ringing sound - -The ringing sound that is played can be customized in two ways. +### Customizing the ringing sounds +The ringing sounds can be customized in two ways: 1. Override the bundled resources inside your application. 2. Provide your own `RingingConfig` to the `StreamVideoBuilder`. #### Override the bundled resources The resources are: `/raw/call_incoming_sound.mp3` and `/raw/call_outgoing_sound.mp3`. +You can place your own `call_incoming_sound.mp3` and `call_outgoing_sound.mp3` files in the `res/raw` directory of your app. #### Provide your own `RingingConfig` -You can customize this by configuring a `RingingConfig`. -Currently the SDK accepts a `Sounds` object instead so once you have a `RingingConfig` you can +You can customize the sounds by creating a `RingingConfig`. + +:::note +Currently the SDK accepts a `Sounds` object in the builder, so once you have a `RingingConfig`, you can create a `Sounds` object via `ringingConfig.toSounds()` and pass it to the `StreamVideoBuilder`. +::: :::caution -The `Sounds` class is deprecated and will entirely be replaced by the `RingingConfig` in the future. -Current `Sounds` constructor which accepts two integers will always return an `emptyRingingConfig()` +The `Sounds` class is deprecated and will entirely be replaced by `RingingConfig` in the future. +The current `Sounds` constructor which accepts two integers will always return an `emptyRingingConfig()` with muted sounds. ::: @@ -186,28 +189,26 @@ The `RingingConfig` interface defines two properties: - `incomingCallSoundUri`: The URI for the incoming call sound. - `outgoingCallSoundUri`: The URI for the outgoing call sound. -You can implement this interface and provide your own values for the properties. (e.g. a user chosen -URIs). -After which you need to provide it to a `Sounds` constructor (e.g. Sounds(ringingConfig)) and then -pass that object to the builder. -If one of the Uri's is null the SDK will simply not play that sound and log this fact instead. +You can implement this interface and provide your own values for the properties (e.g. a user chosen +URI). After that, create a `Sounds` object using the `RingingConfig` instance (e.g. `Sounds(ringingConfig)`) and pass the `Sounds` object to the SDK builder. +If one of the `Uri`s is null the SDK will simply not play that sound and log this fact instead. -To create a `RingingConfig` with only an incoming sound you can extend it in a following manner -where the `outgoingCallSoundUri` is `null`: +For example, to create a `RingingConfig` that only includes an incoming sound you can extend it as shown below, setting `outgoingCallSoundUri` is `null`: ```kotlin class IncomingOnlyRingingConfig : RingingConfig { override val incomingCallSoundUri: Uri = Uri.parse("android.resource://$packageName/${R.raw.custom_incoming_sound}") - override val outgoingCallSoundUri: Uri? = null + + override val outgoingCallSoundUri: Uri? = null // Outgoing sound will be muted } ``` `RingingConfig` can also be created via several factory methods: - `defaultResourcesRingingConfig` - This method returns a `RingingConfig` that uses the SDK's - default sounds bundled with the SDK for both incoming and outgoing calls -- `resRingingConfig` - This method returns a `RingingConfig` that specifies a resource identifier for both incoming and outgoing calls. + default sounds for both incoming and outgoing calls +- `resRingingConfig` - This method returns a `RingingConfig` that uses a resource identifier for both incoming and outgoing calls. - `uriRingingConfig(Uri, Uri)` - Returns a `RingingConfig` that is configured with two `Uri` objects for the corresponding sounds. - `emptyRingingConfig` - The SDK will not play any sounds for incoming and outgoing calls. diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt index ce34026fb9..9042748f31 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt @@ -29,8 +29,8 @@ import org.jetbrains.annotations.ApiStatus /** * Interface representing a ringing configuration. * - * @see deviceRingtoneRingingConfig * @see defaultResourcesRingingConfig + * @see deviceRingtoneRingingConfig * @see emptyRingingConfig * @see resRingingConfig * @see uriRingingConfig @@ -90,27 +90,26 @@ public fun deviceRingtoneRingingConfig(context: Context): RingingConfig = object } /** - * Returns a ringing config that uses custom sounds for incoming and outgoing calls. - * - * @param context Context used for retrieving the sounds. Mandatory when one of the sound parameters is a resource ID. - * @param incomingCallSound The incoming call sound. Can be a resource ID or a URI. - * @param outgoingCallSound The outgoing call sound. Can be a resource ID or a URI. + * Returns a ringing config that uses custom resources for incoming and outgoing call sounds. * - * @return A ringing config with the provided sounds. - * - * @throws IllegalArgumentException If one of the sound parameters is a resource ID and the context is not provided. + * @param context Context used for retrieving the sounds. + * @param incomingCallSoundResId The resource ID for the incoming call sound. + * @param outgoingCallSoundResId The resource ID for the outgoing call sound. */ public fun resRingingConfig( context: Context, - incomingCallSound: Int, - outgoingCallSound: Int, + @RawRes incomingCallSoundResId: Int, + @RawRes outgoingCallSoundResId: Int, ) = object : RingingConfig { - override val incomingCallSoundUri: Uri? = incomingCallSound.toUriOrNUll(context) - override val outgoingCallSoundUri: Uri? = outgoingCallSound.toUriOrNUll(context) + override val incomingCallSoundUri: Uri? = incomingCallSoundResId.toUriOrNUll(context) + override val outgoingCallSoundUri: Uri? = outgoingCallSoundResId.toUriOrNUll(context) } /** - * Returns a ringing config that uses custom URIs for incoming and outgoing calls. + * Returns a ringing config that uses custom URIs for incoming and outgoing call sounds. + * + * @param incomingCallSoundUri The URI for the incoming call sound. + * @param outgoingCallSoundUri The URI for the outgoing call sound. */ public fun uriRingingConfig( incomingCallSoundUri: Uri, @@ -121,7 +120,7 @@ public fun uriRingingConfig( } /** - * Returns a ringing config that mutes (disables) all sounds. + * Returns a ringing config that mutes (disables) incoming and outgoing call sounds. */ public fun emptyRingingConfig(): RingingConfig = object : RingingConfig { override val incomingCallSoundUri: Uri? = null