From a06bea4d71a123c016ecdf8e148cb33e51490cbb Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 15 Sep 2023 16:39:44 +0200 Subject: [PATCH] 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 --- app/build.gradle.kts | 1 + changelog.d/1300.feature | 1 + features/call/build.gradle.kts | 37 ++++ features/call/src/main/AndroidManifest.xml | 61 +++++ .../features/call/CallForegroundService.kt | 89 ++++++++ .../features/call/CallIntentDataParser.kt | 45 ++++ .../android/features/call/CallScreenView.kt | 152 +++++++++++++ .../features/call/ElementCallActivity.kt | 208 ++++++++++++++++++ .../android/features/call/di/CallBindings.kt | 26 +++ .../src/main/res/values-fr/translations.xml | 6 + .../src/main/res/values/do_not_translate.xml | 20 ++ .../call/src/main/res/values/localazy.xml | 6 + .../call/CallIntentDataParserTests.kt | 105 +++++++++ .../features/call/MapWebkitPermissionsTest.kt | 44 ++++ gradle/libs.versions.toml | 1 + .../designsystem/utils/CommonResources.kt | 21 ++ .../res/drawable/ic_notification_small.xml | 7 + libraries/push/impl/build.gradle.kts | 1 + .../factories/NotificationFactory.kt | 13 +- .../res/drawable-xxhdpi/ic_notification.png | Bin 1269 -> 0 bytes .../src/main/res/values-cs/translations.xml | 4 +- .../src/main/res/values-fr/translations.xml | 3 - .../src/main/res/values-ru/translations.xml | 4 +- .../src/main/res/values/localazy.xml | 8 +- tests/uitests/build.gradle.kts | 1 + ...lScreenView-D-0_0_null,NEXUS_5,1.0,en].png | 3 + ...lScreenView-N-0_1_null,NEXUS_5,1.0,en].png | 3 + tools/lint/lint.xml | 2 + tools/localazy/config.json | 6 + 29 files changed, 862 insertions(+), 16 deletions(-) create mode 100644 changelog.d/1300.feature create mode 100644 features/call/build.gradle.kts create mode 100644 features/call/src/main/AndroidManifest.xml create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt create mode 100644 features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt create mode 100644 features/call/src/main/res/values-fr/translations.xml create mode 100644 features/call/src/main/res/values/do_not_translate.xml create mode 100644 features/call/src/main/res/values/localazy.xml create mode 100644 features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt create mode 100644 features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonResources.kt create mode 100644 libraries/designsystem/src/main/res/drawable/ic_notification_small.xml delete mode 100644 libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-D-0_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.call_null_CallScreenView-N-0_1_null,NEXUS_5,1.0,en].png 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 a86508b71b432a095780bc8fd6b8122816c10e47..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1269 zcmV1Jw|1DI%CxRJEgpPxkR`%a>$bk(*HW;Aqq7G$E4ZYbsNeFH$D1x3 z$C*1^e?oo3Hv!or`BIW2&`Xl@wSy60Vs>%X7Y<`jC?yd6(j|1!MnR51mbAvYrIR9v z)KmydMV>%h01XAj2BLstFsBYc_rm5B6Enay;;^EQKqp0O^2EAXh4>Sf)Qty{V0;sZ ze=_aPY%cGqBM=48%haam&x0odI!F#HPd3FgLxk!p4F^ z0kKvtu*A%$Cm^nejXMef-1Ao@G62sGb9EPGC5(?FeL3e2DFV-(KOR)46P& zEc4|Udghc7P`ubZzuRYssyCAa{r)B!$!YMJAlP;7!liSAx6>69C``S_Z??O6F-!K*pBnDXlxx8LWmcg@>B6qq(R9@8XS{daDS<>BtDXBUvB!MF zu5+uVqb5vxK}Q5+Rh(J+*y2g+QGJoEWxPkT(L1LBQA?F=Rs3WnJs~?`-1NNA)ER9~ zlpTjSK~qJawMvs?fOUe<#k0>?f(pyESpPg680hTSCJ+)EviU%p{{c4GcfsYJLD))^ zJN@`NM^QKcU9@7>bhdHVGQgpbNM0%5S3m-K8j9uzHkK`e*fxd}$`E+u(pU%C0@+V3 z-`Si-jfLogThdjseQW-~6ZFH<*~i46Qf5G^&onMg`6Anb!}?^gXE5hw*(Y#IK#bci zz>~DAS9qPy4-9Bq$JscRa7$g;Un~?p4FUwnV}rMDh>br8<{bK7{JTwe5R?+FQ^;w zs`H_XLUxW}Ly~wgh+CBpUA1W^Ylb*QTYr%v>4^lH5QH!HV^cn%R6yrvu<|U(5rjRZ z$Wrqisa+4~v#~z4(3+AK&BXjdpV0;Ay;WzWh;DV*{Z32++={K7B98DoYtP1`pqzkP zO_7r%;q6A6+13-sTOM{2*>TPObWa66>+IfgY;a_CjlBT3qe=}Gjx6ur3HTrVW{Fxt zHlGD$LX>0SkRt|NrGhuPoBESmuq;tmdoR9?LKavFGPw6=@&%O}X f5!@Uq=nBn0jKJO8Fdhew00000NkvXXu0mjfM&?#H 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_.*" + ] } ] }