diff --git a/changelog.d/1377.misc b/changelog.d/1377.misc new file mode 100644 index 0000000000..2fa9e98b91 --- /dev/null +++ b/changelog.d/1377.misc @@ -0,0 +1 @@ +Element Call: support scheme `io.element.call` diff --git a/features/call/src/main/AndroidManifest.xml b/features/call/src/main/AndroidManifest.xml index 1aed77cd95..d106d4e7b8 100644 --- a/features/call/src/main/AndroidManifest.xml +++ b/features/call/src/main/AndroidManifest.xml @@ -53,6 +53,14 @@ + + + + + + + + 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 index a664e562f3..30e71d6201 100644 --- 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 @@ -17,9 +17,9 @@ package io.element.android.features.call import android.net.Uri -import java.net.URLDecoder +import javax.inject.Inject -object CallIntentDataParser { +class CallIntentDataParser @Inject constructor() { private val validHttpSchemes = sequenceOf("http", "https") @@ -31,15 +31,23 @@ object CallIntentDataParser { 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() - } + parsedUrl.getUrlParameter() + } + scheme == "io.element.call" && parsedUrl.host == null -> { + // 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.getUrlParameter() } // This should never be possible, but we still need to take into account the possibility else -> null } } + + private fun Uri.getUrlParameter(): String? { + return getQueryParameter("url") + ?.takeIf { + val internalUri = Uri.parse(it) + internalUri.scheme in validHttpSchemes && !internalUri.host.isNullOrBlank() + } + } } 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 index 69ef3963cb..481634a4ca 100644 --- 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 @@ -39,6 +39,7 @@ import javax.inject.Inject class ElementCallActivity : ComponentActivity() { @Inject lateinit var userAgentProvider: UserAgentProvider + @Inject lateinit var callIntentDataParser: CallIntentDataParser private lateinit var audioManager: AudioManager @@ -129,7 +130,7 @@ class ElementCallActivity : ComponentActivity() { finishAndRemoveTask() } - private fun parseUrl(url: String?): String? = CallIntentDataParser.parse(url) + private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url) private fun registerPermissionResultLauncher(): ActivityResultLauncher> { return registerForActivityResult( 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 index da41692b40..71290f15b7 100644 --- 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 @@ -25,28 +25,30 @@ import java.net.URLEncoder @RunWith(RobolectricTestRunner::class) class CallIntentDataParserTests { + private val callIntentDataParser = CallIntentDataParser() + @Test fun `a null data returns null`() { val url: String? = null - assertThat(CallIntentDataParser.parse(url)).isNull() + assertThat(callIntentDataParser.parse(url)).isNull() } @Test fun `empty data returns null`() { val url = "" - assertThat(CallIntentDataParser.parse(url)).isNull() + assertThat(callIntentDataParser.parse(url)).isNull() } @Test fun `invalid data returns null`() { val url = "!" - assertThat(CallIntentDataParser.parse(url)).isNull() + assertThat(callIntentDataParser.parse(url)).isNull() } @Test fun `data with no scheme returns null`() { val url = "test" - assertThat(CallIntentDataParser.parse(url)).isNull() + assertThat(callIntentDataParser.parse(url)).isNull() } @Test @@ -55,10 +57,10 @@ class CallIntentDataParserTests { 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) + 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 @@ -67,10 +69,10 @@ class CallIntentDataParserTests { 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() + assertThat(callIntentDataParser.parse(httpBaseUrl)).isNull() + assertThat(callIntentDataParser.parse(httpsBaseUrl)).isNull() + assertThat(callIntentDataParser.parse(httpInvalidUrl)).isNull() + assertThat(callIntentDataParser.parse(httpsInvalidUrl)).isNull() } @Test @@ -78,7 +80,15 @@ class CallIntentDataParserTests { 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) + assertThat(callIntentDataParser.parse(url)).isEqualTo(embeddedUrl) + } + + @Test + fun `element scheme 2 with 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 = "io.element.call:/?url=$encodedUrl" + assertThat(callIntentDataParser.parse(url)).isEqualTo(embeddedUrl) } @Test @@ -86,7 +96,15 @@ class CallIntentDataParserTests { 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() + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `element scheme 2 with no url returns null`() { + val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters" + val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") + val url = "io.element.call:/?no_url=$encodedUrl" + assertThat(callIntentDataParser.parse(url)).isNull() } @Test @@ -94,12 +112,26 @@ class CallIntentDataParserTests { 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() + assertThat(callIntentDataParser.parse(url)).isNull() } @Test fun `element scheme with no data returns null`() { val url = "element://call?url=" - assertThat(CallIntentDataParser.parse(url)).isNull() + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `element scheme 2 with no data returns null`() { + val url = "io.element.call:/?url=" + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `element invalid scheme returns null`() { + val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters" + val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") + val url = "bad.scheme:/?url=$encodedUrl" + assertThat(callIntentDataParser.parse(url)).isNull() } } diff --git a/tools/adb/callLinkCustomScheme.sh b/tools/adb/callLinkCustomScheme.sh new file mode 100755 index 0000000000..d556d88f70 --- /dev/null +++ b/tools/adb/callLinkCustomScheme.sh @@ -0,0 +1,23 @@ +#! /bin/bash +# +# 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. +# + +# Format is: +# element://call?url=some-encoded-url +# For instance +# element://call?url=https%3A%2F%2Fcall.element.io%2FTestElementCall + +adb shell am start -a android.intent.action.VIEW -d element://call?url=https%3A%2F%2Fcall.element.io%2FTestElementCall diff --git a/tools/adb/callLinkCustomScheme2.sh b/tools/adb/callLinkCustomScheme2.sh new file mode 100755 index 0000000000..d6dabd595c --- /dev/null +++ b/tools/adb/callLinkCustomScheme2.sh @@ -0,0 +1,23 @@ +#! /bin/bash +# +# 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. +# + +# Format is: +# io.element.call:/?url=some-encoded-url +# For instance +# io.element.call:/?url=https%3A%2F%2Fcall.element.io%2FTestElementCall + +adb shell am start -a android.intent.action.VIEW -d io.element.call:/?url=https%3A%2F%2Fcall.element.io%2FTestElementCall diff --git a/tools/adb/callLinkHttps.sh b/tools/adb/callLinkHttps.sh new file mode 100755 index 0000000000..00ff341de3 --- /dev/null +++ b/tools/adb/callLinkHttps.sh @@ -0,0 +1,23 @@ +#! /bin/bash +# +# 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. +# + +# Format is: +# https://call.element.io/* +# For instance +# https://call.element.io/TestElementCall + +adb shell am start -a android.intent.action.VIEW -d https://call.element.io/TestElementCall