Skip to content

Commit 8d856d7

Browse files
rossbacherAndreas Rossbacher
andauthored
Added a new concept of deep link handlers. (#330)
* Add support for deeplink handlers to mach index. * Rename .java to .kt * Convert BaseDeepLinkDelegate to Kotlin. * Add new concept of DeepLinkHandler.kt * Update handler test to test several success and failure scenarios. * Added documentation * Cleanup, fixed Java support and added tests. Better error handling in the processor. Added support for throwing on type conversion errors. * Cleanup * Fix Proguard/R8 config for new requirements. Add Proguard/R8 config to sample release build type. * type fix * Fixed keep rules and tested with obfuscation. Do not use InBetweenDepLinkHandler on Java test. Cleaner way to get the type of the args object that only works because of the way we define these here. * Use lambdas to handle type conversion issues to give implementing apps more flexibility. * Fixed formatting for ktlint * Moved DeepLinkHandler<T> from abstract class to interface. Changed behavior so that the arguments class must only contain a subset of all placeholders or query elements in the deep link. Added support for custom type converters. Updated reflection logic to find arg type to work on implemented interfaces and extended classes that implement the interface. Moved type conversion error handlers to BaseDeepLinkDelegate.kt. Rewrote project speciffic DeepLinkDelegate creation fully and support more constructor overrides. Added updated tests for new features. Added error when havong two @com.airbnb.deeplinkdelegate.DeepLinkHandler annotated classes in one package. Added test for that error case. * Add type conversion support for generic types. * Fixed R8/Proguard rule Added support for no parameter class. Added logging to handlers Cleanup removed warnings. * Fixed R8/Proguard rule Added support for no parameter class. Added logging to handlers Cleanup removed warnings. Moved type converters to functional interface so that the suopported set can change during runtime. Renamed parameters to deepLinkArgs * KSP to 1.0 * Keep registries public. * fix handling of null returned from annotated methods. added unit tests for the behavior. * Update interface for deep link handler argument type conversion errors. Add handler and handler arg class to DeepLinkResult. Fix issue when handler was implemented on extension class plus added tests for this and further removed extension case. * Cleanup documentation Co-authored-by: Andreas Rossbacher <andreas.rossbacher@airbnb.com>
1 parent 64b1834 commit 8d856d7

File tree

49 files changed

+4403
-1004
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+4403
-1004
lines changed

README.md

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,85 @@ public class MainActivity extends Activity {
4848
}
4949
```
5050

51+
### DeepLinkHandler Annotations
52+
53+
You can annotate a Kotlin `object` that is extending `com.airbnb.deeplinkdispatch.handler.DeepLinkHandler`
54+
with an `@DeepLink` annotation.
55+
56+
```kotlin
57+
@DeepLink("foo://example.com/handlerDeepLink/{param1}?query1={queryParameter}")
58+
object ProjectDeepLinkHandler : DeepLinkHandler<ProjectDeepLinkHandlerArgs>() {
59+
override fun handleDeepLink(parameters: ProjectDeepLinkHandlerArgs) {
60+
/**
61+
* From here any internal/3rd party navigation framework can be called the provided args.
62+
*/
63+
}
64+
}
65+
66+
data class ProjectDeepLinkHandlerArgs(
67+
@DeeplinkParam("param1", DeepLinkParamType.Path) val number: Int,
68+
@DeeplinkParam("query1", DeepLinkParamType.Query) val flag: Boolean?,
69+
)
70+
```
71+
72+
DeepLinkDispatch will then call the `handleDeepLink` function in your handler with the path placeholders
73+
and queryParameters converted into an instance of the specified type class.
74+
75+
Query parameter conversion is supported for nullable and non nullable versions of `Boolean`,`Int`,
76+
`Long`,`Short`,`Byte`,`Double`,`Float` and `String` as well as the same types in Java. For other
77+
types see: [Type conversion](#type-conversion)
78+
79+
This will give compile time safety, as all placeholders and query parameters specified in the template
80+
inside the `@DeepLink` annotation must be present in the arguments class for the processor to pass.
81+
This is also true the other way around as all fields in the arguments class must be annotated and must
82+
be present in the template inside the annotation.
83+
84+
From this function you can now call into any internal or 3rd party navigation system
85+
without any Intent being fired at all and with type safety for your arguments.
86+
87+
*Note:* Even though they must be listed in the template and annotation, argument values annotated
88+
with `DeepLinkParamType.Query` can be null as they are allowed to not be present in the matched url.
89+
90+
#### Type conversion
91+
92+
If you want to support the automatic conversions of types other than `Boolean`,`Int`,`Long`,`Short`,`Byte`,
93+
`Double`,`Float` and `String` in deep link argument classes you can add support by adding your own type
94+
converters in the `DeepLinkDelegate` class that you are instantiating.
95+
96+
Type conversion is handled via a lambda that you can set in the `DeepLinkDelegate` constructor.
97+
98+
All type converters you want to add get added to an instance of `TypeConverters` which then in turn
99+
gets returned by the lambda. This way you can add type converters on the fly while the app is running
100+
(e.g. if you just downloaded a dynamic feature which supports additional types).
101+
102+
There is an example of this in the `sample` app for this. Here is a brief overview:
103+
104+
```java
105+
TypeConverters typeConverters = new TypeConverters();
106+
typeConverters.put(ColorDrawable.class, value -> {
107+
switch (value.toLowerCase()) {
108+
case "red":
109+
return new ColorDrawable(0xff0000ff);
110+
}
111+
});
112+
113+
Function0<TypeConverters> typeConvertersLambda = () -> typeConverters;
114+
115+
DeepLinkDelegate deepLinkDelegate = new DeepLinkDelegate(
116+
...
117+
typeConvertersLambda,
118+
...);
119+
```
120+
121+
#### Type conversion errors
122+
123+
If a url parameter cannot be converted to the specified type, the system will -- by default -- set the
124+
value to `0` or `null`, depending on if the type is nullable. However this behavior can be overwritten
125+
by implementing a lambda `Function3<DeepLinkUri, Type, String, Integer>` and setting it to
126+
`typeConversionErrorNullable` and/or `typeConversionErrorNonNullable` via the `DeepLinkDelegate`
127+
constructor. When called, the lambda will let you know about the matching `DeepLinkUri` template, the
128+
type and the value that was tried to type convert so you can also log these events.
129+
51130
### Method Annotations
52131

53132
You can also annotate any `public static` method with `@DeepLink`. DeepLinkDispatch will call that
@@ -604,15 +683,12 @@ At runtime we traverse the graph for each module to find the correct action to u
604683
* Configurable path segments can have empty values e.g. `<brand>` can be set to `""` in the previous example. Which would then match `dld://airbnb/cereal`. If a deeplink like that is defined already somewhere else the same match rules as mentioned before apply to which match actually gets found.
605684
* Because of limitations of the algo the last path element (the item behind the last slash) cannot be a configurable path segment with it's value set to `""`. Currently the system will allow you to do this but will not correctly match in that case.
606685
607-
## Proguard Rules
686+
## Proguard/R8 Rules
608687
609-
```
610-
-keep @interface com.airbnb.deeplinkdispatch.DeepLink
611-
-keepclasseswithmembers class * {
612-
@com.airbnb.deeplinkdispatch.DeepLink <methods>;
613-
}
614-
```
615-
**Note:** remember to include Proguard rules to keep Custom annotations you have used, for example by package:
688+
The Proguard/R8 rules mandatory for teh lib are defined in the [proguard-rules.pro](deeplinkdispatch/proguard-rules.pro) in `deeplinkdispatch`. However
689+
they are already included via `consumerProguardFiles` so there is nothing you have to do to include them.
690+
691+
Please note however that you must add your own Proguard/R8 rules to keep Custom annotations you have used. For example:
616692
617693
```
618694
-keep @interface your.package.path.deeplink.<annotation class name>

build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ allprojects {
3131
}
3232
}
3333

34+
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
35+
kotlinOptions {
36+
allWarningsAsErrors = true
37+
}
38+
}
39+
3440
def getReleaseRepositoryUrl() {
3541
return hasProperty('RELEASE_REPOSITORY_URL') ? RELEASE_REPOSITORY_URL
3642
: "https://oss.sonatype.org/service/local/staging/deploy/maven2/"

deeplinkdispatch-base/src/main/java/com/airbnb/deeplinkdispatch/DeepLinkEntry.kt

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,21 @@ data class DeepLinkMatchResult(
3333

3434
override fun toString(): String {
3535
return "uriTemplate: ${deeplinkEntry.uriTemplate} " +
36-
"activity: ${deeplinkEntry.activityClass.name} " +
37-
"method: ${deeplinkEntry.method} " +
36+
"activity: ${deeplinkEntry.clazz.name} " +
37+
"${if (deeplinkEntry is DeepLinkEntry.MethodDeeplinkEntry) "method: ${deeplinkEntry.method} " else ""}" +
3838
"parameters: $parameterMap"
3939
}
4040

41-
private val firstConfigurablePathSegmentIndex: Int by lazy { deeplinkEntry.uriTemplate.indexOf(configurablePathSegmentPrefixChar) }
42-
private val firstPlaceholderIndex: Int by lazy { deeplinkEntry.uriTemplate.indexOf(componentParamPrefixChar) }
41+
private val firstConfigurablePathSegmentIndex: Int by lazy {
42+
deeplinkEntry.uriTemplate.indexOf(
43+
configurablePathSegmentPrefixChar
44+
)
45+
}
46+
private val firstPlaceholderIndex: Int by lazy {
47+
deeplinkEntry.uriTemplate.indexOf(
48+
componentParamPrefixChar
49+
)
50+
}
4351
private val firstNonConcreteIndex: Int by lazy {
4452
if (firstPlaceholderIndex == -1 && firstConfigurablePathSegmentIndex == -1) {
4553
-1
@@ -78,21 +86,25 @@ data class DeepLinkMatchResult(
7886
}
7987
}
8088

81-
data class DeepLinkEntry(
82-
val uriTemplate: String,
83-
/**
84-
* The class name where the annotation corresponding to where an instance of DeepLinkEntry is declared.
85-
*/
86-
val className: String,
87-
val method: String?
88-
) {
89+
sealed class DeepLinkEntry(open val uriTemplate: String, open val className: String) {
8990

90-
enum class Type {
91-
CLASS,
92-
METHOD
93-
}
91+
data class ActivityDeeplinkEntry(
92+
override val uriTemplate: String,
93+
override val className: String
94+
) : DeepLinkEntry(uriTemplate, className)
9495

95-
val activityClass: Class<*> by lazy {
96+
data class MethodDeeplinkEntry(
97+
override val uriTemplate: String,
98+
override val className: String,
99+
val method: String
100+
) : DeepLinkEntry(uriTemplate, className)
101+
102+
data class HandlerDeepLinkEntry(
103+
override val uriTemplate: String,
104+
override val className: String
105+
) : DeepLinkEntry(uriTemplate, className)
106+
107+
val clazz: Class<*> by lazy {
96108
try {
97109
Class.forName(className)
98110
} catch (e: ClassNotFoundException) {
@@ -103,8 +115,4 @@ data class DeepLinkEntry(
103115
)
104116
}
105117
}
106-
107-
override fun toString(): String {
108-
return "uriTemplate: $uriTemplate className: $className method: $method"
109-
}
110118
}

deeplinkdispatch-base/src/main/java/com/airbnb/deeplinkdispatch/DeepLinkUri.java

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616

1717
package com.airbnb.deeplinkdispatch;
1818

19+
import androidx.annotation.NonNull;
1920
import androidx.annotation.Nullable;
2021

22+
import org.jetbrains.annotations.NotNull;
23+
2124
import java.io.EOFException;
2225
import java.net.IDN;
2326
import java.net.InetAddress;
@@ -29,10 +32,13 @@
2932
import java.util.ArrayList;
3033
import java.util.Arrays;
3134
import java.util.Collections;
35+
import java.util.HashSet;
3236
import java.util.LinkedHashSet;
3337
import java.util.List;
3438
import java.util.Locale;
3539
import java.util.Set;
40+
import java.util.regex.Matcher;
41+
import java.util.regex.Pattern;
3642

3743
import okio.Buffer;
3844

@@ -55,6 +61,7 @@ public final class DeepLinkUri {
5561
static final String CONVERT_TO_URI_ENCODE_SET = "^`{}|\\";
5662
static final String FORM_ENCODE_SET = " \"':;<=>@[]^`{}|/\\?#&!$(),~";
5763
static final String FRAGMENT_ENCODE_SET = "";
64+
static final Pattern PLACEHOLDER_REGEX = Pattern.compile("\\{(.*?)\\}");
5865

5966
/** Either "http" or "https". */
6067
private final String scheme;
@@ -91,7 +98,9 @@ public final class DeepLinkUri {
9198
/** Canonical URL. */
9299
private final String url;
93100

94-
private DeepLinkUri(Builder builder) {
101+
private String urlTemplate;
102+
103+
private DeepLinkUri(Builder builder, String urlTemplate) {
95104
this.scheme = builder.scheme;
96105
this.username = percentDecode(builder.encodedUsername);
97106
this.password = percentDecode(builder.encodedPassword);
@@ -105,6 +114,7 @@ private DeepLinkUri(Builder builder) {
105114
? percentDecode(builder.encodedFragment)
106115
: null;
107116
this.url = builder.toString();
117+
this.urlTemplate = urlTemplate;
108118
}
109119

110120
/** Returns this URL as a {@link URL java.net.URL}. */
@@ -301,6 +311,27 @@ String query() {
301311
return result.toString();
302312
}
303313

314+
public @NonNull
315+
Set<String> getSchemeHostPathPlaceholders() {
316+
if (toTemplateString() == null) return Collections.emptySet();
317+
if (this.query() == null || this.query().isEmpty()) return getPlaceHolders(toTemplateString());
318+
return getPlaceHolders(
319+
toTemplateString().substring(0, toTemplateString().indexOf(this.query()))
320+
);
321+
}
322+
323+
@NotNull
324+
private Set<String> getPlaceHolders(String input) {
325+
final Matcher matcher = PLACEHOLDER_REGEX.matcher(input);
326+
Set<String> placeholders = new HashSet<>(matcher.groupCount());
327+
while (matcher.find()) {
328+
for (int i = 1; i <= matcher.groupCount(); i++) {
329+
placeholders.add(matcher.group(i));
330+
}
331+
}
332+
return placeholders;
333+
}
334+
304335
@Nullable
305336
List<String> getQueryNamesAndValues() {
306337
return queryNamesAndValues;
@@ -454,6 +485,10 @@ static DeepLinkUri get(URI uri) {
454485
return url;
455486
}
456487

488+
public String toTemplateString() {
489+
return urlTemplate;
490+
}
491+
457492
static final class Builder {
458493
String scheme;
459494
String encodedUsername = "";
@@ -463,6 +498,7 @@ static final class Builder {
463498
final List<String> encodedPathSegments = new ArrayList<>();
464499
List<String> encodedQueryNamesAndValues;
465500
String encodedFragment;
501+
String templateUrl;
466502

467503
Builder() {
468504
encodedPathSegments.add(""); // The default path is '/' which needs a trailing space.
@@ -668,7 +704,7 @@ Builder encodedFragment(String encodedFragment) {
668704
DeepLinkUri build() {
669705
if (scheme == null) throw new IllegalStateException("scheme == null");
670706
if (host == null) throw new IllegalStateException("host == null");
671-
return new DeepLinkUri(this);
707+
return new DeepLinkUri(this, templateUrl);
672708
}
673709

674710
@Override public String toString() {
@@ -726,6 +762,9 @@ enum ParseResult {
726762
ParseResult parse(DeepLinkUri base, String input, boolean allowPlaceholderInScheme) {
727763
int pos = skipLeadingAsciiWhitespace(input, 0, input.length());
728764
int limit = skipTrailingAsciiWhitespace(input, pos, input.length());
765+
if (allowPlaceholderInScheme) {
766+
templateUrl = input;
767+
}
729768

730769
// Scheme.
731770
int schemeDelimiterOffset = schemeDelimiterOffset(input,

0 commit comments

Comments
 (0)