diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2821fcbd04..839a5095dd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -198,6 +198,7 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
+ implementation(projects.features.call)
implementation(projects.anvilannotations)
implementation(projects.appnav)
anvil(projects.anvilcodegen)
diff --git a/changelog.d/1300.feature b/changelog.d/1300.feature
new file mode 100644
index 0000000000..bfa40bfc3b
--- /dev/null
+++ b/changelog.d/1300.feature
@@ -0,0 +1 @@
+Integrate Element Call into EX by embedding a call in a WebView.
diff --git a/features/call/build.gradle.kts b/features/call/build.gradle.kts
new file mode 100644
index 0000000000..69046e33b4
--- /dev/null
+++ b/features/call/build.gradle.kts
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 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")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.features.call"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.network)
+ implementation(libs.androidx.webkit)
+ ksp(libs.showkase.processor)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.robolectric)
+}
diff --git a/features/call/src/main/AndroidManifest.xml b/features/call/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..1aed77cd95
--- /dev/null
+++ b/features/call/src/main/AndroidManifest.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt
new file mode 100644
index 0000000000..12355290e3
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2023 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.app.Service
+import android.content.Context
+import android.content.Intent
+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.graphics.drawable.IconCompat
+import io.element.android.libraries.designsystem.utils.CommonDrawables
+
+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)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, CallForegroundService::class.java)
+ context.stopService(intent)
+ }
+ }
+
+ private lateinit var notificationManagerCompat: NotificationManagerCompat
+
+ override fun onCreate() {
+ super.onCreate()
+
+ notificationManagerCompat = NotificationManagerCompat.from(this)
+
+ val foregroundServiceChannel = NotificationChannelCompat.Builder(
+ "call_foreground_service_channel",
+ NotificationManagerCompat.IMPORTANCE_LOW,
+ ).setName(
+ getString(R.string.call_foreground_service_channel_title_android).ifEmpty { "Ongoing call" }
+ ).build()
+ notificationManagerCompat.createNotificationChannel(foregroundServiceChannel)
+
+ val callActivityIntent = Intent(this, ElementCallActivity::class.java)
+ val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false)
+ val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id)
+ .setSmallIcon(IconCompat.createWithResource(this, CommonDrawables.ic_notification_small))
+ .setContentTitle(getString(R.string.call_foreground_service_title_android))
+ .setContentText(getString(R.string.call_foreground_service_message_android))
+ .setContentIntent(pendingIntent)
+ .build()
+ startForeground(1, notification)
+ }
+
+ @Suppress("DEPRECATION")
+ override fun onDestroy() {
+ super.onDestroy()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ stopForeground(true)
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt
new file mode 100644
index 0000000000..a664e562f3
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2023 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.net.Uri
+import java.net.URLDecoder
+
+object CallIntentDataParser {
+
+ private val validHttpSchemes = sequenceOf("http", "https")
+
+ fun parse(data: String?): String? {
+ val parsedUrl = data?.let { Uri.parse(data) } ?: return null
+ val scheme = parsedUrl.scheme
+ return when {
+ scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> data
+ scheme == "element" && parsedUrl.host == "call" -> {
+ // We use this custom scheme to load arbitrary URLs for other instances of Element Call,
+ // so we can only verify it's an HTTP/HTTPs URL with a non-empty host
+ parsedUrl.getQueryParameter("url")
+ ?.let { URLDecoder.decode(it, "utf-8") }
+ ?.takeIf {
+ val internalUri = Uri.parse(it)
+ internalUri.scheme in validHttpSchemes && !internalUri.host.isNullOrBlank()
+ }
+ }
+ // This should never be possible, but we still need to take into account the possibility
+ else -> null
+ }
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt
new file mode 100644
index 0000000000..08ad687f91
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2023 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.annotation.SuppressLint
+import android.view.ViewGroup
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
+import android.webkit.WebView
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.theme.ElementTheme
+
+typealias RequestPermissionCallback = (Array) -> Unit
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun CallScreenView(
+ url: String?,
+ userAgent: String,
+ requestPermissions: (Array, RequestPermissionCallback) -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ElementTheme {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.element_call)) },
+ navigationIcon = {
+ BackButton(
+ imageVector = Icons.Default.Close,
+ onClick = onClose
+ )
+ }
+ )
+ }
+ ) { padding ->
+ CallWebView(
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .fillMaxSize(),
+ url = url,
+ userAgent = userAgent,
+ onPermissionsRequested = { request ->
+ val androidPermissions = mapWebkitPermissions(request.resources)
+ val callback: RequestPermissionCallback = { request.grant(it) }
+ requestPermissions(androidPermissions.toTypedArray(), callback)
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun CallWebView(
+ url: String?,
+ userAgent: String,
+ onPermissionsRequested: (PermissionRequest) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val isInpectionMode = LocalInspectionMode.current
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ WebView(context).apply {
+ if (!isInpectionMode) {
+ setup(userAgent, onPermissionsRequested)
+ if (url != null) {
+ loadUrl(url)
+ }
+ }
+ }
+ },
+ update = { webView ->
+ if (!isInpectionMode && url != null) {
+ webView.loadUrl(url)
+ }
+ },
+ onRelease = { webView ->
+ webView.destroy()
+ }
+ )
+}
+
+@SuppressLint("SetJavaScriptEnabled")
+private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+
+ with(settings) {
+ javaScriptEnabled = true
+ allowContentAccess = true
+ allowFileAccess = true
+ domStorageEnabled = true
+ mediaPlaybackRequiresUserGesture = false
+ databaseEnabled = true
+ loadsImagesAutomatically = true
+ userAgentString = userAgent
+ }
+
+ webChromeClient = object : WebChromeClient() {
+ override fun onPermissionRequest(request: PermissionRequest) {
+ onPermissionsRequested(request)
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+internal fun CallScreenViewPreview() {
+ ElementTheme {
+ CallScreenView(
+ url = "https://call.element.io/some-actual-call?with=parameters",
+ userAgent = "",
+ requestPermissions = { _, _ -> },
+ onClose = { },
+ )
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt b/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt
new file mode 100644
index 0000000000..69ef3963cb
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (c) 2023 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.Manifest
+import android.content.Intent
+import android.content.res.Configuration
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Build
+import android.os.Bundle
+import android.view.WindowManager
+import android.webkit.PermissionRequest
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.mutableStateOf
+import io.element.android.features.call.di.CallBindings
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.network.useragent.UserAgentProvider
+import javax.inject.Inject
+
+class ElementCallActivity : ComponentActivity() {
+
+ @Inject lateinit var userAgentProvider: UserAgentProvider
+
+ private lateinit var audioManager: AudioManager
+
+ private var requestPermissionCallback: RequestPermissionCallback? = null
+
+ private var audiofocusRequest: AudioFocusRequest? = null
+ private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
+
+ private val requestPermissionsLauncher = registerPermissionResultLauncher()
+
+ private var isDarkMode = false
+ private val urlState = mutableStateOf(null)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ applicationContext.bindings().inject(this)
+
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ urlState.value = intent?.dataString?.let(::parseUrl) ?: run {
+ finish()
+ return
+ }
+
+ if (savedInstanceState == null) {
+ updateUiMode(resources.configuration)
+ }
+
+ audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
+ requestAudioFocus()
+
+ val userAgent = userAgentProvider.provide()
+
+ setContent {
+ CallScreenView(
+ url = urlState.value,
+ userAgent = userAgent,
+ onClose = this::finish,
+ requestPermissions = { permissions, callback ->
+ requestPermissionCallback = callback
+ requestPermissionsLauncher.launch(permissions)
+ }
+ )
+ }
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+
+ updateUiMode(newConfig)
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+
+ val intentUrl = intent?.dataString?.let(::parseUrl)
+ when {
+ // New URL, update it and reload the webview
+ intentUrl != null -> urlState.value = intentUrl
+ // Re-opened the activity but we have no url to load or a cached one, finish the activity
+ intent?.dataString == null && urlState.value == null -> finish()
+ // Coming back from notification, do nothing
+ else -> return
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ CallForegroundService.stop(this)
+ }
+
+ override fun onStop() {
+ super.onStop()
+ if (!isFinishing && !isChangingConfigurations) {
+ CallForegroundService.start(this)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ releaseAudioFocus()
+ CallForegroundService.stop(this)
+ }
+
+ override fun finish() {
+ // Also remove the task from recents
+ finishAndRemoveTask()
+ }
+
+ private fun parseUrl(url: String?): String? = CallIntentDataParser.parse(url)
+
+ private fun registerPermissionResultLauncher(): ActivityResultLauncher> {
+ return registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ val callback = requestPermissionCallback ?: return@registerForActivityResult
+ val permissionsToGrant = mutableListOf()
+ permissions.forEach { (permission, granted) ->
+ if (granted) {
+ val webKitPermission = when (permission) {
+ Manifest.permission.CAMERA -> PermissionRequest.RESOURCE_VIDEO_CAPTURE
+ Manifest.permission.RECORD_AUDIO -> PermissionRequest.RESOURCE_AUDIO_CAPTURE
+ else -> return@forEach
+ }
+ permissionsToGrant.add(webKitPermission)
+ }
+ }
+ callback(permissionsToGrant.toTypedArray())
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun requestAudioFocus() {
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .build()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+ .setAudioAttributes(audioAttributes)
+ .build()
+ audioManager.requestAudioFocus(request)
+ audiofocusRequest = request
+ } else {
+ val listener = AudioManager.OnAudioFocusChangeListener { }
+ audioManager.requestAudioFocus(
+ listener,
+ AudioManager.STREAM_VOICE_CALL,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
+ )
+
+ audioFocusChangeListener = listener
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun releaseAudioFocus() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ audiofocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
+ } else {
+ audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) }
+ }
+ }
+
+ private fun updateUiMode(configuration: Configuration) {
+ val prevDarkMode = isDarkMode
+ val currentNightMode = configuration.uiMode and Configuration.UI_MODE_NIGHT_YES
+ isDarkMode = currentNightMode != 0
+ if (prevDarkMode != isDarkMode) {
+ if (isDarkMode) {
+ window.setBackgroundDrawableResource(android.R.drawable.screen_background_dark)
+ } else {
+ window.setBackgroundDrawableResource(android.R.drawable.screen_background_light)
+ }
+ }
+ }
+}
+
+internal fun mapWebkitPermissions(permissions: Array): List {
+ return permissions.mapNotNull { permission ->
+ when (permission) {
+ PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO
+ PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA
+ else -> null
+ }
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt b/features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt
new file mode 100644
index 0000000000..1e261cc225
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 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.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import io.element.android.features.call.ElementCallActivity
+import io.element.android.libraries.di.AppScope
+
+@ContributesTo(AppScope::class)
+interface CallBindings {
+ fun inject(callActivity: ElementCallActivity)
+}
diff --git a/features/call/src/main/res/values-fr/translations.xml b/features/call/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..e35473b31b
--- /dev/null
+++ b/features/call/src/main/res/values-fr/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Appel en cours"
+ "Appuyez pour retourner à l\'appel."
+ "☎️ Appel en cours"
+
diff --git a/features/call/src/main/res/values/do_not_translate.xml b/features/call/src/main/res/values/do_not_translate.xml
new file mode 100644
index 0000000000..c1fe10cdfb
--- /dev/null
+++ b/features/call/src/main/res/values/do_not_translate.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ Element Call
+
diff --git a/features/call/src/main/res/values/localazy.xml b/features/call/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..cfe40526f4
--- /dev/null
+++ b/features/call/src/main/res/values/localazy.xml
@@ -0,0 +1,6 @@
+
+
+ "Ongoing call"
+ "Tap to return to the call"
+ "☎️ Call in progress"
+
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt b/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt
new file mode 100644
index 0000000000..da41692b40
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2023 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 com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.net.URLEncoder
+
+@RunWith(RobolectricTestRunner::class)
+class CallIntentDataParserTests {
+
+ @Test
+ fun `a null data returns null`() {
+ val url: String? = null
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `empty data returns null`() {
+ val url = ""
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `invalid data returns null`() {
+ val url = "!"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `data with no scheme returns null`() {
+ val url = "test"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `Element Call urls will be returned as is`() {
+ val httpBaseUrl = "http://call.element.io"
+ val httpCallUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val httpsBaseUrl = "https://call.element.io"
+ val httpsCallUrl = "https://call.element.io/some-actual-call?with=parameters"
+ assertThat(CallIntentDataParser.parse(httpBaseUrl)).isEqualTo(httpBaseUrl)
+ assertThat(CallIntentDataParser.parse(httpCallUrl)).isEqualTo(httpCallUrl)
+ assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isEqualTo(httpsBaseUrl)
+ assertThat(CallIntentDataParser.parse(httpsCallUrl)).isEqualTo(httpsCallUrl)
+ }
+
+ @Test
+ fun `HTTP and HTTPS urls that don't come from EC return null`() {
+ val httpBaseUrl = "http://app.element.io"
+ val httpsBaseUrl = "https://app.element.io"
+ val httpInvalidUrl = "http://"
+ val httpsInvalidUrl = "http://"
+ assertThat(CallIntentDataParser.parse(httpBaseUrl)).isNull()
+ assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isNull()
+ assertThat(CallIntentDataParser.parse(httpInvalidUrl)).isNull()
+ assertThat(CallIntentDataParser.parse(httpsInvalidUrl)).isNull()
+ }
+
+ @Test
+ fun `element scheme with call host and url param gets url extracted`() {
+ val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
+ val url = "element://call?url=$encodedUrl"
+ assertThat(CallIntentDataParser.parse(url)).isEqualTo(embeddedUrl)
+ }
+
+ @Test
+ fun `element scheme with call host and no url param returns null`() {
+ val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
+ val url = "element://call?no-url=$encodedUrl"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `element scheme with no call host returns null`() {
+ val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
+ val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
+ val url = "element://no-call?url=$encodedUrl"
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+
+ @Test
+ fun `element scheme with no data returns null`() {
+ val url = "element://call?url="
+ assertThat(CallIntentDataParser.parse(url)).isNull()
+ }
+}
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
new file mode 100644
index 0000000000..f82e31c068
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2023 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.Manifest
+import android.webkit.PermissionRequest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class MapWebkitPermissionsTest {
+
+ @Test
+ fun `given Webkit's RESOURCE_AUDIO_CAPTURE returns Android's RECORD_AUDIO permission`() {
+ val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))
+ assertThat(permission).isEqualTo(listOf(Manifest.permission.RECORD_AUDIO))
+ }
+
+ @Test
+ fun `given Webkit's RESOURCE_VIDEO_CAPTURE returns Android's CAMERA permission`() {
+ val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
+ assertThat(permission).isEqualTo(listOf(Manifest.permission.CAMERA))
+ }
+
+ @Test
+ fun `given any other permission, it returns nothing`() {
+ val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID))
+ assertThat(permission).isEqualTo(emptyList())
+ }
+
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ebd4f4c888..b164dfbb95 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -91,6 +91,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
androidx_preference = "androidx.preference:preference:1.2.1"
+androidx_webkit = "androidx.webkit:webkit:1.8.0"
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
# Warning: issue on alpha07, make sure this is working when upgrading
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonResources.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonResources.kt
new file mode 100644
index 0000000000..adcfd93af8
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonResources.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2023 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.libraries.designsystem.utils
+
+import io.element.android.libraries.designsystem.R
+
+typealias CommonDrawables = R.drawable
diff --git a/libraries/designsystem/src/main/res/drawable/ic_notification_small.xml b/libraries/designsystem/src/main/res/drawable/ic_notification_small.xml
new file mode 100644
index 0000000000..cf84d679cd
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_notification_small.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts
index b961146e78..c7e3251ffb 100644
--- a/libraries/push/impl/build.gradle.kts
+++ b/libraries/push/impl/build.gradle.kts
@@ -38,6 +38,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
+ implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.network)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt
index b359f540f8..105b5789e4 100755
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt
@@ -25,6 +25,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -67,7 +68,7 @@ class NotificationFactory @Inject constructor(
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)
}
- val smallIcon = R.drawable.ic_notification
+ val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing)
return NotificationCompat.Builder(context, channelId)
@@ -141,7 +142,7 @@ class NotificationFactory @Inject constructor(
inviteNotifiableEvent: InviteNotifiableEvent
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
- val smallIcon = R.drawable.ic_notification
+ val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
@@ -185,7 +186,7 @@ class NotificationFactory @Inject constructor(
simpleNotifiableEvent: SimpleNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
- val smallIcon = R.drawable.ic_notification
+ val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
@@ -220,7 +221,7 @@ class NotificationFactory @Inject constructor(
fallbackNotifiableEvent: FallbackNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
- val smallIcon = R.drawable.ic_notification
+ val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(false)
return NotificationCompat.Builder(context, channelId)
@@ -261,7 +262,7 @@ class NotificationFactory @Inject constructor(
lastMessageTimestamp: Long
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
- val smallIcon = R.drawable.ic_notification
+ val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
@@ -301,7 +302,7 @@ class NotificationFactory @Inject constructor(
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
- .setSmallIcon(R.drawable.ic_notification)
+ .setSmallIcon(CommonDrawables.ic_notification_small)
.setLargeIcon(getBitmap(R.drawable.element_logo_green))
.setColor(ContextCompat.getColor(context, R.color.notification_accent_color))
.setPriority(NotificationCompat.PRIORITY_MAX)
diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png
deleted file mode 100644
index a86508b71b..0000000000
Binary files a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png and /dev/null differ
diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml
index 0ff43bceb7..271b614dc4 100644
--- a/libraries/ui-strings/src/main/res/values-cs/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml
@@ -195,9 +195,9 @@
"Další nastavení"
"Halsové a video hovory"
"Neshoda konfigurace"
- "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností.
+ "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností.
-Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní.
+Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní.
Pokud budete pokračovat, některá nastavení se mohou změnit."
"Přímé zprávy"
diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml
index 67b738cc21..4978cdb68c 100644
--- a/libraries/ui-strings/src/main/res/values-fr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml
@@ -67,9 +67,6 @@
"Prendre une photo"
"Afficher la source"
"Oui"
- "Appel en cours"
- "Appuyez pour retourner à l\'appel."
- "☎️ Appel en cours"
"À propos"
"Politique d\'utilisation acceptable"
"Paramètres avancés"
diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml
index 798515fa6a..d37e28dc7a 100644
--- a/libraries/ui-strings/src/main/res/values-ru/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml
@@ -194,9 +194,9 @@
"Дополнительные параметры"
"Аудио и видео звонки"
"Несоответствие конфигурации"
- "Мы упростили настройки уведомлений, чтобы упростить поиск опций.
+ "Мы упростили настройки уведомлений, чтобы упростить поиск опций.
-Некоторые пользовательские настройки, выбранные вами ранее, не отображаются в данном меню, но они все еще активны.
+Некоторые пользовательские настройки, выбранные вами ранее, не отображаются в данном меню, но они все еще активны.
Если вы продолжите, некоторые настройки могут быть изменены."
"Прямые чаты"
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 2355e4b603..0cd0b7df0e 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -67,9 +67,6 @@
"Take photo"
"View Source"
"Yes"
- "Ongoing call"
- "Tap to return to the call"
- "☎️ Call in progress"
"About"
"Acceptable use policy"
"Advanced settings"
@@ -207,6 +204,11 @@
"This is the beginning of this conversation."
"New"
"Share analytics data"
+ "Display name"
+ "Your display name"
+ "An unknown error was encountered and the information couldn\'t be changed."
+ "Unable to update profile"
+ "Updating profile…"
"Failed selecting media, please try again."
"Failed processing media to upload, please try again."
"Failed uploading media, please try again."
diff --git a/tests/uitests/build.gradle.kts b/tests/uitests/build.gradle.kts
index ffa3e50aff..9556d653bf 100644
--- a/tests/uitests/build.gradle.kts
+++ b/tests/uitests/build.gradle.kts
@@ -52,6 +52,7 @@ dependencies {
// `testOptions { unitTests.isIncludeAndroidResources = true }` in the app build.gradle.kts file
// implementation(projects.app)
implementation(projects.appnav)
+ implementation(projects.features.call)
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..9fdfd38cee
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:26df292602cdf27ddd42b3b4c1dcc3fc7ae41e207af48d76c7b65bd66babf649
+size 10561
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..e524d75c96
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f28b5214727111f39cef9a1d625469f30481fe2cbbe02df0efc53807a968d210
+size 9787
diff --git a/tools/lint/lint.xml b/tools/lint/lint.xml
index db1a20701c..ce49e50a7a 100644
--- a/tools/lint/lint.xml
+++ b/tools/lint/lint.xml
@@ -48,6 +48,8 @@
+
+
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index b92e02f670..c2cb5cef3e 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -128,6 +128,12 @@
"includeRegex": [
"screen_create_poll_.*"
]
+ },
+ {
+ "name": ":features:call",
+ "includeRegex": [
+ "call_.*"
+ ]
}
]
}