diff --git a/deeplinks-plugin/api/deeplinks-plugin.api b/deeplinks-plugin/api/deeplinks-plugin.api index da3b57ab1..b96c2aa13 100644 --- a/deeplinks-plugin/api/deeplinks-plugin.api +++ b/deeplinks-plugin/api/deeplinks-plugin.api @@ -1,3 +1,71 @@ +public final class com/freeletics/khonshu/deeplinks/plugin/Configuration { + public static final field Companion Lcom/freeletics/khonshu/deeplinks/plugin/Configuration$Companion; + public synthetic fun (ILjava/util/List;Ljava/util/List;Ljava/util/Map;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V + public fun (Ljava/util/List;Ljava/util/List;Ljava/util/Map;)V + public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/util/Map; + public final fun copy (Ljava/util/List;Ljava/util/List;Ljava/util/Map;)Lcom/freeletics/khonshu/deeplinks/plugin/Configuration; + public static synthetic fun copy$default (Lcom/freeletics/khonshu/deeplinks/plugin/Configuration;Ljava/util/List;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)Lcom/freeletics/khonshu/deeplinks/plugin/Configuration; + public fun equals (Ljava/lang/Object;)Z + public final fun getDeepLinks ()Ljava/util/Map; + public final fun getPlaceholders ()Ljava/util/List; + public final fun getPrefixes ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public static final synthetic fun write$Self (Lcom/freeletics/khonshu/deeplinks/plugin/Configuration;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class com/freeletics/khonshu/deeplinks/plugin/Configuration$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/freeletics/khonshu/deeplinks/plugin/Configuration$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/freeletics/khonshu/deeplinks/plugin/Configuration; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/freeletics/khonshu/deeplinks/plugin/Configuration;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/freeletics/khonshu/deeplinks/plugin/Configuration$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/freeletics/khonshu/deeplinks/plugin/DeepLink { + public static final field Companion Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink$Companion; + public synthetic fun (ILjava/util/List;Ljava/util/List;Ljava/util/List;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V + public fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/util/List; + public final fun copy (Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink; + public static synthetic fun copy$default (Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink; + public fun equals (Ljava/lang/Object;)Z + public final fun getPatterns ()Ljava/util/List; + public final fun getPlaceholders ()Ljava/util/List; + public final fun getPrefixes ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public static final synthetic fun write$Self (Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class com/freeletics/khonshu/deeplinks/plugin/DeepLink$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/freeletics/khonshu/deeplinks/plugin/DeepLink;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/freeletics/khonshu/deeplinks/plugin/DeepLink$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public abstract class com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTask : org/gradle/api/DefaultTask { public fun ()V public abstract fun getDeeplinksConfigurationFile ()Lorg/gradle/api/file/RegularFileProperty; @@ -12,3 +80,67 @@ public abstract class com/freeletics/khonshu/deeplinks/plugin/DeeplinksPlugin : public fun apply (Lorg/gradle/api/Project;)V } +public final class com/freeletics/khonshu/deeplinks/plugin/Placeholder { + public static final field Companion Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder$Companion; + public synthetic fun (ILjava/lang/String;Ljava/util/List;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/util/List;)Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder; + public static synthetic fun copy$default (Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder; + public fun equals (Ljava/lang/Object;)Z + public final fun getKey ()Ljava/lang/String; + public final fun getValues ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public static final synthetic fun write$Self (Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class com/freeletics/khonshu/deeplinks/plugin/Placeholder$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/freeletics/khonshu/deeplinks/plugin/Placeholder;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/freeletics/khonshu/deeplinks/plugin/Placeholder$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/freeletics/khonshu/deeplinks/plugin/Prefix { + public static final field Companion Lcom/freeletics/khonshu/deeplinks/plugin/Prefix$Companion; + public synthetic fun (ILjava/lang/String;Ljava/lang/String;ZLkotlinx/serialization/internal/SerializationConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Z)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun copy (Ljava/lang/String;Ljava/lang/String;Z)Lcom/freeletics/khonshu/deeplinks/plugin/Prefix; + public static synthetic fun copy$default (Lcom/freeletics/khonshu/deeplinks/plugin/Prefix;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lcom/freeletics/khonshu/deeplinks/plugin/Prefix; + public fun equals (Ljava/lang/Object;)Z + public final fun getAutoVerified ()Z + public final fun getHost ()Ljava/lang/String; + public final fun getScheme ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public static final synthetic fun write$Self (Lcom/freeletics/khonshu/deeplinks/plugin/Prefix;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class com/freeletics/khonshu/deeplinks/plugin/Prefix$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/freeletics/khonshu/deeplinks/plugin/Prefix$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/freeletics/khonshu/deeplinks/plugin/Prefix; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/freeletics/khonshu/deeplinks/plugin/Prefix;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/freeletics/khonshu/deeplinks/plugin/Prefix$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + diff --git a/deeplinks-plugin/deeplinks-plugin.gradle.kts b/deeplinks-plugin/deeplinks-plugin.gradle.kts index f53c63489..a635b8070 100644 --- a/deeplinks-plugin/deeplinks-plugin.gradle.kts +++ b/deeplinks-plugin/deeplinks-plugin.gradle.kts @@ -1,11 +1,16 @@ plugins { alias(libs.plugins.fgp.gradle) - // alias(libs.plugins.fgp.publish) + alias(libs.plugins.fgp.publish) + alias(libs.plugins.kotlin.serialization) } dependencies { compileOnly(libs.android.gradle.api) + api(projects.navigation) + implementation(libs.toml) + implementation(libs.serialization) + testImplementation(libs.junit) testImplementation(libs.truth) } diff --git a/deeplinks-plugin/rules.pro b/deeplinks-plugin/rules.pro index 65ce95ddb..e4ac397c5 100644 --- a/deeplinks-plugin/rules.pro +++ b/deeplinks-plugin/rules.pro @@ -14,9 +14,11 @@ -keepattributes Signature,Exceptions,*Annotation*,InnerClasses,PermittedSubclasses,EnclosingMethod,Deprecated,SourceFile,LineNumberTable # Keep your public API so that it's callable from scripts --keep class com.freeletics.khonshu.deeplinks.plugin.*Extension { *; } --keep class com.freeletics.khonshu.deeplinks.plugin.*Plugin { *; } --keep class com.freeletics.khonshu.deeplinks.plugin.*Task { *; } +-keep class com.freeletics.khonshu.deeplinks.plugin.** { public *; } +-keepnames class kotlin.jvm.internal.DefaultConstructorMarker +-keepclassmembers @com.squareup.moshi.JsonClass @kotlin.Metadata class * { + synthetic (...); +} # No need to obfuscate class names -dontobfuscate diff --git a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/Configuration.kt b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/Configuration.kt new file mode 100644 index 000000000..d19bebdf9 --- /dev/null +++ b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/Configuration.kt @@ -0,0 +1,58 @@ +package com.freeletics.khonshu.deeplinks.plugin + +import com.freeletics.khonshu.navigation.deeplinks.DeepLinkHandler +import com.freeletics.khonshu.navigation.deeplinks.DeepLinkHandler.Pattern +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +public data class Configuration( + val prefixes: List = emptyList(), + val placeholders: List = emptyList(), + val deepLinks: Map, +) + +@Serializable +public data class DeepLink( + val patterns: List< + @Serializable(PatternSerializer::class) + Pattern, + >, + val prefixes: List? = null, // use global if null + val placeholders: List? = null, // use global if null or if key not found +) + +@Serializable +public data class Prefix( + val scheme: String, + val host: String, + val autoVerified: Boolean, +) { + init { + // for validation purposes + DeepLinkHandler.Prefix("$scheme://$host") + } +} + +@Serializable +public data class Placeholder( + val key: String, + val values: List, +) + +internal object PatternSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Pattern", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Pattern { + return Pattern(decoder.decodeString()) + } + + override fun serialize(encoder: Encoder, value: Pattern) { + throw UnsupportedOperationException() + } +} diff --git a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/Deeplinks.kt b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/Deeplinks.kt deleted file mode 100644 index 3bf2164d0..000000000 --- a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/Deeplinks.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.freeletics.khonshu.deeplinks.plugin - -internal data class DeepLinks( - val prefixes: List, - val placeholders: List, - val deepLinks: Map, -) - -internal data class DeepLink( - val patterns: List, - val prefixes: List?, // use global if null - val placeholders: List?, // use global if null or if key not found -) - -internal data class Prefix( - val value: String, - val autoVerified: Boolean, -) - -internal data class Placeholder( - val key: String, - val values: List, -) diff --git a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfigurator.kt b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfigurator.kt new file mode 100644 index 000000000..3cf610a94 --- /dev/null +++ b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfigurator.kt @@ -0,0 +1,99 @@ +package com.freeletics.khonshu.deeplinks.plugin + +import com.freeletics.khonshu.navigation.deeplinks.DeepLinkHandler +import com.freeletics.khonshu.navigation.deeplinks.replacePlaceholders +import com.freeletics.khonshu.navigation.internal.InternalNavigationApi +import java.io.File +import net.peanuuutz.tomlkt.Toml + +private const val PLACEHOLDER = "" + +internal fun configure( + configurationFile: File, + inputManifestFile: File, + outputManifestFile: File, +) { + val manifest = inputManifestFile.readLines().toMutableList() + val placeholderIndex = manifest.indexOfFirst { it.contains(PLACEHOLDER) } + check(placeholderIndex >= 0) { + "Did not find $PLACEHOLDER in given manifest ${inputManifestFile.absolutePath}" + } + + val configuration = Toml.decodeFromString(Configuration.serializer(), configurationFile.readText()) + val indentation = manifest[placeholderIndex].takeWhile { it == ' ' } + manifest[placeholderIndex] = intentFiltersFromConfig(configuration, indentation) + + outputManifestFile.writeText(manifest.joinToString(separator = "\n")) +} + +private fun intentFiltersFromConfig(configuration: Configuration, indentation: String): String { + val builder = IntentFilterBuilder(indentation) + + val deepLinksWithGlobalPrefixes = configuration.deepLinks.values.filter { it.prefixes == null } + if (deepLinksWithGlobalPrefixes.isNotEmpty()) { + check(configuration.prefixes.isNotEmpty()) { + "Configuration contains deep links without a prefix but has no global prefixes" + } + + configuration.prefixes.forEach { prefix -> + builder.appendIntentFilter(prefix, deepLinksWithGlobalPrefixes) + } + } + + configuration.deepLinks.values.filter { it.prefixes != null }.forEach { + it.prefixes!!.forEach { prefix -> + builder.appendIntentFilter(prefix, listOf(it)) + } + } + + return builder.toString().trim() +} + +private fun IntentFilterBuilder.appendIntentFilter(prefix: Prefix, deepLinks: List) { + start(prefix.autoVerified) + appendAction("android.intent.action.VIEW") + appendCategory("android.intent.category.DEFAULT") + appendCategory("android.intent.category.BROWSABLE") + + deepLinks.forEach { deepLink -> + deepLink.patterns.forEach { pattern -> + appendData(prefix.scheme, prefix.host, pattern) + } + } + end() +} + +private class IntentFilterBuilder( + private val indentation: String, +) { + private val builder = StringBuilder() + + fun start(autoVerify: Boolean) { + builder.appendLine("$indentation") + } + + fun appendAction(action: String) { + builder.appendLine("$indentation ") + } + + fun appendCategory(category: String) { + builder.appendLine("$indentation ") + } + + @OptIn(InternalNavigationApi::class) + fun appendData(scheme: String, host: String, pathPattern: DeepLinkHandler.Pattern) { + builder.appendLine("$indentation ") + } + + fun end() { + builder.appendLine("$indentation") + } + + override fun toString(): String { + return builder.toString() + } +} diff --git a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTask.kt b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTask.kt index 54123a64d..121c05663 100644 --- a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTask.kt +++ b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTask.kt @@ -1,48 +1,28 @@ package com.freeletics.khonshu.deeplinks.plugin -import java.io.File import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction -abstract class DeeplinksManifestConfiguratorTask : DefaultTask() { +public abstract class DeeplinksManifestConfiguratorTask : DefaultTask() { @get:InputFile - abstract val deeplinksConfigurationFile: RegularFileProperty + public abstract val deeplinksConfigurationFile: RegularFileProperty @get:InputFile - abstract val mergedManifest: RegularFileProperty + public abstract val mergedManifest: RegularFileProperty @get:OutputFile - abstract val updatedManifest: RegularFileProperty + public abstract val updatedManifest: RegularFileProperty @TaskAction - fun taskAction() { + public fun taskAction() { val configurationFile = deeplinksConfigurationFile.get().asFile val inputManifest = mergedManifest.get().asFile val outputManifest = updatedManifest.get().asFile - DeeplinksManifestConfigurator(configurationFile, inputManifest, outputManifest).configure() - } -} - -internal class DeeplinksManifestConfigurator( - private val configurationFile: File, - private val inputManifestFile: File, - private val outputManifestFile: File, -) { - fun configure() { - val configuration = configurationFile.readText() - - var manifest = inputManifestFile.readText() - manifest = manifest.replace(PLACEHOLDER, configuration) - - outputManifestFile.writeText(manifest) - } - - companion object { - const val PLACEHOLDER = "" + configure(configurationFile, inputManifest, outputManifest) } } diff --git a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksPlugin.kt b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksPlugin.kt index b218f382a..5f1efc5ee 100644 --- a/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksPlugin.kt +++ b/deeplinks-plugin/src/main/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksPlugin.kt @@ -10,7 +10,7 @@ import org.gradle.api.Project * Plugin for deeplinks integration: * - appends intent filters to app's manifest for deeplinks from configuration file */ -abstract class DeeplinksPlugin : Plugin { +public abstract class DeeplinksPlugin : Plugin { override fun apply(project: Project) { val configurationFile = File(project.projectDir, "deeplinks.toml") if (!configurationFile.exists()) { diff --git a/deeplinks-plugin/src/test/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTest.kt b/deeplinks-plugin/src/test/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTest.kt index 52354af79..f7978fa64 100644 --- a/deeplinks-plugin/src/test/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTest.kt +++ b/deeplinks-plugin/src/test/kotlin/com/freeletics/khonshu/deeplinks/plugin/DeeplinksManifestConfiguratorTest.kt @@ -1,7 +1,8 @@ package com.freeletics.khonshu.deeplinks.plugin -import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat import java.io.File +import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -11,17 +12,410 @@ class DeeplinksManifestConfiguratorTest { @get:Rule var tmpFolder: TemporaryFolder = TemporaryFolder() + private val tomlHeader = """ + [[prefixes]] + scheme = "https" + host = "www.example.com" + autoVerified = true + + [[prefixes]] + scheme = "exampleapp" + host = "example.com" + autoVerified = false + + [[placeholders]] + key = "locale" + values = [ "en", "de" ] + + [[placeholders]] + key = "user_id" + values = [ "113748745" ] + + """.trimIndent() + + private val simpleDeepLink = """ + [deepLinks.home] + patterns = [ "home" ] + """.trimIndent() + + @Test + fun `simple deep link`() = test(tomlHeader + simpleDeepLink) { + assertThat(runTest()).isEqualTo( + """ + pre content + + + + + + + + + + + + + post content + """.trimIndent(), + ) + } + + private val deepLinkWithPlaceholder = """ + [deepLinks.plans] + patterns = [ "{locale}/plans" ] + """.trimIndent() + + @Test + fun `deep link with placeholder`() = test(tomlHeader + deepLinkWithPlaceholder) { + assertThat(runTest()).isEqualTo( + """ + pre content + + + + + + + + + + + + + post content + """.trimIndent(), + ) + } + + private val deepLinkWithMultiplePlaceholders = """ + [deepLinks.user_profile] + patterns = [ "{locale}/users/{user_id}" ] + + """.trimIndent() + + @Test + fun `deep link with multiple placeholders`() = test(tomlHeader + deepLinkWithMultiplePlaceholders) { + assertThat(runTest()).isEqualTo( + """ + pre content + + + + + + + + + + + + + post content + """.trimIndent(), + ) + } + + private val deepLinkWithMultiplePatterns = """ + [deepLinks.user_profile_2] + patterns = [ "users/{user_id}", "profiles/{user_id}" ] + + """.trimIndent() + @Test - fun `simple text replace test`() = test { - Truth.assertThat(runTest()).isEqualTo( + fun `deep link with multiple patterns`() = test(tomlHeader + deepLinkWithMultiplePatterns) { + assertThat(runTest()).isEqualTo( """ pre content - deeplinks go here + + + + + + + + + + + + + + post content """.trimIndent(), ) } + private val deepLinkWithCustomPlaceholder = """ + [deepLinks.plan_by_slug] + patterns = [ "{locale}/plans/{slug}" ] + placeholders = [ + { key = "slug", values = [ "plan-foo", "plan-bar" ] }, + ] + """.trimIndent() + + @Test + fun `deep link with custom placeholder`() = test(tomlHeader + deepLinkWithCustomPlaceholder) { + assertThat(runTest()).isEqualTo( + """ + pre content + + + + + + + + + + + + + post content + """.trimIndent(), + ) + } + + private val deepLinkWithCustomPrefixes = """ + [deepLinks.user_profile_short] + patterns = [ "users/{user_id}", "profiles/{user_id}" ] + prefixes = [ + { scheme = "https", host = "xmpl.com", autoVerified = true }, + { scheme = "exampleapp", host = "xmpl.com", autoVerified = false }, + ] + """.trimIndent() + + @Test + fun `deep link with custom prefixes`() = test(tomlHeader + deepLinkWithCustomPrefixes) { + assertThat(runTest()).isEqualTo( + """ + pre content + + + + + + + + + + + + + + + post content + """.trimIndent(), + ) + } + + private val multipleDeepLinks = listOf( + simpleDeepLink, + deepLinkWithPlaceholder, + deepLinkWithMultiplePlaceholders, + deepLinkWithMultiplePatterns, + deepLinkWithCustomPlaceholder, + deepLinkWithCustomPrefixes, + ).joinToString(separator = "\n") + + @Test + fun `multiple deep links`() = test(tomlHeader + multipleDeepLinks) { + assertThat(runTest()).isEqualTo( + """ + pre content + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + post content + """.trimIndent(), + ) + } + + @Test + fun `manifest without placeholder fails`() = test(inputManifestContent = "a\nb\nc") { + val exception = assertThrows(IllegalStateException::class.java) { + runTest() + } + + assertThat(exception).hasMessageThat() + .startsWith("Did not find in ") + } + + @Test + fun `no prefixes defined fails`() = test(simpleDeepLink) { + val exception = assertThrows(IllegalStateException::class.java) { + runTest() + } + + assertThat(exception).hasMessageThat() + .startsWith("Configuration contains deep links without a prefix but has no global prefixes") + } + + @Test + fun `no prefixes defined works if all deeplinks have their own`() = test(deepLinkWithCustomPrefixes) { + runTest() + } + private fun test( configurationContent: String = SAMPLE_CONFIG, inputManifestContent: String = INPUT_MANIFEST_CONTENT.trimIndent(), @@ -48,26 +442,26 @@ private class TestScope( inputManifestContent: String, ) { + private val configFile: File + private val inputManifestFile: File private val outputManifestFile: File - private val configurator: DeeplinksManifestConfigurator init { - val configFile = temporaryFolder.newFile("config") + configFile = temporaryFolder.newFile("config") configFile.writeText(configurationContent) - val inputManifestFile = temporaryFolder.newFile("inputManifest") + inputManifestFile = temporaryFolder.newFile("inputManifest") inputManifestFile.writeText(inputManifestContent) outputManifestFile = temporaryFolder.newFile("outputManifest") - configurator = DeeplinksManifestConfigurator( + } + + fun runTest(): String { + configure( configFile, inputManifestFile, outputManifestFile, ) - } - - fun runTest(): String { - configurator.configure() return outputManifestFile.readText() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eede30ed8..be2651bca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -93,6 +93,8 @@ androidx-savedstate = { module = "androidx.savedstate:savedstate", version.ref = androidx-viewbinding = { module = "androidx.databinding:viewbinding", version.ref = "android-gradle" } uri = { module = "com.eygraber:uri-kmp", version.ref = "uri" } +toml = { module = "net.peanuuutz.tomlkt:tomlkt", version = "0.3.5" } +serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version = "1.6.0" } inject = { module = "javax.inject:javax.inject", version.ref = "inject" } dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } @@ -133,6 +135,7 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } publish = { id = "com.vanniktech.maven.publish", version.ref = "publish" } diff --git a/navigation/src/androidUnitTest/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkHandlerTest.kt b/navigation/src/androidUnitTest/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkHandlerTest.kt new file mode 100644 index 000000000..10af5b1d1 --- /dev/null +++ b/navigation/src/androidUnitTest/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkHandlerTest.kt @@ -0,0 +1,64 @@ +package com.freeletics.khonshu.navigation.deeplinks + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test + +class DeepLinkHandlerTest { + + @Test + fun `fails on invalid prefix values`() { + listOf( + "https", + "https://", + "example.com", + "://example.com", + "https://example.com/", + "https://example.com/abc", + "abc/https://example.com", + ).forEach { + val exception = assertThrows(IllegalStateException::class.java) { + DeepLinkHandler.Prefix(it) + } + + assertThat(exception).hasMessageThat() + .isEqualTo("$it does not match ^[a-z]+://[a-z0-9._-]+\\.[a-z]+$") + } + } + + @Test + fun `fails on pattern values with leading slash`() { + listOf( + "/", + "/abc", + "/{abc}/a", + ).forEach { + println(it) + val exception = assertThrows(IllegalStateException::class.java) { + DeepLinkHandler.Pattern(it) + } + + assertThat(exception).hasMessageThat() + .isEqualTo("Pattern should not start with a / but is $it") + } + } + + @Test + fun `fails on invalid pattern values containing question mark`() { + listOf( + "?", + "?abc", + "abc?", + "abc?abc", + "abc?a=b&c=d", + ).forEach { + println(it) + val exception = assertThrows(IllegalStateException::class.java) { + DeepLinkHandler.Pattern(it) + } + + assertThat(exception).hasMessageThat() + .isEqualTo("Pattern should not contain any query parameters but is $it") + } + } +} diff --git a/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkHandler.kt b/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkHandler.kt index 62ff12d68..6eabd1e01 100644 --- a/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkHandler.kt +++ b/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkHandler.kt @@ -64,11 +64,11 @@ public interface DeepLinkHandler { public value class Pattern(internal val value: String) { init { check(!value.startsWith("/")) { "Pattern should not start with a / but is $value" } - check(!value.contains("?")) { "Pattern should not contain any query parameters is $value" } + check(!value.contains("?")) { "Pattern should not contain any query parameters but is $value" } } } private companion object { - private val PREFIX_REGEX = "[a-z]+://[a-z0-9._-]+.[a-z]+".toRegex() + private val PREFIX_REGEX = "^[a-z]+://[a-z0-9._-]+\\.[a-z]+$".toRegex() } } diff --git a/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkMatcher.kt b/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkMatcher.kt index 4e9a614dc..6b02d6cd8 100644 --- a/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkMatcher.kt +++ b/navigation/src/commonMain/kotlin/com/freeletics/khonshu/navigation/deeplinks/DeepLinkMatcher.kt @@ -44,7 +44,7 @@ internal fun DeepLinkHandler.findMatchingPattern( // find the pattern that matches uriString return patterns.find { pattern -> // replace all {name} placeholders in the pattern with a regex placeholder - val regexPattern = pattern.value.replace(PARAM_REGEX, PARAM_VALUE) + val regexPattern = pattern.replacePlaceholders() // when the pattern is not empty check for it, otherwise check for the prefixes with // an optional trailing slash, query parameters are allowed in both cases val regex = if (regexPattern.isNotBlank()) { @@ -64,14 +64,19 @@ private fun Set.asOneOfRegex(): String { } } +@InternalNavigationApi +public fun Pattern.replacePlaceholders(replacement: String = PARAM_VALUE): String { + // $1 and $3 will add the optional leading and trailing slashes if needed + return value.replace(PARAM_REGEX, "$1$replacement$3") +} + // matches placeholders like {locale} or {foo_bar-1}, requires a leading slash and either a trailing // slash or the end of the string to avoid that a path segment is not fully filled by the // placeholder private val PARAM_REGEX = "(/|^)\\{([a-zA-Z][a-zA-Z0-9_-]*)\\}(/|$)".toRegex() // a regex for values that are allowed in the path segment that contains the placeholder -// $2 will add the optional trailing / if needed -private const val PARAM_VALUE = "$1([a-zA-Z0-9_'!+%~=,\\-\\.\\@\\$\\:]+)$3" +private const val PARAM_VALUE = "([a-zA-Z0-9_'!+%~=,\\-\\.\\@\\$\\:]+)" // the query parameter itself is optional and starts with a question mark, afterwards anything // is accepted since its not part of the pattern, ends with the end of the whole url