Element Call SPA integration (#1283)

* Integrate Element Call into EX, being able to open its URLs and handle the call in-app.
* Add custom scheme support with format `element:call?url=...`.
* Update androix.webkit
* Silence the foreground service notification.
- Allow foreground service tap action to re-open the ongoing call.
- Unify notification small icons in different modules using a vector one.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2023-09-15 16:39:44 +02:00
committed by GitHub
parent 36119106a9
commit a06bea4d71
29 changed files with 862 additions and 16 deletions

View File

@@ -198,6 +198,7 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
implementation(projects.features.call)
implementation(projects.anvilannotations)
implementation(projects.appnav)
anvil(projects.anvilcodegen)

1
changelog.d/1300.feature Normal file
View File

@@ -0,0 +1 @@
Integrate Element Call into EX by embedding a call in a WebView.

View File

@@ -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)
}

View File

@@ -0,0 +1,61 @@
<!--
~ 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application>
<activity
android:name=".ElementCallActivity"
android:label="@string/element_call"
android:exported="true"
android:taskAffinity="io.element.android.features.call"
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:launchMode="singleTask">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="call.element.io" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="element" />
<data android:host="call" />
</intent-filter>
</activity>
<service android:name=".CallForegroundService" android:enabled="true" android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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<String>) -> Unit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CallScreenView(
url: String?,
userAgent: String,
requestPermissions: (Array<String>, 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 = { },
)
}
}

View File

@@ -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<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applicationContext.bindings<CallBindings>().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<Array<String>> {
return registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val callback = requestPermissionCallback ?: return@registerForActivityResult
val permissionsToGrant = mutableListOf<String>()
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<String>): List<String> {
return permissions.mapNotNull { permission ->
when (permission) {
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA
else -> null
}
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
<string name="call_foreground_service_message_android">"Appuyez pour retourner à l\'appel."</string>
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
</resources>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<string translatable="false" name="element_call">Element Call</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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>
</resources>

View File

@@ -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()
}
}

View File

@@ -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<String>())
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
<vector android:height="32dp" android:viewportHeight="54"
android:viewportWidth="54" android:width="32dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000" android:pathData="M19.4,3.2C19.4,1.5 20.9,0 22.7,0C34.6,0 44.3,9.7 44.3,21.6C44.3,23.4 42.8,24.8 41,24.8C39.3,24.8 37.8,23.4 37.8,21.6C37.8,13.3 31,6.5 22.7,6.5C20.9,6.5 19.4,5 19.4,3.2Z"/>
<path android:fillColor="#000000" android:pathData="M34.6,50.8C34.6,52.6 33.1,54 31.3,54C19.4,54 9.7,44.3 9.7,32.4C9.7,30.6 11.2,29.2 13,29.2C14.8,29.2 16.2,30.6 16.2,32.4C16.2,40.8 23,47.5 31.3,47.5C33.1,47.5 34.6,49 34.6,50.8Z"/>
<path android:fillColor="#000000" android:pathData="M3.2,34.6C1.5,34.6 -0,33.1 -0,31.3C-0,19.4 9.7,9.7 21.6,9.7C23.4,9.7 24.8,11.2 24.8,13C24.8,14.8 23.4,16.2 21.6,16.2C13.3,16.2 6.5,23 6.5,31.3C6.5,33.1 5,34.6 3.2,34.6Z"/>
<path android:fillColor="#000000" android:pathData="M50.8,19.4C52.6,19.4 54,20.9 54,22.7C54,34.6 44.3,44.3 32.4,44.3C30.6,44.3 29.2,42.8 29.2,41C29.2,39.3 30.6,37.8 32.4,37.8C40.8,37.8 47.5,31 47.5,22.7C47.5,20.9 49,19.4 50.8,19.4Z"/>
</vector>

View File

@@ -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)

View File

@@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -195,9 +195,9 @@
<string name="screen_notification_settings_additional_settings_section_title">"Další nastavení"</string>
<string name="screen_notification_settings_calls_label">"Halsové a video hovory"</string>
<string name="screen_notification_settings_configuration_mismatch">"Neshoda konfigurace"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností.
<string name="screen_notification_settings_configuration_mismatch_description">"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."</string>
<string name="screen_notification_settings_direct_chats">"Přímé zprávy"</string>

View File

@@ -67,9 +67,6 @@
<string name="action_take_photo">"Prendre une photo"</string>
<string name="action_view_source">"Afficher la source"</string>
<string name="action_yes">"Oui"</string>
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
<string name="call_foreground_service_message_android">"Appuyez pour retourner à l\'appel."</string>
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
<string name="common_about">"À propos"</string>
<string name="common_acceptable_use_policy">"Politique d\'utilisation acceptable"</string>
<string name="common_advanced_settings">"Paramètres avancés"</string>

View File

@@ -194,9 +194,9 @@
<string name="screen_notification_settings_additional_settings_section_title">"Дополнительные параметры"</string>
<string name="screen_notification_settings_calls_label">"Аудио и видео звонки"</string>
<string name="screen_notification_settings_configuration_mismatch">"Несоответствие конфигурации"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Мы упростили настройки уведомлений, чтобы упростить поиск опций.
<string name="screen_notification_settings_configuration_mismatch_description">"Мы упростили настройки уведомлений, чтобы упростить поиск опций.
Некоторые пользовательские настройки, выбранные вами ранее, не отображаются в данном меню, но они все еще активны.
Некоторые пользовательские настройки, выбранные вами ранее, не отображаются в данном меню, но они все еще активны.
Если вы продолжите, некоторые настройки могут быть изменены."</string>
<string name="screen_notification_settings_direct_chats">"Прямые чаты"</string>

View File

@@ -67,9 +67,6 @@
<string name="action_take_photo">"Take photo"</string>
<string name="action_view_source">"View Source"</string>
<string name="action_yes">"Yes"</string>
<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="common_about">"About"</string>
<string name="common_acceptable_use_policy">"Acceptable use policy"</string>
<string name="common_advanced_settings">"Advanced settings"</string>
@@ -207,6 +204,11 @@
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</string>
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
<string name="screen_edit_profile_display_name">"Display name"</string>
<string name="screen_edit_profile_display_name_placeholder">"Your display name"</string>
<string name="screen_edit_profile_error">"An unknown error was encountered and the information couldn\'t be changed."</string>
<string name="screen_edit_profile_error_title">"Unable to update profile"</string>
<string name="screen_edit_profile_updating_details">"Updating profile…"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>

View File

@@ -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)

View File

@@ -48,6 +48,8 @@
<ignore path="**/localazy.xml" />
<!-- Ignore unused resource in debug build type -->
<ignore path="**/src/debug/**" />
<!-- Ignore unused resources in designsystem since they're imported elsewhere through aliases and can't be properly detected -->
<ignore path="**/libraries/designsystem/src/main/res/**" />
</issue>
<!-- UX -->

View File

@@ -128,6 +128,12 @@
"includeRegex": [
"screen_create_poll_.*"
]
},
{
"name": ":features:call",
"includeRegex": [
"call_.*"
]
}
]
}