diff --git a/deeplinkdispatch-base/src/main/java/com/airbnb/deeplinkdispatch/DeepLinkEntry.kt b/deeplinkdispatch-base/src/main/java/com/airbnb/deeplinkdispatch/DeepLinkEntry.kt index 10413151..d45e268a 100644 --- a/deeplinkdispatch-base/src/main/java/com/airbnb/deeplinkdispatch/DeepLinkEntry.kt +++ b/deeplinkdispatch-base/src/main/java/com/airbnb/deeplinkdispatch/DeepLinkEntry.kt @@ -111,17 +111,72 @@ sealed class DeepLinkEntry( } } + /** + * Count of placeholder parameters in the URI template + */ + private val placeholderCount: Int by lazy { + placeholderRegex.findAll(uriTemplate).count() + } + + /** + * Count of configurable path segments in the URI template + */ + private val configurablePathSegmentCount: Int by lazy { + uriTemplate.count { it == CONFIGURABLE_PATH_SEGMENT_PREFIX_CHAR } + } + + /** + * Total number of non-concrete elements (placeholders + configurable path segments) + */ + private val totalNonConcreteElements: Int by lazy { + placeholderCount + configurablePathSegmentCount + } + + /** + * List of all non-concrete element indices and their types, in order + */ + private val nonConcreteElementIndicesAndTypes: List> by lazy { + val result = mutableListOf>() + var index = 0 + while (index < uriTemplate.length) { + val char = uriTemplate[index] + if (char == CONFIGURABLE_PATH_SEGMENT_PREFIX_CHAR || char == COMPONENT_PARAM_PREFIX_CHAR) { + result.add(Pair(index, char)) + } + index++ + } + result + } + fun templatesMatchesSameUrls(other: DeepLinkEntry) = uriTemplateWithoutPlaceholders == other.uriTemplateWithoutPlaceholders /** - * Whatever template has the first placeholder (and then configurable path segment) is the less - * concrete one. - * Because if they would have been all in the same index those elements would have been on the - * same level and in the same "list" of elements we compare in order. - * In this case the one with the more concete element would have won and the same is true here. + * Compares two DeepLinkEntry instances by their concreteness (specificity). + * More concrete (specific) entries are considered "less than" and will be matched first. + * + * Comparison logic: + * 1. Fully concrete URLs (no placeholders/configurable segments) are most concrete + * 2. URLs with earlier first non-concrete element position are more concrete + * 3. When firstNonConcreteIndex is equal, we compare by: + * a. Total count of non-concrete elements (fewer = more concrete) + * b. Pairwise type and index comparison at each non-concrete position: + * - Iterate through all non-concrete element positions in order + * - At the first position where types differ, configurable path segment (<) + * is more concrete than placeholder ({}) + * - If types are the same but indices differ, later index is more concrete + * c. Length of concrete parts (longer = more concrete) + * + * Examples of concreteness ordering (most to least concrete): + * - scheme://host/path1/path2/path3 (fully concrete) + * - scheme://host/path1/{param}/path3 (1 placeholder, later position) + * - scheme://host/{param}/path2/path3 (1 placeholder, earlier position) + * - scheme://host/{p1}/path2/path3/{p2} (2 placeholders, 2nd at later position) + * - scheme://host/{p1}/path2/{p2}/path4 (2 placeholders, 2nd at earlier position) + * - scheme://host/{param1}/path2/ (2 non-concrete, configurable at 2nd position) + * - scheme://host/{param1}/path2/{param2} (2 non-concrete, placeholder at 2nd position) */ - override fun compareTo(other: DeepLinkEntry): Int = - when { + override fun compareTo(other: DeepLinkEntry): Int { + return when { /** * Specific conditions added for fully concrete links. * Concrete link will always return -1 for firstNonConcreteIndex, @@ -131,14 +186,53 @@ sealed class DeepLinkEntry( other.firstNonConcreteIndex < 0 && other.firstNonConcreteIndex != this.firstNonConcreteIndex -> 1 this.firstNonConcreteIndex < other.firstNonConcreteIndex -> 1 this.firstNonConcreteIndex == other.firstNonConcreteIndex -> { - if (this.firstNonConcreteIndex == -1 || uriTemplate[firstNonConcreteIndex] == other.uriTemplate[firstNonConcreteIndex]) { - 0 - } else if (uriTemplate[firstNonConcreteIndex] == CONFIGURABLE_PATH_SEGMENT_PREFIX_CHAR) { - -1 - } else { - 1 + when { + // Both are fully concrete + this.firstNonConcreteIndex == -1 -> 0 + // Compare by total number of non-concrete elements (fewer is more concrete) + this.totalNonConcreteElements != other.totalNonConcreteElements -> + this.totalNonConcreteElements.compareTo(other.totalNonConcreteElements) + // Same number of non-concrete elements, compare types and positions pairwise + else -> { + val thisElements = this.nonConcreteElementIndicesAndTypes + val otherElements = other.nonConcreteElementIndicesAndTypes + + // Compare types and indices at each non-concrete position + for (i in 0 until min(thisElements.size, otherElements.size)) { + val thisIndex = thisElements[i].first + val otherIndex = otherElements[i].first + val thisType = thisElements[i].second + val otherType = otherElements[i].second + + // If types differ, configurable path segment is more concrete than placeholder + if (thisType != otherType) { + return if (thisType == CONFIGURABLE_PATH_SEGMENT_PREFIX_CHAR) { + -1 + } else { + 1 + } + } + + // Same type but different indices - later index is more concrete + if (thisIndex != otherIndex) { + return if (thisIndex > otherIndex) { + -1 + } else { + 1 + } + } + } + + // All types and positions match, compare by length of concrete parts (longer is more concrete) + if (this.uriTemplateWithoutPlaceholders.length != other.uriTemplateWithoutPlaceholders.length) { + -this.uriTemplateWithoutPlaceholders.length.compareTo(other.uriTemplateWithoutPlaceholders.length) + } else { + 0 + } + } } } else -> -1 } + } } diff --git a/deeplinkdispatch/src/test/java/com/airbnb/deeplinkdispatch/DeepLinkEntryTest.kt b/deeplinkdispatch/src/test/java/com/airbnb/deeplinkdispatch/DeepLinkEntryTest.kt index 60cc9786..8bcda3f5 100644 --- a/deeplinkdispatch/src/test/java/com/airbnb/deeplinkdispatch/DeepLinkEntryTest.kt +++ b/deeplinkdispatch/src/test/java/com/airbnb/deeplinkdispatch/DeepLinkEntryTest.kt @@ -351,6 +351,147 @@ class DeepLinkEntryTest { assertThat(matchEnHost!!.parameterMap[url]).isEqualTo(parameterMap) } + @Test + fun testCompareToFullyConcreteVsPlaceholder() { + // Fully concrete should be "less than" (more specific) than one with placeholder + val concreteEntry = activityDeepLinkEntry("airbnb://host/path1/path2") + val placeholderEntry = activityDeepLinkEntry("airbnb://host/{param}/path2") + + assertThat(concreteEntry.compareTo(placeholderEntry)).isLessThan(0) + assertThat(placeholderEntry.compareTo(concreteEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToSameFirstPlaceholderDifferentTotalConcreteness() { + // Both have placeholder at same position, but first has more concrete segments after + val moreConcreteEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/path3") + val lessConcreteEntry = activityDeepLinkEntry("airbnb://host/{param1}/{param2}/path3") + + // More concrete entry should be "less than" (higher priority) + assertThat(moreConcreteEntry.compareTo(lessConcreteEntry)).isLessThan(0) + assertThat(lessConcreteEntry.compareTo(moreConcreteEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToSameFirstPlaceholderDifferentLaterPositions() { + // The first placeholder is at same position for both, but one of them does not have a second placeholder + val moreConcreteEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/path3/path4") + val lessConcreteEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/{param2}/path4") + + // More concrete entry should be "less than" (higher priority) + assertThat(moreConcreteEntry.compareTo(lessConcreteEntry)).isLessThan(0) + assertThat(lessConcreteEntry.compareTo(moreConcreteEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToBothHaveTwoPlaceholdersSecondAtDifferentPositions() { + // Both have two placeholders with first at same position, but second placeholder is at different positions + val secondPlaceholderLaterEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/path3/{param2}") + val secondPlaceholderEarlierEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/{param2}/path4") + + // Entry with second placeholder later is more concrete (less than) + assertThat(secondPlaceholderLaterEntry.compareTo(secondPlaceholderEarlierEntry)).isLessThan(0) + assertThat(secondPlaceholderEarlierEntry.compareTo(secondPlaceholderLaterEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToBothHaveThreePlaceholdersDifferentThirdPosition() { + // Both have three placeholders with first two at same positions, third at different positions + val thirdPlaceholderLaterEntry = activityDeepLinkEntry("airbnb://host/{p1}/path2/{p2}/path4/path5/{p3}") + val thirdPlaceholderEarlierEntry = activityDeepLinkEntry("airbnb://host/{p1}/path2/{p2}/path4/{p3}/path6") + + // Entry with third placeholder later is more concrete + assertThat(thirdPlaceholderLaterEntry.compareTo(thirdPlaceholderEarlierEntry)).isLessThan(0) + assertThat(thirdPlaceholderEarlierEntry.compareTo(thirdPlaceholderLaterEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToBothHaveTwoConfigurablesDifferentSecondPosition() { + // Both have two configurables with first at same position, second at different positions + val secondConfigLaterEntry = activityDeepLinkEntry("airbnb://host//path2/path3/") + val secondConfigEarlierEntry = activityDeepLinkEntry("airbnb://host//path2//path4") + + // Entry with second configurable later is more concrete + assertThat(secondConfigLaterEntry.compareTo(secondConfigEarlierEntry)).isLessThan(0) + assertThat(secondConfigEarlierEntry.compareTo(secondConfigLaterEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToBothHaveMixedNonConcretesDifferentSecondPosition() { + // Both have placeholder first, then one has configurable and other has placeholder at different positions + val secondNonConcreteLaterEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/path3/") + val secondNonConcreteEarlierEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/{param2}/path4") + + // Entry with second non-concrete later is more concrete + assertThat(secondNonConcreteLaterEntry.compareTo(secondNonConcreteEarlierEntry)).isLessThan(0) + assertThat(secondNonConcreteEarlierEntry.compareTo(secondNonConcreteLaterEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToBothHaveSamePositionsSameTypesButDifferentLengths() { + // Both have placeholders at exact same positions and types, but different concrete part lengths + val longerConcreteEntry = activityDeepLinkEntry("airbnb://host/{param1}/longerpath/{param2}") + val shorterConcreteEntry = activityDeepLinkEntry("airbnb://host/{param1}/path/{param2}") + + // Entry with longer concrete parts is more concrete + assertThat(longerConcreteEntry.compareTo(shorterConcreteEntry)).isLessThan(0) + assertThat(shorterConcreteEntry.compareTo(longerConcreteEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToConfigurablePathSegmentVsPlaceholder() { + // Configurable path segment should be more concrete than placeholder + val placeholderEntry = activityDeepLinkEntry("airbnb://host/{param}/path2") + val configurableEntry = activityDeepLinkEntry("airbnb://host//path2") + + // Configurable entry should be "less than" (higher priority) + assertThat(configurableEntry.compareTo(placeholderEntry)).isLessThan(0) + assertThat(placeholderEntry.compareTo(configurableEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToMultiplePlaceholdersVsFullyConcrete() { + val concreteEntry = activityDeepLinkEntry("airbnb://host/path1/path2/path3") + val twoPlaceholdersEntry = activityDeepLinkEntry("airbnb://host/{param1}/{param2}/path3") + + assertThat(concreteEntry.compareTo(twoPlaceholdersEntry)).isLessThan(0) + assertThat(twoPlaceholdersEntry.compareTo(concreteEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToSameFirstDifferentSecondNonConcreteType() { + // Both have placeholder at same first position, but second non-concrete element differs in type + // Configurable path segment is more concrete than placeholder + val configurableSecondEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2//path4") + val placeholderSecondEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/{param2}/path4") + + // Configurable at second position should be more concrete (less than) + assertThat(configurableSecondEntry.compareTo(placeholderSecondEntry)).isLessThan(0) + assertThat(placeholderSecondEntry.compareTo(configurableSecondEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToSameFirstTwoNonConcretesDifferentThirdType() { + // Both have same types for first two non-concrete elements, but differ at third + val configurableThirdEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/{param2}//path5") + val placeholderThirdEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2/{param2}/{param3}/path5") + + // Configurable at third position should be more concrete (less than) + assertThat(configurableThirdEntry.compareTo(placeholderThirdEntry)).isLessThan(0) + assertThat(placeholderThirdEntry.compareTo(configurableThirdEntry)).isGreaterThan(0) + } + + @Test + fun testCompareToMultipleConfigurableVsPlaceholder() { + // First has configurable, second has placeholder at first position + val configurableFirstEntry = activityDeepLinkEntry("airbnb://host//path2/{param2}/path4") + val placeholderFirstEntry = activityDeepLinkEntry("airbnb://host/{param1}/path2//path4") + + // Configurable at first position should be more concrete (less than) + assertThat(configurableFirstEntry.compareTo(placeholderFirstEntry)).isLessThan(0) + assertThat(placeholderFirstEntry.compareTo(configurableFirstEntry)).isGreaterThan(0) + } + companion object { private fun activityDeepLinkEntry( uriTemplate: String,