Skip to content

Commit f885071

Browse files
jmartinespElementBot
andauthored
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 <[email protected]>
1 parent 6563dca commit f885071

File tree

29 files changed

+862
-16
lines changed

29 files changed

+862
-16
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ dependencies {
198198
allLibrariesImpl()
199199
allServicesImpl()
200200
allFeaturesImpl(rootDir, logger)
201+
implementation(projects.features.call)
201202
implementation(projects.anvilannotations)
202203
implementation(projects.appnav)
203204
anvil(projects.anvilcodegen)

changelog.d/1300.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Integrate Element Call into EX by embedding a call in a WebView.

features/call/build.gradle.kts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
plugins {
18+
id("io.element.android-compose-library")
19+
alias(libs.plugins.anvil)
20+
alias(libs.plugins.ksp)
21+
}
22+
23+
android {
24+
namespace = "io.element.android.features.call"
25+
}
26+
27+
dependencies {
28+
implementation(projects.libraries.architecture)
29+
implementation(projects.libraries.designsystem)
30+
implementation(projects.libraries.network)
31+
implementation(libs.androidx.webkit)
32+
ksp(libs.showkase.processor)
33+
34+
testImplementation(libs.test.junit)
35+
testImplementation(libs.test.truth)
36+
testImplementation(libs.test.robolectric)
37+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<!--
2+
~ Copyright (c) 2023 New Vector Ltd
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
-->
16+
17+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
18+
19+
<uses-feature android:name="android.hardware.camera" android:required="false" />
20+
<uses-feature android:name="android.hardware.microphone" android:required="false" />
21+
22+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
23+
<uses-permission android:name="android.permission.CAMERA" />
24+
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
25+
<uses-permission android:name="android.permission.WAKE_LOCK" />
26+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
27+
28+
<application>
29+
<activity
30+
android:name=".ElementCallActivity"
31+
android:label="@string/element_call"
32+
android:exported="true"
33+
android:taskAffinity="io.element.android.features.call"
34+
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
35+
android:launchMode="singleTask">
36+
37+
<intent-filter android:autoVerify="true">
38+
<action android:name="android.intent.action.VIEW" />
39+
<category android:name="android.intent.category.DEFAULT" />
40+
<category android:name="android.intent.category.BROWSABLE" />
41+
42+
<data android:scheme="http" />
43+
<data android:scheme="https" />
44+
45+
<data android:host="call.element.io" />
46+
</intent-filter>
47+
<!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io -->
48+
<intent-filter>
49+
<action android:name="android.intent.action.VIEW" />
50+
<category android:name="android.intent.category.DEFAULT" />
51+
<category android:name="android.intent.category.BROWSABLE" />
52+
53+
<data android:scheme="element" />
54+
<data android:host="call" />
55+
</intent-filter>
56+
57+
</activity>
58+
<service android:name=".CallForegroundService" android:enabled="true" android:foregroundServiceType="mediaPlayback" />
59+
</application>
60+
61+
</manifest>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.call
18+
19+
import android.app.Service
20+
import android.content.Context
21+
import android.content.Intent
22+
import android.os.Build
23+
import android.os.IBinder
24+
import androidx.core.app.NotificationChannelCompat
25+
import androidx.core.app.NotificationCompat
26+
import androidx.core.app.NotificationManagerCompat
27+
import androidx.core.app.PendingIntentCompat
28+
import androidx.core.graphics.drawable.IconCompat
29+
import io.element.android.libraries.designsystem.utils.CommonDrawables
30+
31+
class CallForegroundService : Service() {
32+
33+
companion object {
34+
fun start(context: Context) {
35+
val intent = Intent(context, CallForegroundService::class.java)
36+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
37+
context.startForegroundService(intent)
38+
} else {
39+
context.startService(intent)
40+
}
41+
}
42+
43+
fun stop(context: Context) {
44+
val intent = Intent(context, CallForegroundService::class.java)
45+
context.stopService(intent)
46+
}
47+
}
48+
49+
private lateinit var notificationManagerCompat: NotificationManagerCompat
50+
51+
override fun onCreate() {
52+
super.onCreate()
53+
54+
notificationManagerCompat = NotificationManagerCompat.from(this)
55+
56+
val foregroundServiceChannel = NotificationChannelCompat.Builder(
57+
"call_foreground_service_channel",
58+
NotificationManagerCompat.IMPORTANCE_LOW,
59+
).setName(
60+
getString(R.string.call_foreground_service_channel_title_android).ifEmpty { "Ongoing call" }
61+
).build()
62+
notificationManagerCompat.createNotificationChannel(foregroundServiceChannel)
63+
64+
val callActivityIntent = Intent(this, ElementCallActivity::class.java)
65+
val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false)
66+
val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id)
67+
.setSmallIcon(IconCompat.createWithResource(this, CommonDrawables.ic_notification_small))
68+
.setContentTitle(getString(R.string.call_foreground_service_title_android))
69+
.setContentText(getString(R.string.call_foreground_service_message_android))
70+
.setContentIntent(pendingIntent)
71+
.build()
72+
startForeground(1, notification)
73+
}
74+
75+
@Suppress("DEPRECATION")
76+
override fun onDestroy() {
77+
super.onDestroy()
78+
79+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
80+
stopForeground(STOP_FOREGROUND_REMOVE)
81+
} else {
82+
stopForeground(true)
83+
}
84+
}
85+
86+
override fun onBind(intent: Intent?): IBinder? {
87+
return null
88+
}
89+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.call
18+
19+
import android.net.Uri
20+
import java.net.URLDecoder
21+
22+
object CallIntentDataParser {
23+
24+
private val validHttpSchemes = sequenceOf("http", "https")
25+
26+
fun parse(data: String?): String? {
27+
val parsedUrl = data?.let { Uri.parse(data) } ?: return null
28+
val scheme = parsedUrl.scheme
29+
return when {
30+
scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> data
31+
scheme == "element" && parsedUrl.host == "call" -> {
32+
// We use this custom scheme to load arbitrary URLs for other instances of Element Call,
33+
// so we can only verify it's an HTTP/HTTPs URL with a non-empty host
34+
parsedUrl.getQueryParameter("url")
35+
?.let { URLDecoder.decode(it, "utf-8") }
36+
?.takeIf {
37+
val internalUri = Uri.parse(it)
38+
internalUri.scheme in validHttpSchemes && !internalUri.host.isNullOrBlank()
39+
}
40+
}
41+
// This should never be possible, but we still need to take into account the possibility
42+
else -> null
43+
}
44+
}
45+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.call
18+
19+
import android.annotation.SuppressLint
20+
import android.view.ViewGroup
21+
import android.webkit.PermissionRequest
22+
import android.webkit.WebChromeClient
23+
import android.webkit.WebView
24+
import androidx.compose.foundation.layout.consumeWindowInsets
25+
import androidx.compose.foundation.layout.fillMaxSize
26+
import androidx.compose.foundation.layout.padding
27+
import androidx.compose.material.icons.Icons
28+
import androidx.compose.material.icons.filled.Close
29+
import androidx.compose.material3.ExperimentalMaterial3Api
30+
import androidx.compose.runtime.Composable
31+
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.platform.LocalInspectionMode
33+
import androidx.compose.ui.res.stringResource
34+
import androidx.compose.ui.viewinterop.AndroidView
35+
import io.element.android.libraries.designsystem.components.button.BackButton
36+
import io.element.android.libraries.designsystem.preview.DayNightPreviews
37+
import io.element.android.libraries.designsystem.theme.components.Scaffold
38+
import io.element.android.libraries.designsystem.theme.components.Text
39+
import io.element.android.libraries.designsystem.theme.components.TopAppBar
40+
import io.element.android.libraries.theme.ElementTheme
41+
42+
typealias RequestPermissionCallback = (Array<String>) -> Unit
43+
44+
@OptIn(ExperimentalMaterial3Api::class)
45+
@Composable
46+
internal fun CallScreenView(
47+
url: String?,
48+
userAgent: String,
49+
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
50+
onClose: () -> Unit,
51+
modifier: Modifier = Modifier,
52+
) {
53+
ElementTheme {
54+
Scaffold(
55+
modifier = modifier,
56+
topBar = {
57+
TopAppBar(
58+
title = { Text(stringResource(R.string.element_call)) },
59+
navigationIcon = {
60+
BackButton(
61+
imageVector = Icons.Default.Close,
62+
onClick = onClose
63+
)
64+
}
65+
)
66+
}
67+
) { padding ->
68+
CallWebView(
69+
modifier = Modifier
70+
.padding(padding)
71+
.consumeWindowInsets(padding)
72+
.fillMaxSize(),
73+
url = url,
74+
userAgent = userAgent,
75+
onPermissionsRequested = { request ->
76+
val androidPermissions = mapWebkitPermissions(request.resources)
77+
val callback: RequestPermissionCallback = { request.grant(it) }
78+
requestPermissions(androidPermissions.toTypedArray(), callback)
79+
}
80+
)
81+
}
82+
}
83+
}
84+
85+
@Composable
86+
private fun CallWebView(
87+
url: String?,
88+
userAgent: String,
89+
onPermissionsRequested: (PermissionRequest) -> Unit,
90+
modifier: Modifier = Modifier,
91+
) {
92+
val isInpectionMode = LocalInspectionMode.current
93+
AndroidView(
94+
modifier = modifier,
95+
factory = { context ->
96+
WebView(context).apply {
97+
if (!isInpectionMode) {
98+
setup(userAgent, onPermissionsRequested)
99+
if (url != null) {
100+
loadUrl(url)
101+
}
102+
}
103+
}
104+
},
105+
update = { webView ->
106+
if (!isInpectionMode && url != null) {
107+
webView.loadUrl(url)
108+
}
109+
},
110+
onRelease = { webView ->
111+
webView.destroy()
112+
}
113+
)
114+
}
115+
116+
@SuppressLint("SetJavaScriptEnabled")
117+
private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) {
118+
layoutParams = ViewGroup.LayoutParams(
119+
ViewGroup.LayoutParams.MATCH_PARENT,
120+
ViewGroup.LayoutParams.MATCH_PARENT
121+
)
122+
123+
with(settings) {
124+
javaScriptEnabled = true
125+
allowContentAccess = true
126+
allowFileAccess = true
127+
domStorageEnabled = true
128+
mediaPlaybackRequiresUserGesture = false
129+
databaseEnabled = true
130+
loadsImagesAutomatically = true
131+
userAgentString = userAgent
132+
}
133+
134+
webChromeClient = object : WebChromeClient() {
135+
override fun onPermissionRequest(request: PermissionRequest) {
136+
onPermissionsRequested(request)
137+
}
138+
}
139+
}
140+
141+
@DayNightPreviews
142+
@Composable
143+
internal fun CallScreenViewPreview() {
144+
ElementTheme {
145+
CallScreenView(
146+
url = "https://call.element.io/some-actual-call?with=parameters",
147+
userAgent = "",
148+
requestPermissions = { _, _ -> },
149+
onClose = { },
150+
)
151+
}
152+
}

0 commit comments

Comments
 (0)