diff --git a/app/src/main/java/com/anytypeio/anytype/other/Deeplinks.kt b/app/src/main/java/com/anytypeio/anytype/other/Deeplinks.kt index 9a1c2aad02..977c80d1e6 100644 --- a/app/src/main/java/com/anytypeio/anytype/other/Deeplinks.kt +++ b/app/src/main/java/com/anytypeio/anytype/other/Deeplinks.kt @@ -18,21 +18,19 @@ const val DEEP_LINK_TO_OBJECT_BASE_URL = "https://object.any.coop" * Regex pattern for matching */ const val DEEP_LINK_INVITE_REG_EXP = "invite.any.coop/([a-zA-Z0-9]+)#([a-zA-Z0-9]+)" +const val DEEP_LINK_TO_OBJECT_REG_EXP = """object\.any\.coop/([a-zA-Z0-9?=&._-]+)""" const val DEE_LINK_INVITE_CUSTOM_REG_EXP = "anytype://invite/\\?cid=([a-zA-Z0-9]+)&key=([a-zA-Z0-9]+)" const val MAIN_PATH = "main" const val OBJECT_PATH = "object" const val IMPORT_PATH = "import" -const val INVITE_PATH = "invite" const val MEMBERSHIP_PATH = "membership" const val TYPE_PARAM = "type" const val OBJECT_ID_PARAM = "objectId" const val SPACE_ID_PARAM = "spaceId" -const val CONTENT_ID_PARAM = "cid" const val INVITE_ID_PARAM = "inviteID" -const val ENCRYPTION_KEY_PARAM = "key" const val SOURCE_PARAM = "source" const val TYPE_VALUE_EXPERIENCE = "experience" const val TIER_ID_PARAM = "tier" @@ -42,60 +40,78 @@ const val IMPORT_EXPERIENCE_DEEPLINK = "$DEEP_LINK_PATTERN$MAIN_PATH/$IMPORT_PAT object DefaultDeepLinkResolver : DeepLinkResolver { private val defaultInviteRegex = Regex(DEEP_LINK_INVITE_REG_EXP) + private val defaultLinkToObjectRegex = Regex(DEEP_LINK_TO_OBJECT_REG_EXP) - override fun resolve( - deeplink: String - ): DeepLinkResolver.Action = when { - deeplink.contains(IMPORT_EXPERIENCE_DEEPLINK) -> { - try { - val type = Uri.parse(deeplink).getQueryParameter(TYPE_PARAM) - val source = Uri.parse(deeplink).getQueryParameter(SOURCE_PARAM) - DeepLinkResolver.Action.Import.Experience( - type = type.orEmpty(), - source = source.orEmpty() - ) - } catch (e: Exception) { - DeepLinkResolver.Action.Unknown - } + override fun resolve(deeplink: String): DeepLinkResolver.Action { + val uri = Uri.parse(deeplink) + + return when { + deeplink.contains(IMPORT_EXPERIENCE_DEEPLINK) -> resolveImportExperience(uri) + defaultInviteRegex.containsMatchIn(deeplink) -> DeepLinkResolver.Action.Invite(deeplink) + defaultLinkToObjectRegex.containsMatchIn(deeplink) -> resolveDeepLinkToObject(uri) + deeplink.contains(OBJECT_PATH) -> resolveObjectPath(uri) + deeplink.contains(MEMBERSHIP_PATH) -> resolveMembershipPath(uri) + else -> DeepLinkResolver.Action.Unknown + }.also { + Timber.d("Resolving deep link: $deeplink") } - deeplink.contains(INVITE_PATH) -> { - DeepLinkResolver.Action.Invite(deeplink) + } + + private fun resolveImportExperience(uri: Uri): DeepLinkResolver.Action { + return try { + val type = uri.getQueryParameter(TYPE_PARAM).orEmpty() + val source = uri.getQueryParameter(SOURCE_PARAM).orEmpty() + DeepLinkResolver.Action.Import.Experience(type, source) + } catch (e: Exception) { + DeepLinkResolver.Action.Unknown } - defaultInviteRegex.containsMatchIn(deeplink) -> { - DeepLinkResolver.Action.Invite(deeplink) - } - deeplink.contains(OBJECT_PATH) -> { - val uri = Uri.parse(deeplink) - val obj = uri.getQueryParameter(OBJECT_ID_PARAM) - val space = uri.getQueryParameter(SPACE_ID_PARAM) - if (!obj.isNullOrEmpty() && !space.isNullOrEmpty()) { - val cid = uri.getQueryParameter(CONTENT_ID_PARAM) - val key = uri.getQueryParameter(ENCRYPTION_KEY_PARAM) - DeepLinkResolver.Action.DeepLinkToObject( - obj = obj, - space = SpaceId(space), - invite = if (!cid.isNullOrEmpty() && !key.isNullOrEmpty()) { - DeepLinkResolver.Action.DeepLinkToObject.Invite( - cid = cid, - key = key - ) - } else { - null - } - ) - } else { - DeepLinkResolver.Action.Unknown - } - } - deeplink.contains(MEMBERSHIP_PATH) -> { - val uri = Uri.parse(deeplink) - DeepLinkResolver.Action.DeepLinkToMembership( - tierId = uri.getQueryParameter(TIER_ID_PARAM) + } + + private fun resolveDeepLinkToObject(uri: Uri): DeepLinkResolver.Action { + val obj = uri.pathSegments.getOrNull(0) ?: return DeepLinkResolver.Action.Unknown + val space = uri.getQueryParameter(SPACE_ID_PARAM)?.takeIf { it.isNotEmpty() } + ?: return DeepLinkResolver.Action.Unknown // Ensure spaceId is required + + return DeepLinkResolver.Action.DeepLinkToObject( + obj = obj, + space = SpaceId(space), + invite = parseInvite(uri) + ) + } + + private fun resolveObjectPath(uri: Uri): DeepLinkResolver.Action { + val obj = uri.getQueryParameter(OBJECT_ID_PARAM)?.takeIf { it.isNotEmpty() } + val space = uri.getQueryParameter(SPACE_ID_PARAM)?.takeIf { it.isNotEmpty() } + ?: return DeepLinkResolver.Action.Unknown // Ensure spaceId is required + + return if (obj != null) { + DeepLinkResolver.Action.DeepLinkToObject( + obj = obj, + space = SpaceId(space), + invite = parseInvite(uri) ) + } else { + DeepLinkResolver.Action.Unknown + } + } + + private fun resolveMembershipPath(uri: Uri): DeepLinkResolver.Action { + return DeepLinkResolver.Action.DeepLinkToMembership( + tierId = uri.getQueryParameter(TIER_ID_PARAM) + ) + } + + private fun parseInvite(uri: Uri): DeepLinkResolver.Action.DeepLinkToObject.Invite? { + val inviteId = uri.getQueryParameter(INVITE_ID_PARAM)?.takeIf { it.isNotEmpty() } + val encryption = uri.fragment?.takeIf { it.isNotEmpty() } + return if (inviteId != null && encryption != null) { + DeepLinkResolver.Action.DeepLinkToObject.Invite( + key = encryption, + cid = inviteId + ) + } else { + null } - else -> DeepLinkResolver.Action.Unknown - }.also { - Timber.d("Resolving deep link: $deeplink") } override fun createObjectDeepLink(obj: Id, space: SpaceId): Url { diff --git a/app/src/test/java/com/anytypeio/anytype/other/DefaultDeepLinkResolverTest.kt b/app/src/test/java/com/anytypeio/anytype/other/DefaultDeepLinkResolverTest.kt index 5fcbe9a911..64bc587f23 100644 --- a/app/src/test/java/com/anytypeio/anytype/other/DefaultDeepLinkResolverTest.kt +++ b/app/src/test/java/com/anytypeio/anytype/other/DefaultDeepLinkResolverTest.kt @@ -1,12 +1,14 @@ package com.anytypeio.anytype.other import android.os.Build +import androidx.compose.runtime.key import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.domain.misc.DeepLinkResolver import com.anytypeio.anytype.test_utils.MockDataFactory import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -51,6 +53,62 @@ class DefaultDeepLinkResolverTest { ) } + @Test + fun `resolve https deep link to object`() { + // Given + + val obj = MockDataFactory.randomUuid() + + val space = MockDataFactory.randomUuid() + + val deeplink = "https://object.any.coop/$obj?spaceId=$space" + + // When + val result = deepLinkResolver.resolve(deeplink) + + // Then + assertEquals( + DeepLinkResolver.Action.DeepLinkToObject( + space = SpaceId(space), + obj = obj + ), + result + ) + } + + @Test + fun `resolve https deep link to object with invite`() { + // Given + + val obj = MockDataFactory.randomUuid() + + val space = MockDataFactory.randomUuid() + + val cid = MockDataFactory.randomUuid() + + val encryption = MockDataFactory.randomUuid() + + val invite = "$cid#$encryption" + + val deeplink = "https://object.any.coop/$obj?spaceId=$space&inviteID=$invite" + + // When + val result = deepLinkResolver.resolve(deeplink) + + // Then + assertEquals( + DeepLinkResolver.Action.DeepLinkToObject( + space = SpaceId(space), + obj = obj, + invite = DeepLinkResolver.Action.DeepLinkToObject.Invite( + cid = cid, + key = encryption + ) + ), + result + ) + } + @Test fun `resolve returns Invite with deeplink for invite deep links`() { // Given