diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt new file mode 100644 index 0000000000..dde63e79a6 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.text + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.Charset + +fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name()) +fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name()) diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt index 97c6eeda3f..95b7ccf116 100644 --- a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.deeplink.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.text.urlEncoded import io.element.android.libraries.deeplink.api.DeepLinkCreator import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -21,13 +22,13 @@ class DefaultDeepLinkCreator : DeepLinkCreator { override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String { return buildString { append("$SCHEME://$HOST/") - append(sessionId.value) + append(sessionId.value.urlEncoded()) append("/") - append(roomId?.value.orEmpty()) + append(roomId?.value?.urlEncoded().orEmpty()) append("/") - append(threadId?.value.orEmpty()) + append(threadId?.value?.urlEncoded().orEmpty()) append("/") - append(eventId?.value.orEmpty()) + append(eventId?.value?.urlEncoded().orEmpty()) } // Remove all possible trailing '/' characters: // No event id diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt index ca1a39d5d0..8c865b6557 100644 --- a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt @@ -12,6 +12,7 @@ import android.content.Intent import android.net.Uri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.text.urlDecoded import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.matrix.api.core.EventId @@ -31,7 +32,7 @@ class DefaultDeeplinkParser : DeeplinkParser { private fun Uri.toDeeplinkData(): DeeplinkData? { if (scheme != SCHEME) return null if (host != HOST) return null - val pathBits = path.orEmpty().split("/").drop(1) + val pathBits = encodedPath.orEmpty().split("/").drop(1).map { it.urlDecoded() } val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null return when (val screenPathComponent = pathBits.elementAtOrNull(1)) { diff --git a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt index 4e3a10e861..ee01b20a8f 100644 --- a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt +++ b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt @@ -9,6 +9,11 @@ package io.element.android.libraries.deeplink.impl import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.text.urlEncoded +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -19,15 +24,43 @@ class DefaultDeepLinkCreatorTest { @Test fun create() { val sut = DefaultDeepLinkCreator() - assertThat(sut.create(A_SESSION_ID, null, null, null)) - .isEqualTo("elementx://open/@alice:server.org") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, null)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId") + val sessionId = A_SESSION_ID + val roomId = A_ROOM_ID + val threadId = A_THREAD_ID + val eventId = AN_EVENT_ID + assertThat(sut.create(sessionId, null, null, null)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, null, null)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, threadId, null)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}/${threadId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, threadId, eventId)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}/${threadId.urlEncoded()}/${eventId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, null, eventId)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}//${eventId.urlEncoded()}") + } + + @Test + fun `create - with escaped invalid characters`() { + val sut = DefaultDeepLinkCreator() + val sessionId = SessionId("@a/:domain") + val roomId = RoomId("!a/RoomId:domain") + val threadId = ThreadId("\$a/ThreadId") + val eventId = EventId("\$an/EventId") + assertThat(sut.create(sessionId, null, null, null)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, null, null)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, threadId, null)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}/${threadId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, threadId, eventId)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}/${threadId.urlEncoded()}/${eventId.urlEncoded()}") + assertThat(sut.create(sessionId, roomId, null, eventId)) + .isEqualTo("elementx://open/${sessionId.urlEncoded()}/${roomId.urlEncoded()}//${eventId.urlEncoded()}") } } + +private fun SessionId.urlEncoded() = this.value.urlEncoded() +private fun RoomId.urlEncoded() = this.value.urlEncoded() +private fun ThreadId.urlEncoded() = this.value.urlEncoded() +private fun EventId.urlEncoded() = this.value.urlEncoded() diff --git a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt index 4b79f2b08c..7f563ddd7f 100644 --- a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt +++ b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt @@ -12,6 +12,10 @@ import android.content.Intent import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat import io.element.android.libraries.deeplink.api.DeeplinkData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -34,6 +38,8 @@ class DefaultDeeplinkParserTest { "elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId" const val A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD = "elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId" + const val A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT_AND_INVALID_CHARACTERS = + "elementx://open/@a%2Flice:server.org/!a%2FRoomId:domain/\$a%2FThreadId/\$an%2FEventId" } @Test @@ -49,6 +55,15 @@ class DefaultDeeplinkParserTest { .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID)) assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD))) .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT_AND_INVALID_CHARACTERS))) + .isEqualTo( + DeeplinkData.Room( + sessionId = SessionId("@a/lice:server.org"), + roomId = RoomId("!a/RoomId:domain"), + threadId = ThreadId("\$a/ThreadId"), + eventId = EventId("\$an/EventId"), + ) + ) } @Test