URL-encode deep link path segments in DefaultDeepLinkCreator

Decode them later in `DefaultDeepLinkParser` too
This commit is contained in:
Jorge Martín
2025-12-10 13:09:00 +01:00
committed by Jorge Martin Espinosa
parent 1e52e1139f
commit a59b9c86e9
5 changed files with 80 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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