Element Call ringing notifications (#2978)
- Add `ActiveCallManager` to handle incoming and ongoing calls. - Add ringing call notifications with full screen intents and missed call ones as part of the 'conversation' notifications. --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
b827b859dc
commit
6f8de0b2c6
186 changed files with 2686 additions and 330 deletions
31
features/call/api/build.gradle.kts
Normal file
31
features/call/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.call.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
package io.element.android.features.call.api
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Entry point for the call feature.
|
||||
*/
|
||||
interface ElementCallEntryPoint {
|
||||
/**
|
||||
* Start a call of the given type.
|
||||
* @param callType The type of call to start.
|
||||
*/
|
||||
fun startCall(callType: CallType)
|
||||
|
||||
/**
|
||||
* Handle an incoming call.
|
||||
* @param callType The type of call.
|
||||
* @param eventId The event id of the event that started the call.
|
||||
* @param senderId The user id of the sender of the event that started the call.
|
||||
* @param roomName The name of the room the call is in.
|
||||
* @param senderName The name of the sender of the event that started the call.
|
||||
* @param avatarUrl The avatar url of the room or DM.
|
||||
* @param timestamp The timestamp of the event that started the call.
|
||||
* @param notificationChannelId The id of the notification channel to use for the call notification.
|
||||
*/
|
||||
fun handleIncomingCall(
|
||||
callType: CallType.RoomCall,
|
||||
eventId: EventId,
|
||||
senderId: UserId,
|
||||
roomName: String?,
|
||||
senderName: String?,
|
||||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
notificationChannelId: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
|
@ -23,11 +23,15 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.call"
|
||||
namespace = "io.element.android.features.call.impl"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
|
|
@ -41,12 +45,17 @@ dependencies {
|
|||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.impl)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.serialization.json)
|
||||
api(projects.features.call.api)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
@ -54,9 +63,12 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(projects.features.call.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
@ -23,11 +23,17 @@
|
|||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Permissions for call foreground services -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
|
|
@ -70,10 +76,24 @@
|
|||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<activity android:name=".ui.IncomingCallActivity"
|
||||
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity="io.element.android.features.call" />
|
||||
|
||||
<service
|
||||
android:name=".CallForegroundService"
|
||||
android:name=".services.CallForegroundService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="phoneCall" />
|
||||
|
||||
<receiver android:name=".receivers.DeclineCallBroadcastReceiver"
|
||||
android:exported="false"
|
||||
android:enabled="true" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.impl
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||
import io.element.android.features.call.impl.utils.IntentProvider
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultElementCallEntryPoint @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val activeCallManager: ActiveCallManager,
|
||||
) : ElementCallEntryPoint {
|
||||
companion object {
|
||||
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
|
||||
const val REQUEST_CODE = 2255
|
||||
}
|
||||
|
||||
override fun startCall(callType: CallType) {
|
||||
context.startActivity(IntentProvider.createIntent(context, callType))
|
||||
}
|
||||
|
||||
override fun handleIncomingCall(
|
||||
callType: CallType.RoomCall,
|
||||
eventId: EventId,
|
||||
senderId: UserId,
|
||||
roomName: String?,
|
||||
senderName: String?,
|
||||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
notificationChannelId: String,
|
||||
) {
|
||||
val incomingCallNotificationData = CallNotificationData(
|
||||
sessionId = callType.sessionId,
|
||||
roomId = callType.roomId,
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
roomName = roomName,
|
||||
senderName = senderName,
|
||||
avatarUrl = avatarUrl,
|
||||
timestamp = timestamp,
|
||||
notificationChannelId = notificationChannelId,
|
||||
)
|
||||
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.data
|
||||
package io.element.android.features.call.impl.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -14,13 +14,17 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.di
|
||||
package io.element.android.features.call.impl.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
|
||||
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||
import io.element.android.features.call.impl.ui.IncomingCallActivity
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface CallBindings {
|
||||
fun inject(callActivity: ElementCallActivity)
|
||||
fun inject(callActivity: IncomingCallActivity)
|
||||
fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.impl.notifications
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class CallNotificationData(
|
||||
val sessionId: SessionId,
|
||||
val roomId: RoomId,
|
||||
val eventId: EventId,
|
||||
val senderId: UserId,
|
||||
val roomName: String?,
|
||||
val senderName: String?,
|
||||
val avatarUrl: String?,
|
||||
val notificationChannelId: String,
|
||||
val timestamp: Long,
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.element.android.features.call.impl.notifications
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioManager
|
||||
import android.media.RingtoneManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.Person
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
|
||||
import io.element.android.features.call.impl.ui.IncomingCallActivity
|
||||
import io.element.android.features.call.impl.utils.IntentProvider
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Creates a notification for a ringing call.
|
||||
*/
|
||||
class RingingCallNotificationCreator @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
private val notificationBitmapLoader: NotificationBitmapLoader,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Request code for the decline action.
|
||||
*/
|
||||
const val DECLINE_REQUEST_CODE = 1
|
||||
|
||||
/**
|
||||
* Request code for the full screen intent.
|
||||
*/
|
||||
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
|
||||
}
|
||||
|
||||
suspend fun createNotification(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId,
|
||||
senderId: UserId,
|
||||
roomName: String?,
|
||||
senderDisplayName: String,
|
||||
roomAvatarUrl: String?,
|
||||
notificationChannelId: String,
|
||||
timestamp: Long,
|
||||
): Notification? {
|
||||
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
|
||||
val imageLoader = imageLoaderHolder.get(matrixClient)
|
||||
val largeIcon = notificationBitmapLoader.getUserIcon(roomAvatarUrl, imageLoader)
|
||||
|
||||
val caller = Person.Builder()
|
||||
.setName(senderDisplayName)
|
||||
.setIcon(largeIcon)
|
||||
.setImportant(true)
|
||||
.build()
|
||||
|
||||
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId))
|
||||
|
||||
val declineIntent = PendingIntentCompat.getBroadcast(
|
||||
context,
|
||||
DECLINE_REQUEST_CODE,
|
||||
Intent(context, DeclineCallBroadcastReceiver::class.java),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
)!!
|
||||
|
||||
val fullScreenIntent = PendingIntentCompat.getActivity(
|
||||
context,
|
||||
FULL_SCREEN_INTENT_REQUEST_CODE,
|
||||
Intent(context, IncomingCallActivity::class.java).apply {
|
||||
putExtra(
|
||||
IncomingCallActivity.EXTRA_NOTIFICATION_DATA,
|
||||
CallNotificationData(sessionId, roomId, eventId, senderId, roomName, senderDisplayName, roomAvatarUrl, notificationChannelId, timestamp)
|
||||
)
|
||||
},
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false
|
||||
)
|
||||
|
||||
val ringtoneUri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
|
||||
return NotificationCompat.Builder(context, notificationChannelId)
|
||||
.setSmallIcon(CommonDrawables.ic_notification_small)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declineIntent, answerIntent).setIsVideo(true))
|
||||
.addPerson(caller)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(timestamp)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setSound(ringtoneUri, AudioManager.STREAM_RING)
|
||||
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
|
||||
.setContentIntent(answerIntent)
|
||||
.setDeleteIntent(declineIntent)
|
||||
.setFullScreenIntent(fullScreenIntent, true)
|
||||
.build()
|
||||
.apply {
|
||||
flags = flags.or(Notification.FLAG_INSISTENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.impl.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import io.element.android.features.call.impl.di.CallBindings
|
||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Broadcast receiver to decline the incoming call.
|
||||
*/
|
||||
class DeclineCallBroadcastReceiver : BroadcastReceiver() {
|
||||
@Inject
|
||||
lateinit var activeCallManager: ActiveCallManager
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
context.bindings<CallBindings>().inject(this)
|
||||
activeCallManager.hungUpCall()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
|
@ -14,30 +14,36 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
package io.element.android.features.call.impl.services
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.features.call.impl.R
|
||||
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* A foreground service that shows a notification for an ongoing call while the UI is in background.
|
||||
*/
|
||||
class CallForegroundService : Service() {
|
||||
companion object {
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, CallForegroundService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
|
|
@ -69,7 +75,17 @@ class CallForegroundService : Service() {
|
|||
.setContentText(getString(R.string.call_foreground_service_message_android))
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
startForeground(1, notification)
|
||||
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||
} else {
|
||||
0
|
||||
}
|
||||
runCatching {
|
||||
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to start ongoing call foreground service")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
@ -14,11 +14,12 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import io.element.android.features.call.utils.WidgetMessageInterceptor
|
||||
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
|
||||
|
||||
sealed interface CallScreenEvents {
|
||||
data object Hangup : CallScreenEvents
|
||||
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
|
||||
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) :
|
||||
CallScreenEvents
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
|
@ -30,11 +30,12 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.data.WidgetMessage
|
||||
import io.element.android.features.call.utils.CallWidgetProvider
|
||||
import io.element.android.features.call.utils.WidgetMessageInterceptor
|
||||
import io.element.android.features.call.utils.WidgetMessageSerializer
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.impl.data.WidgetMessage
|
||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||
import io.element.android.features.call.impl.utils.CallWidgetProvider
|
||||
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
|
||||
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
|
|
@ -65,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
|||
private val matrixClientsProvider: MatrixClientProvider,
|
||||
private val screenTracker: ScreenTracker,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val activeCallManager: ActiveCallManager,
|
||||
) : Presenter<CallScreenState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -84,6 +86,10 @@ class CallScreenPresenter @AssistedInject constructor(
|
|||
|
||||
LaunchedEffect(Unit) {
|
||||
loadUrl(callType, urlState, callWidgetDriver)
|
||||
|
||||
if (callType is CallType.RoomCall) {
|
||||
activeCallManager.joinedCall(callType.sessionId, callType.roomId)
|
||||
}
|
||||
}
|
||||
|
||||
when (callType) {
|
||||
|
|
@ -134,6 +140,14 @@ class CallScreenPresenter @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (callType is CallType.RoomCall) {
|
||||
activeCallManager.hungUpCall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: CallScreenEvents) {
|
||||
when (event) {
|
||||
is CallScreenEvents.Hangup -> {
|
||||
|
|
@ -193,7 +207,6 @@ class CallScreenPresenter @AssistedInject constructor(
|
|||
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
|
||||
matrixClientsProvider.getOrNull(it)
|
||||
} ?: return@DisposableEffect onDispose { }
|
||||
|
||||
coroutineScope.launch {
|
||||
client.syncService().syncState
|
||||
.onEach { state ->
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
|
|
@ -34,8 +34,8 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.call.R
|
||||
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
|
||||
import io.element.android.features.call.impl.R
|
||||
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -14,12 +14,10 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
|
|
@ -41,30 +39,16 @@ import io.element.android.compound.theme.ElementTheme
|
|||
import io.element.android.compound.theme.Theme
|
||||
import io.element.android.compound.theme.isDark
|
||||
import io.element.android.compound.theme.mapToTheme
|
||||
import io.element.android.features.call.CallForegroundService
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.di.CallBindings
|
||||
import io.element.android.features.call.utils.CallIntentDataParser
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.di.CallBindings
|
||||
import io.element.android.features.call.impl.services.CallForegroundService
|
||||
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import javax.inject.Inject
|
||||
|
||||
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||
companion object {
|
||||
private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS"
|
||||
|
||||
fun start(
|
||||
context: Context,
|
||||
callInputs: CallType,
|
||||
) {
|
||||
val intent = Intent(context, ElementCallActivity::class.java).apply {
|
||||
putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs)
|
||||
addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||
|
|
@ -88,7 +72,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
|||
|
||||
applicationContext.bindings<CallBindings>().inject(this)
|
||||
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
|
||||
setCallType(intent)
|
||||
|
||||
|
|
@ -157,16 +147,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
|||
}
|
||||
|
||||
private fun setCallType(intent: Intent?) {
|
||||
val inputs = intent?.let {
|
||||
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
|
||||
val callType = intent?.let {
|
||||
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
|
||||
}
|
||||
val intentUrl = intent?.dataString?.let(::parseUrl)
|
||||
when {
|
||||
// Re-opened the activity but we have no url to load or a cached one, finish the activity
|
||||
intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish()
|
||||
inputs != null -> {
|
||||
webViewTarget.value = inputs
|
||||
presenter = presenterFactory.create(inputs, this)
|
||||
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
|
||||
callType != null -> {
|
||||
webViewTarget.value = callType
|
||||
presenter = presenterFactory.create(callType, this)
|
||||
}
|
||||
intentUrl != null -> {
|
||||
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.di.CallBindings
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||
import io.element.android.features.call.impl.utils.CallState
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Activity that's displayed as a full screen intent when an incoming call is received.
|
||||
*/
|
||||
class IncomingCallActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
/**
|
||||
* Extra key for the notification data.
|
||||
*/
|
||||
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var elementCallEntryPoint: ElementCallEntryPoint
|
||||
|
||||
@Inject
|
||||
lateinit var activeCallManager: ActiveCallManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
applicationContext.bindings<CallBindings>().inject(this)
|
||||
|
||||
// Set flags so it can be displayed in the lock screen
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
|
||||
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||
if (notificationData != null) {
|
||||
setContent {
|
||||
IncomingCallScreen(
|
||||
notificationData = notificationData,
|
||||
onAnswer = ::onAnswer,
|
||||
onCancel = ::onCancel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No data, finish the activity
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
activeCallManager.activeCall
|
||||
.filter { it?.callState !is CallState.Ringing }
|
||||
.onEach { finish() }
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun onAnswer(notificationData: CallNotificationData) {
|
||||
elementCallEntryPoint.startCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||
}
|
||||
|
||||
private fun onCancel() {
|
||||
activeCallManager.hungUpCall()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.call.impl.R
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun IncomingCallScreen(
|
||||
notificationData: CallNotificationData,
|
||||
onAnswer: (CallNotificationData) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
ElementTheme {
|
||||
OnboardingBackground()
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Bottom
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 20.dp, top = 124.dp)
|
||||
.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = AvatarData(
|
||||
id = notificationData.senderId.value,
|
||||
name = notificationData.senderName,
|
||||
url = notificationData.avatarUrl,
|
||||
size = AvatarSize.IncomingCall,
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = notificationData.senderName ?: notificationData.senderId.value,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_incoming_call_subtitle_android),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ActionButton(
|
||||
size = 64.dp,
|
||||
onClick = { onAnswer(notificationData) },
|
||||
icon = CompoundIcons.VoiceCall(),
|
||||
title = stringResource(CommonStrings.action_accept),
|
||||
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
|
||||
borderColor = ElementTheme.colors.borderSuccessSubtle
|
||||
)
|
||||
|
||||
ActionButton(
|
||||
size = 64.dp,
|
||||
onClick = onCancel,
|
||||
icon = CompoundIcons.EndCall(),
|
||||
title = stringResource(CommonStrings.action_reject),
|
||||
backgroundColor = ElementTheme.colors.iconCriticalPrimary,
|
||||
borderColor = ElementTheme.colors.borderCriticalSubtle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
size: Dp,
|
||||
onClick: () -> Unit,
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
backgroundColor: Color,
|
||||
borderColor: Color,
|
||||
contentDescription: String? = title,
|
||||
borderSize: Dp = 1.33.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(120.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
FilledIconButton(
|
||||
modifier = Modifier.size(size + borderSize)
|
||||
.border(borderSize, borderColor, CircleShape),
|
||||
onClick = onClick,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = backgroundColor,
|
||||
contentColor = Color.White,
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun IncomingCallScreenPreview() {
|
||||
ElementPreview {
|
||||
IncomingCallScreen(
|
||||
notificationData = CallNotificationData(
|
||||
sessionId = SessionId("@alice:matrix.org"),
|
||||
roomId = RoomId("!1234:matrix.org"),
|
||||
eventId = EventId("\$asdadadsad:matrix.org"),
|
||||
senderId = UserId("@bob:matrix.org"),
|
||||
roomName = "A room",
|
||||
senderName = "Bob",
|
||||
avatarUrl = null,
|
||||
notificationChannelId = "incoming_call",
|
||||
timestamp = 0L,
|
||||
),
|
||||
onAnswer = {},
|
||||
onCancel = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Manages the active call state.
|
||||
*/
|
||||
interface ActiveCallManager {
|
||||
/**
|
||||
* The active call state flow, which will be updated when the active call changes.
|
||||
*/
|
||||
val activeCall: StateFlow<ActiveCall?>
|
||||
|
||||
/**
|
||||
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
|
||||
* @param notificationData The data for the incoming call notification.
|
||||
*/
|
||||
fun registerIncomingCall(notificationData: CallNotificationData)
|
||||
|
||||
/**
|
||||
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
|
||||
*/
|
||||
fun incomingCallTimedOut()
|
||||
|
||||
/**
|
||||
* Hangs up the active call and removes any associated UI.
|
||||
*/
|
||||
fun hungUpCall()
|
||||
|
||||
/**
|
||||
* Called when the user joins a call. It will remove any existing UI and set the call state as [CallState.InCall].
|
||||
*
|
||||
* @param sessionId The session ID of the user joining the call.
|
||||
* @param roomId The room ID of the call.
|
||||
*/
|
||||
fun joinedCall(sessionId: SessionId, roomId: RoomId)
|
||||
}
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultActiveCallManager @Inject constructor(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
|
||||
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
||||
private val notificationManagerCompat: NotificationManagerCompat,
|
||||
) : ActiveCallManager {
|
||||
private var timedOutCallJob: Job? = null
|
||||
|
||||
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
||||
|
||||
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||
if (activeCall.value != null) {
|
||||
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
|
||||
return
|
||||
}
|
||||
activeCall.value = ActiveCall(
|
||||
sessionId = notificationData.sessionId,
|
||||
roomId = notificationData.roomId,
|
||||
callState = CallState.Ringing(notificationData),
|
||||
)
|
||||
|
||||
timedOutCallJob = coroutineScope.launch {
|
||||
registerIncomingCall(notificationData)
|
||||
showIncomingCallNotification(notificationData)
|
||||
|
||||
// Wait for the call to end
|
||||
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
|
||||
incomingCallTimedOut()
|
||||
}
|
||||
}
|
||||
|
||||
override fun incomingCallTimedOut() {
|
||||
val previousActiveCall = activeCall.value ?: return
|
||||
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
|
||||
activeCall.value = null
|
||||
|
||||
cancelIncomingCallNotification()
|
||||
|
||||
coroutineScope.launch {
|
||||
onMissedCallNotificationHandler.addMissedCallNotification(
|
||||
sessionId = previousActiveCall.sessionId,
|
||||
roomId = previousActiveCall.roomId,
|
||||
eventId = notificationData.eventId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hungUpCall() {
|
||||
cancelIncomingCallNotification()
|
||||
timedOutCallJob?.cancel()
|
||||
activeCall.value = null
|
||||
}
|
||||
|
||||
override fun joinedCall(sessionId: SessionId, roomId: RoomId) {
|
||||
cancelIncomingCallNotification()
|
||||
timedOutCallJob?.cancel()
|
||||
|
||||
activeCall.value = ActiveCall(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
callState = CallState.InCall,
|
||||
)
|
||||
// Send call notification to the room
|
||||
coroutineScope.launch {
|
||||
matrixClientProvider.getOrRestore(sessionId)
|
||||
.getOrNull()
|
||||
?.getRoom(roomId)
|
||||
?.sendCallNotificationIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
|
||||
val notification = ringingCallNotificationCreator.createNotification(
|
||||
sessionId = notificationData.sessionId,
|
||||
roomId = notificationData.roomId,
|
||||
eventId = notificationData.eventId,
|
||||
senderId = notificationData.senderId,
|
||||
roomName = notificationData.roomName,
|
||||
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
|
||||
roomAvatarUrl = notificationData.avatarUrl,
|
||||
notificationChannelId = notificationData.notificationChannelId,
|
||||
timestamp = notificationData.timestamp
|
||||
) ?: return
|
||||
runCatching {
|
||||
notificationManagerCompat.notify(
|
||||
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
|
||||
notification,
|
||||
)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to publish notification for incoming call")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelIncomingCallNotification() {
|
||||
notificationManagerCompat.cancel(NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an active call.
|
||||
*/
|
||||
data class ActiveCall(
|
||||
val sessionId: SessionId,
|
||||
val roomId: RoomId,
|
||||
val callState: CallState,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the state of an active call.
|
||||
*/
|
||||
sealed interface CallState {
|
||||
/**
|
||||
* The call is in a ringing state.
|
||||
* @param notificationData The data for the incoming call notification.
|
||||
*/
|
||||
data class Ringing(val notificationData: CallNotificationData) : CallState
|
||||
|
||||
/**
|
||||
* The call is in an in-call state.
|
||||
*/
|
||||
data object InCall : CallState
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import android.net.Uri
|
||||
import javax.inject.Inject
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||
|
||||
internal object IntentProvider {
|
||||
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
|
||||
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
|
||||
}
|
||||
|
||||
fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
|
||||
return PendingIntentCompat.getActivity(
|
||||
context,
|
||||
DefaultElementCallEntryPoint.REQUEST_CODE,
|
||||
createIntent(context, callType),
|
||||
0,
|
||||
false
|
||||
)!!
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.JavascriptInterface
|
||||
|
|
@ -22,7 +22,7 @@ import android.webkit.WebView
|
|||
import android.webkit.WebViewClient
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import io.element.android.features.call.BuildConfig
|
||||
import io.element.android.features.call.impl.BuildConfig
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
class WebViewWidgetMessageInterceptor(
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
|
|
@ -14,9 +14,9 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import io.element.android.features.call.data.WidgetMessage
|
||||
import io.element.android.features.call.impl.data.WidgetMessage
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object WidgetMessageSerializer {
|
||||
|
|
@ -3,4 +3,5 @@
|
|||
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
|
||||
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
|
||||
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
|
||||
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||
import io.element.android.features.call.utils.FakeActiveCallManager
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DefaultElementCallEntryPointTest {
|
||||
@Test
|
||||
fun `startCall - starts ElementCallActivity setup with the needed extras`() {
|
||||
val entryPoint = createEntryPoint()
|
||||
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
|
||||
|
||||
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
|
||||
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
|
||||
assertThat(intent.component).isEqualTo(expectedIntent.component)
|
||||
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
|
||||
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
|
||||
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
|
||||
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
|
||||
|
||||
entryPoint.handleIncomingCall(
|
||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = A_USER_ID_2,
|
||||
roomName = "roomName",
|
||||
senderName = "senderName",
|
||||
avatarUrl = "avatarUrl",
|
||||
timestamp = 0,
|
||||
notificationChannelId = "notificationChannelId",
|
||||
)
|
||||
|
||||
registerIncomingCallLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun createEntryPoint(
|
||||
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||
) = DefaultElementCallEntryPoint(
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
activeCallManager = activeCallManager,
|
||||
)
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.call
|
|||
import android.Manifest
|
||||
import android.webkit.PermissionRequest
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.ui.mapWebkitPermissions
|
||||
import io.element.android.features.call.impl.ui.mapWebkitPermissions
|
||||
import org.junit.Test
|
||||
|
||||
class MapWebkitPermissionsTest {
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.notifications
|
||||
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import coil.ImageLoader
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
|
||||
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class RingingCallNotificationCreatorTest {
|
||||
@Test
|
||||
fun `createNotification - with no associated MatrixClient does nothing`() = runTest {
|
||||
val notificationCreator = createRingingCallNotificationCreator(
|
||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("No client found")) })
|
||||
)
|
||||
|
||||
val result = notificationCreator.createTestNotification()
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createNotification - creates a valid notification`() = runTest {
|
||||
val notificationCreator = createRingingCallNotificationCreator(
|
||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) })
|
||||
)
|
||||
|
||||
val result = notificationCreator.createTestNotification()
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createNotification - tries to load the avatar URL`() = runTest {
|
||||
val getUserIconLambda = lambdaRecorder<String?, ImageLoader, IconCompat?> { _, _ -> null }
|
||||
val notificationCreator = createRingingCallNotificationCreator(
|
||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
|
||||
notificationBitmapLoader = FakeNotificationBitmapLoader(getUserIconResult = getUserIconLambda)
|
||||
)
|
||||
|
||||
notificationCreator.createTestNotification()
|
||||
|
||||
getUserIconLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private suspend fun RingingCallNotificationCreator.createTestNotification() = createNotification(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = A_USER_ID_2,
|
||||
roomName = "Room",
|
||||
senderDisplayName = "Johnnie Murphy",
|
||||
roomAvatarUrl = "https://example.com/avatar.jpg",
|
||||
notificationChannelId = "channelId",
|
||||
timestamp = 0L,
|
||||
)
|
||||
|
||||
private fun createRingingCallNotificationCreator(
|
||||
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||
imageLoaderHolder: FakeImageLoaderHolder = FakeImageLoaderHolder(),
|
||||
notificationBitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(),
|
||||
) = RingingCallNotificationCreator(
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
imageLoaderHolder = imageLoaderHolder,
|
||||
notificationBitmapLoader = notificationBitmapLoader,
|
||||
)
|
||||
}
|
||||
|
|
@ -21,7 +21,11 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.impl.ui.CallScreenEvents
|
||||
import io.element.android.features.call.impl.ui.CallScreenNavigator
|
||||
import io.element.android.features.call.impl.ui.CallScreenPresenter
|
||||
import io.element.android.features.call.utils.FakeActiveCallManager
|
||||
import io.element.android.features.call.utils.FakeCallWidgetProvider
|
||||
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
|
@ -254,6 +258,7 @@ class CallScreenPresenterTest {
|
|||
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||
screenTracker: ScreenTracker = FakeScreenTracker(),
|
||||
): CallScreenPresenter {
|
||||
val userAgentProvider = object : UserAgentProvider {
|
||||
|
|
@ -270,8 +275,9 @@ class CallScreenPresenterTest {
|
|||
clock = clock,
|
||||
dispatchers = dispatchers,
|
||||
matrixClientsProvider = matrixClientsProvider,
|
||||
screenTracker = screenTracker,
|
||||
appCoroutineScope = this,
|
||||
activeCallManager = activeCallManager,
|
||||
screenTracker = screenTracker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
import io.element.android.features.call.impl.ui.CallScreenNavigator
|
||||
|
||||
class FakeCallScreenNavigator : CallScreenNavigator {
|
||||
var closeCalled = false
|
||||
private set
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.call.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||
import io.element.android.features.call.impl.utils.ActiveCall
|
||||
import io.element.android.features.call.impl.utils.CallState
|
||||
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
|
||||
import io.element.android.features.call.test.aCallNotificationData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
|
||||
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
|
||||
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DefaultActiveCallManagerTest {
|
||||
private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
|
||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
|
||||
|
||||
assertThat(manager.activeCall.value).isNull()
|
||||
|
||||
val callNotificationData = aCallNotificationData()
|
||||
manager.registerIncomingCall(callNotificationData)
|
||||
|
||||
assertThat(manager.activeCall.value).isEqualTo(
|
||||
ActiveCall(
|
||||
sessionId = callNotificationData.sessionId,
|
||||
roomId = callNotificationData.roomId,
|
||||
callState = CallState.Ringing(callNotificationData)
|
||||
)
|
||||
)
|
||||
|
||||
runCurrent()
|
||||
|
||||
verify { notificationManagerCompat.notify(notificationId, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `registerIncomingCall - when there is an already active call does nothing`() = runTest {
|
||||
val manager = createActiveCallManager()
|
||||
|
||||
// Register existing call
|
||||
val callNotificationData = aCallNotificationData()
|
||||
manager.registerIncomingCall(callNotificationData)
|
||||
val activeCall = manager.activeCall.value
|
||||
|
||||
// Now add a new call
|
||||
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
|
||||
|
||||
assertThat(manager.activeCall.value).isEqualTo(activeCall)
|
||||
assertThat(manager.activeCall.value?.roomId).isNotEqualTo(A_ROOM_ID_2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
|
||||
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
||||
val manager = createActiveCallManager(
|
||||
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
|
||||
)
|
||||
|
||||
manager.incomingCallTimedOut()
|
||||
|
||||
addMissedCallNotificationLambda.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
|
||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
||||
val manager = createActiveCallManager(
|
||||
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
|
||||
notificationManagerCompat = notificationManagerCompat,
|
||||
)
|
||||
|
||||
manager.registerIncomingCall(aCallNotificationData())
|
||||
assertThat(manager.activeCall.value).isNotNull()
|
||||
|
||||
manager.incomingCallTimedOut()
|
||||
assertThat(manager.activeCall.value).isNull()
|
||||
|
||||
runCurrent()
|
||||
|
||||
addMissedCallNotificationLambda.assertions().isCalledOnce()
|
||||
|
||||
verify { notificationManagerCompat.cancel(notificationId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hungUpCall - removes existing call`() = runTest {
|
||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
|
||||
|
||||
manager.registerIncomingCall(aCallNotificationData())
|
||||
assertThat(manager.activeCall.value).isNotNull()
|
||||
|
||||
manager.hungUpCall()
|
||||
assertThat(manager.activeCall.value).isNull()
|
||||
|
||||
verify { notificationManagerCompat.cancel(notificationId) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
|
||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||
val sendCallNotifyLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val room = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotifyLambda)
|
||||
val client = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val manager = createActiveCallManager(
|
||||
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
|
||||
notificationManagerCompat = notificationManagerCompat,
|
||||
)
|
||||
assertThat(manager.activeCall.value).isNull()
|
||||
|
||||
manager.joinedCall(A_SESSION_ID, A_ROOM_ID)
|
||||
assertThat(manager.activeCall.value).isEqualTo(
|
||||
ActiveCall(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
callState = CallState.InCall,
|
||||
)
|
||||
)
|
||||
|
||||
runCurrent()
|
||||
|
||||
sendCallNotifyLambda.assertions().isCalledOnce()
|
||||
verify { notificationManagerCompat.cancel(notificationId) }
|
||||
}
|
||||
|
||||
private fun TestScope.createActiveCallManager(
|
||||
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
|
||||
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
|
||||
) = DefaultActiveCallManager(
|
||||
coroutineScope = this,
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
|
||||
ringingCallNotificationCreator = RingingCallNotificationCreator(
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
imageLoaderHolder = FakeImageLoaderHolder(),
|
||||
notificationBitmapLoader = FakeNotificationBitmapLoader(),
|
||||
),
|
||||
notificationManagerCompat = notificationManagerCompat,
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.call.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
|
||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.utils.ActiveCall
|
||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FakeActiveCallManager(
|
||||
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
|
||||
var incomingCallTimedOutResult: () -> Unit = {},
|
||||
var hungUpCallResult: () -> Unit = {},
|
||||
var joinedCallResult: (SessionId, RoomId) -> Unit = { _, _ -> },
|
||||
) : ActiveCallManager {
|
||||
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
||||
|
||||
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||
registerIncomingCallResult(notificationData)
|
||||
}
|
||||
|
||||
override fun incomingCallTimedOut() {
|
||||
incomingCallTimedOutResult()
|
||||
}
|
||||
|
||||
override fun hungUpCall() {
|
||||
hungUpCallResult()
|
||||
}
|
||||
|
||||
override fun joinedCall(sessionId: SessionId, roomId: RoomId) {
|
||||
joinedCallResult(sessionId, roomId)
|
||||
}
|
||||
|
||||
fun setActiveCall(value: ActiveCall?) {
|
||||
this.activeCall.value = value
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import io.element.android.features.call.impl.utils.CallWidgetProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
|
||||
34
features/call/test/build.gradle.kts
Normal file
34
features/call/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.call.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
|
||||
api(projects.features.call.api)
|
||||
implementation(projects.features.call.impl)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrix.test)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.test
|
||||
|
||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
|
||||
fun aCallNotificationData(
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
senderId: UserId = A_USER_ID_2,
|
||||
roomName: String = A_ROOM_NAME,
|
||||
senderName: String? = A_USER_NAME,
|
||||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
notificationChannelId: String = "channel_id",
|
||||
timestamp: Long = 0L,
|
||||
): CallNotificationData = CallNotificationData(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
roomName = roomName,
|
||||
senderName = senderName,
|
||||
avatarUrl = avatarUrl,
|
||||
notificationChannelId = notificationChannelId,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.test
|
||||
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
class FakeElementCallEntryPoint(
|
||||
var startCallResult: (CallType) -> Unit = {},
|
||||
var handleIncomingCallResult: (CallType.RoomCall, EventId, UserId, String?, String?, String?, String) -> Unit = { _, _, _, _, _, _, _ -> }
|
||||
) : ElementCallEntryPoint {
|
||||
override fun startCall(callType: CallType) {
|
||||
startCallResult(callType)
|
||||
}
|
||||
|
||||
override fun handleIncomingCall(
|
||||
callType: CallType.RoomCall,
|
||||
eventId: EventId,
|
||||
senderId: UserId,
|
||||
roomName: String?,
|
||||
senderName: String?,
|
||||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
notificationChannelId: String
|
||||
) {
|
||||
handleIncomingCallResult(callType, eventId, senderId, roomName, senderName, avatarUrl, notificationChannelId)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ dependencies {
|
|||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.messages.api)
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.call)
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.location.api)
|
||||
implementation(projects.features.poll.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -31,8 +30,8 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||
|
|
@ -58,7 +57,6 @@ import io.element.android.libraries.architecture.createNode
|
|||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -78,11 +76,11 @@ import kotlinx.parcelize.Parcelize
|
|||
class MessagesFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
|
|
@ -188,12 +186,12 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onJoinCallClick(roomId: RoomId) {
|
||||
val inputs = CallType.RoomCall(
|
||||
val callType = CallType.RoomCall(
|
||||
sessionId = matrixClient.sessionId,
|
||||
roomId = roomId,
|
||||
)
|
||||
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
|
||||
ElementCallActivity.start(context, inputs)
|
||||
elementCallEntryPoint.startCall(callType)
|
||||
}
|
||||
}
|
||||
val inputs = MessagesNode.Inputs(
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ class TimelineControllerTest {
|
|||
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
|
||||
val sut = TimelineController(matrixRoom)
|
||||
sut.activeTimelineFlow().test {
|
||||
sut.focusOnEvent(AN_EVENT_ID)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state).isEqualTo(liveTimeline)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ dependencies {
|
|||
api(projects.libraries.usersearch.api)
|
||||
api(projects.services.apperror.api)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(projects.features.call)
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.createroom.api)
|
||||
implementation(projects.features.leaveroom.api)
|
||||
implementation(projects.features.userprofile.shared)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
package io.element.android.features.roomdetails.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -30,8 +29,8 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
|
||||
|
|
@ -46,7 +45,6 @@ import io.element.android.libraries.architecture.BackstackView
|
|||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -62,8 +60,8 @@ import kotlinx.parcelize.Parcelize
|
|||
class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val room: MatrixRoom,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||
|
|
@ -147,7 +145,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
roomId = room.roomId,
|
||||
)
|
||||
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
|
||||
ElementCallActivity.start(context, inputs)
|
||||
elementCallEntryPoint.startCall(inputs)
|
||||
}
|
||||
}
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||
|
|
@ -195,7 +193,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onStartCall(dmRoomId: RoomId) {
|
||||
ElementCallActivity.start(context, CallType.RoomCall(sessionId = room.sessionId, roomId = dmRoomId))
|
||||
elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId))
|
||||
}
|
||||
}
|
||||
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,10 @@ private fun Content(desktopApplicationName: String) {
|
|||
add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2)))
|
||||
add(
|
||||
annotatedTextWithBold(
|
||||
text = stringResource(R.string.screen_create_new_recovery_key_list_item_3),
|
||||
text = stringResource(
|
||||
id = R.string.screen_create_new_recovery_key_list_item_3,
|
||||
stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
|
||||
),
|
||||
boldText = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.features.call)
|
||||
implementation(projects.features.call.api)
|
||||
api(projects.features.userprofile.api)
|
||||
api(projects.features.userprofile.shared)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
package io.element.android.features.userprofile.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -29,8 +28,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
||||
|
|
@ -40,7 +39,6 @@ import io.element.android.libraries.architecture.BaseFlowNode
|
|||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
|
@ -53,7 +51,7 @@ import kotlinx.parcelize.Parcelize
|
|||
class UserProfileFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val sessionIdHolder: CurrentSessionIdHolder,
|
||||
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
|
|
@ -84,7 +82,7 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onStartCall(dmRoomId: RoomId) {
|
||||
ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId))
|
||||
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId))
|
||||
}
|
||||
}
|
||||
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue