├── .codecov.yml ├── .gitignore ├── .travis.yml ├── .whitesource ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── Dependencies.kt │ └── Excludes.kt ├── deeplink ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── hellofresh │ │ └── deeplink │ │ └── extension │ │ └── DeepLinkUriKtTest.kt │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── hellofresh │ │ └── deeplink │ │ ├── Action.kt │ │ ├── BaseRoute.kt │ │ ├── DeepLinkParser.kt │ │ ├── DeepLinkUri.kt │ │ ├── Environment.kt │ │ ├── MatchResult.kt │ │ └── extension │ │ └── DeepLinkUri.kt │ └── test │ └── java │ └── com │ └── hellofresh │ └── deeplink │ ├── BaseRouteTest.kt │ ├── DeepLinkUriTest.kt │ ├── DeeplinkParserTest.kt │ ├── EmptyEnvironment.kt │ └── Routes.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── script ├── deploy-release.sh └── deploy-snapshot.sh └── settings.gradle.kts /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | threshold: 0.2 8 | if_not_found: success 9 | patch: 10 | default: 11 | enabled: no 12 | if_not_found: success -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generic files 2 | *~ 3 | *.lock 4 | *.DS_Store 5 | *.swp 6 | *.out 7 | 8 | # Built application files 9 | *.apk 10 | *.ap_ 11 | *.iml 12 | 13 | # Files for the Dalvik VM 14 | *.dex 15 | 16 | # Files for the idea 17 | .idea/* 18 | !runConfigurations 19 | 20 | # Java class files 21 | *.class 22 | 23 | # Generated files 24 | bin/ 25 | gen/ 26 | 27 | # Gradle files 28 | .gradle/ 29 | build/ 30 | 31 | # Memory Profiling files 32 | captures/ 33 | 34 | # Local configuration file (sdk path, etc) 35 | local.properties 36 | library.properties 37 | gradle.properties 38 | 39 | 40 | # Android studio 41 | projectFilesBackup/ 42 | 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | android: 3 | components: 4 | # travis/android: https://docs.travis-ci.com/user/languages/android/ 5 | - tools 6 | - platform-tools 7 | - tools 8 | # The BuildTools version used by the project 9 | - build-tools-28.0.3 10 | # The SDK version used to compile the project 11 | - android-28 12 | 13 | before_cache: 14 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 15 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 16 | before_install: 17 | - yes | sdkmanager "platforms;android-28" "platform-tools" "tools" 18 | cache: 19 | directories: 20 | - "$HOME/.gradle/caches/" 21 | - "$HOME/.gradle/wrapper/" 22 | - "$HOME/.android/build-cache" 23 | script: 24 | - "./gradlew --stacktrace check --no-daemon" 25 | after_success: 26 | - ./script/deploy-snapshot.sh 27 | deploy: 28 | - provider: script 29 | skip_cleanup: true 30 | script: ./script/deploy-release.sh 31 | on: 32 | tags: true 33 | 34 | env: 35 | global: 36 | - secure: YvcpKhpqHvHhjlFIXqx3gWfx9yeGD2Aowl/UpkVc7Jasl+pDe9E0CeFgcGsMjlC6DQWZLpd0K/UuqZhEnH2gtNDJx/f2bm3BQfjmmQXqPMBKx2PHt5rd2vH0IqVYyRwAk9KFUixh67HI+i2npOq3wB3Hm+s/vn8N3znLnPlEEDkfCZ4yJHSGt6uja5mittryW25AjYVUli8EdNnxtkhcrjLG8TK0SiybpkUp8AgxTEFcmGTK1rugAha+6bBW7J4rAFkac+Gwq2Wejr9n9IY6SPd1FsIFt7/YPjqo7jGQFNxUwrewv7NueL+3aChqogqYpG6RlVFjBRlExppHM8eD/X82v/525tOPws6K6IXj3pM3zDyJ49468ktYVtSXOLUw9d8lAFBsUrU9d1BKMoCVL/CFC4+z+UikKdAvPHpUdi9YmTmTCA5WkDcr4m/Np5gTGFFG3ZSXD3Q6n5YVKSD0ZSDhEcE57OS0lVI0tR9aaOsL6sgaEOc/6LpAV0xJBm5XqhEIiZkYwmcnx2r3+g1k6SDwQI7w/3l6mr2Za7eFiSbC2I4Vx+9VF3D73NAshVo370yfcZD4bBfSVRypJGuMs3CMsOKarVu4ea5q6PrN9WwxSdbKlF2LcZEpYyy39PLp57TmKqqyp604TGO66kFcsAfOOt0HE27/gtLroM+aIjY= 37 | - secure: CsH+n6AfxlFD8Ei4vx2ztyxxpbNCsHeYb9TA/4gI2GWjXvqV/qVfcJUa+iv1fDd1Bhl6/j6rR3xiaQjknR49xSlmWnI6zdE25HrGFU0PV/QgPOIJu4JWevkoytRajNz93D9eUHh6EuyGv8qxIhsr9JEeo7CKXtDhQIb698OImjdhtgjC7Y55ESavwMzTgNTrhEquk3ZO1hYwOLX60/lq6vfuEWtfkZd4RdSmcwYqV4FREPUBaDm1oY0Yjz5idyTzdCEEfGuuGb2EzvuDI+VY4zMkxt776YT0DHHe+VsbycAXEmid+hRgXXDCLEaoUuP8gwaLWRD5gfSINRPfpPODJJ4Z0m9CcNn7I2f6khGNIFAnrfimU6ox61ejw7H1KCPqYcUV9BjAKqGymgojCzETBtaRZaXN3WwDqmoPOur70IXC4+Th7p3JeOie1HZCE7Rq0hwepIh1Mzt4xa4AE0lh/Fh2X6JiVV4r3xIsuBD9glJOAcNSPvveAocTurHJGSlMHL0KjpgLYVVsyNcNYgQxCz4C0HHVsTdgHahKbGBvTb9v+NOqwdgNzhvmFpmxCcITjj2/+XT62mohwmNLcgBeJbK1Mf9iOH7T+R3a8Ko4Rpi2Go/+nQN9zZxSk7tFkQHIZpZiuhIapSGaZz1TdHZciH04DaJyQP8MONoJJnmiLtc= 38 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "settingsInheritedFrom": "hellofresh/whitesource-config@master" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 4 | 5 | 6 | ## [Unreleased] 7 | 8 | ## [0.3.1] - 2019-03-20 9 | ### Removed 10 | - Removed support for retrieving queries from the params map. 11 | - As a side effect, it also fixes crashes from poorly constructed (but still valid) deep links 12 | 13 | ## [0.3.0] - 2019-03-14 14 | ### Added 15 | - Added SNAPSHOT releases to JFrog 16 | - Added handler resolution section to README 17 | - Support for simple regex path matching 18 | 19 | ### Fixed 20 | - Ensure that routes are always selected in order of registration 21 | 22 | ## [0.2.0] - 2019-01-22 23 | - Improved path matching. 24 | - Route pattern with/without trailing slash now matches URI with/without trailing slash. 25 | - Named/nameless parameters now ignore empty path segments at the end of a URI. 26 | However, empty segments occurring before non-empty ones are still processed accordingly 27 | - Changed visibility of `MatchResult` to `internal`. 28 | 29 | ## [0.1.0] - 2019-01-17 30 | ### Added 31 | - Implemented deep link parser. 32 | - Platform agnostic URI implementation. 33 | - Ignore query keys clashing with predefined path params. 34 | - Support for treating host as path. 35 | - Added code sample to readme. 36 | - Set up build automation. 37 | - Added transformation extensions for DeepLinkUri to/from `android.net.Uri`. 38 | - Implemented support for nameless path segment matching. 39 | - Added unit test for `DeepLinkUri`. 40 | 41 | [Unreleased]: https://github.com/hellofresh/android-deeplink/compare/0.3.1...HEAD 42 | [0.3.1]: https://github.com/hellofresh/android-deeplink/compare/0.3.0...0.3.1 43 | [0.3.0]: https://github.com/hellofresh/android-deeplink/compare/0.2.0...0.3.0 44 | [0.2.0]: https://github.com/hellofresh/android-deeplink/compare/0.1.0...0.2.0 45 | [0.1.0]: https://github.com/hellofresh/android-deeplink/compare/2a89d70648ee809bac78a3b768fe664d3f04aad8...0.1.0 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/hellofresh/android-deeplink.svg?branch=master)](https://travis-ci.org/hellofresh/android-deeplink) [ ![Download](https://api.bintray.com/packages/hellofresh/maven/android-deeplink/images/download.svg) ](https://bintray.com/hellofresh/maven/android-deeplink/_latestVersion) 2 | 3 | # DEPRECATED 4 | 5 | The project is no longer maintained. 6 | 7 | Please **consider using a maitained fork** from [Kingsleyadio](https://github.com/kingsleyadio/android-deeplink) or copying the source code and creating the local library module if you are still relying on it. 8 | 9 | 10 | 11 | License 12 | ------- 13 | 14 | Copyright (C) 2019 The Hellofresh Android Team 15 | 16 | Licensed under the Apache License, Version 2.0 (the "License"); 17 | you may not use this file except in compliance with the License. 18 | You may obtain a copy of the License at 19 | 20 | http://www.apache.org/licenses/LICENSE-2.0 21 | 22 | Unless required by applicable law or agreed to in writing, software 23 | distributed under the License is distributed on an "AS IS" BASIS, 24 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 25 | See the License for the specific language governing permissions and 26 | limitations under the License. 27 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Releasing 2 | ======== 3 | 4 | 1. Create a branch from `master` in the format `release/` Eg. `release/1.0.0` 5 | 2. Change the `Project.version` value in `buildSrc/src/main/kotlin/Dependencies.kt` to the version number to be released. 6 | 3. Update `CHANGELOG.md` entry with changes of the release. 7 | 4. Run `./gradlew clean build` to make sure project builds successfully. 8 | 5. `git commit -am "Make release for X.Y.Z."` (where X.Y.Z is the new version). 9 | 6. `git tag -a X.Y.Z -m "Version X.Y.Z"` 10 | 7. `git push && git push --tags` 11 | 8. Update `Project.version` value in `buildSrc/src/main/kotlin/Dependencies.kt` to the next SNAPSHOT version. 12 | 9. `git commit -am "Prepare next development version."` 13 | 10. `git push` -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 18 | 19 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 20 | 21 | buildscript { 22 | repositories { 23 | google() 24 | jcenter() 25 | 26 | } 27 | dependencies { 28 | classpath(Dependencies.androidGradlePlugin) 29 | classpath(kotlin("gradle-plugin", version = Versions.kotlin)) 30 | // NOTE: Do not place your application dependencies here; they belong 31 | // in the individual module build.gradle.kts files 32 | } 33 | } 34 | 35 | plugins { 36 | id("io.gitlab.arturbosch.detekt") version Versions.detekt 37 | id("com.vanniktech.android.junit.jacoco") version Versions.junitJacoco 38 | } 39 | 40 | allprojects { 41 | repositories { 42 | google() 43 | jcenter() 44 | } 45 | } 46 | 47 | tasks.withType().configureEach { 48 | kotlinOptions.jvmTarget = "1.8" 49 | } 50 | 51 | detekt { 52 | input = files("src/main/kotlin") 53 | filters = ".*/resources/.*,.*/build/.*" 54 | } 55 | 56 | junitJacoco { 57 | jacocoVersion = "0.8.2" 58 | excludes = Excludes.jacocoAndroid 59 | } -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | `kotlin-dsl` 19 | } 20 | 21 | repositories { 22 | jcenter() 23 | } -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Dependencies.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | object Project { 18 | 19 | const val artifactId = "deeplink" 20 | const val version = "0.4.0-SNAPSHOT" 21 | const val groupId = "com.hellofresh.android" 22 | const val name = "android-$artifactId" 23 | } 24 | 25 | object Android { 26 | const val sdk = 28 27 | const val minSdk = 17 28 | } 29 | 30 | object Versions { 31 | 32 | const val androidGradlePlugin = "3.4.0-rc01" 33 | const val bintrayGradlePlugin = "1.8.4" 34 | const val detekt = "1.0.0-RC12" 35 | const val dokkaAndroid = "0.9.17" 36 | const val jfrogArtifactory = "4.9.3" 37 | const val junitJacoco = "0.13.0" 38 | const val junit = "4.12" 39 | const val kotlin = "1.3.21" 40 | const val okio = "2.1.0" 41 | const val supportTest = "1.0.2" 42 | } 43 | 44 | object Dependencies { 45 | 46 | const val androidGradlePlugin = "com.android.tools.build:gradle:${Versions.androidGradlePlugin}" 47 | const val kotlinGradle = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" 48 | const val kotlinStdLib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin}" 49 | const val okio = "com.squareup.okio:okio:${Versions.okio}" 50 | } 51 | 52 | object DependenciesTest { 53 | 54 | const val junit = "junit:junit:${Versions.junit}" 55 | const val kotlinTest = "org.jetbrains.kotlin:kotlin-test:${Versions.kotlin}" 56 | const val kotlinTestJunit = "org.jetbrains.kotlin:kotlin-test-junit:${Versions.kotlin}" 57 | const val supportTestRunner = "com.android.support.test:runner:${Versions.supportTest}" 58 | } 59 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Excludes.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | object Excludes { 18 | 19 | val jacocoAndroid = listOf( 20 | // Android 21 | "**/R.class", 22 | "**/R$*.class", 23 | "**/BuildConfig.*", 24 | "**/Manifest*.*", 25 | // Others 26 | "**/*\$Companion.class", 27 | "**/*Lambda$*.*", // Jacoco can not handle several "$" in class name. 28 | "**/*$*$*.*" // Anonymous classes generated by kotlin) 29 | ) 30 | } -------------------------------------------------------------------------------- /deeplink/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import groovy.lang.GroovyObject 2 | import org.gradle.api.publish.maven.MavenPom 3 | import org.jetbrains.dokka.gradle.DokkaTask 4 | import org.jfrog.gradle.plugin.artifactory.dsl.PublisherConfig 5 | import org.jfrog.gradle.plugin.artifactory.dsl.ResolverConfig 6 | 7 | /* 8 | * Copyright (c) 2019. The HelloFresh Android Team 9 | * 10 | * Licensed under the Apache License, Version 2.0 (the "License"); 11 | * you may not use this file except in compliance with the License. 12 | * You may obtain a copy of the License at 13 | * 14 | * http://www.apache.org/licenses/LICENSE-2.0 15 | * 16 | * Unless required by applicable law or agreed to in writing, software 17 | * distributed under the License is distributed on an "AS IS" BASIS, 18 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | * See the License for the specific language governing permissions and 20 | * limitations under the License. 21 | */ 22 | 23 | plugins { 24 | id("com.android.library") 25 | id("kotlin-android") 26 | id("maven-publish") 27 | id("com.jfrog.bintray") version Versions.bintrayGradlePlugin 28 | id("org.jetbrains.dokka-android") version Versions.dokkaAndroid 29 | id("com.jfrog.artifactory") version Versions.jfrogArtifactory 30 | } 31 | 32 | android { 33 | compileSdkVersion(Android.sdk) 34 | defaultConfig { 35 | minSdkVersion(Android.minSdk) 36 | targetSdkVersion(Android.sdk) 37 | versionName = Project.version 38 | setProperty("archivesBaseName", "${Project.name}-${Project.version}") 39 | testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" 40 | } 41 | } 42 | 43 | group = Project.groupId 44 | version = Project.version 45 | 46 | dependencies { 47 | implementation(Dependencies.kotlinStdLib) 48 | implementation(Dependencies.okio) 49 | testImplementation(DependenciesTest.junit) 50 | testImplementation(DependenciesTest.kotlinTest) 51 | testImplementation(DependenciesTest.kotlinTestJunit) 52 | androidTestImplementation(DependenciesTest.junit) 53 | androidTestImplementation(DependenciesTest.kotlinTest) 54 | androidTestImplementation(DependenciesTest.kotlinTestJunit) 55 | androidTestImplementation(DependenciesTest.supportTestRunner) 56 | } 57 | 58 | val artifact = "$buildDir/outputs/aar/${Project.name}-${Project.version}-release.aar" 59 | 60 | val sourcesJar by tasks.creating(Jar::class) { 61 | classifier = "sources" 62 | from(android.sourceSets.getByName("main").java.srcDirs) 63 | } 64 | 65 | val dokka by tasks.getting(DokkaTask::class) { 66 | outputFormat = "html" 67 | outputDirectory = "$buildDir/javadoc" 68 | } 69 | 70 | val dokkaJar by tasks.creating(Jar::class) { 71 | group = JavaBasePlugin.DOCUMENTATION_GROUP 72 | description = "Assembles Kotlin docs with Dokka" 73 | classifier = "javadoc" 74 | from(dokka) 75 | } 76 | 77 | artifacts { 78 | archives(sourcesJar) 79 | archives(dokkaJar) 80 | } 81 | 82 | 83 | publishing { 84 | 85 | publications { 86 | register(Project.artifactId, MavenPublication::class) { 87 | groupId = Project.groupId 88 | artifactId = Project.artifactId 89 | version = Project.version 90 | artifact(artifact) 91 | artifact(sourcesJar) 92 | artifact(dokkaJar) 93 | pom.addDependencies() 94 | } 95 | } 96 | } 97 | 98 | bintray { 99 | user = System.getenv("BINTRAY_USER") ?: "" 100 | key = System.getenv("BINTRAY_API_KEY") ?: "" 101 | publish = true 102 | setPublications(Project.artifactId) 103 | with(pkg) { 104 | repo = "maven" 105 | name = Project.name 106 | websiteUrl = "https://github.com/hellofresh/android-deeplink/" 107 | githubRepo = "hellofresh/android-deeplink" 108 | vcsUrl = "https://github.com/hellofresh/android-deeplink/" 109 | description = "Deeplink parser library" 110 | desc = description 111 | publish = true 112 | githubRepo = "hellofresh/android-deeplink" 113 | githubReleaseNotesFile = "../CHANGELOG.md" 114 | with(version) { 115 | name = Project.version 116 | } 117 | setLabels("kotlin", "Android", "Deep link") 118 | setLicenses("Apache-2.0") 119 | } 120 | } 121 | 122 | fun MavenPom.addDependencies() = withXml { 123 | asNode().appendNode("dependencies").let { depNode -> 124 | configurations.compile.allDependencies.forEach { 125 | depNode.appendNode("dependency").apply { 126 | appendNode("groupId", it.group) 127 | appendNode("artifactId", it.name) 128 | appendNode("version", it.version) 129 | } 130 | } 131 | } 132 | } 133 | 134 | artifactory { 135 | setContextUrl("https://oss.jfrog.org") 136 | publish(delegateClosureOf { 137 | repository(delegateClosureOf { 138 | val repoKey = if (Project.version.endsWith("SNAPSHOT")) "oss-snapshot-local" else "oss-release-local" 139 | setProperty("repoKey", repoKey) 140 | setProperty("username", System.getenv("BINTRAY_USER") ?: "") 141 | setProperty("password", System.getenv("BINTRAY_API_KEY") ?: "") 142 | setProperty("maven", true) 143 | }) 144 | defaults(delegateClosureOf { 145 | invokeMethod("publications", Project.artifactId) 146 | }) 147 | 148 | resolve(delegateClosureOf { 149 | setProperty("repoKey", "jcenter") 150 | }) 151 | }) 152 | } 153 | -------------------------------------------------------------------------------- /deeplink/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /deeplink/src/androidTest/java/com/hellofresh/deeplink/extension/DeepLinkUriKtTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink.extension 18 | 19 | import android.net.Uri 20 | import com.hellofresh.deeplink.DeepLinkUri 21 | import org.junit.Test 22 | import kotlin.test.assertEquals 23 | 24 | class DeepLinkUriKtTest { 25 | 26 | @Test 27 | fun get() { 28 | val androidUri = Uri.parse("http://www.hellofresh.com/test") 29 | val deepUri = DeepLinkUri.get(androidUri) 30 | 31 | assertEquals(androidUri.toString(), deepUri.toString()) 32 | } 33 | 34 | @Test 35 | fun toAndroidUri() { 36 | val deepUri = DeepLinkUri.parse("custom://host.com/path/to/resource") 37 | val androidUri = deepUri.toAndroidUri() 38 | 39 | assertEquals(deepUri.toString(), androidUri.toString()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /deeplink/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 19 | 20 | -------------------------------------------------------------------------------- /deeplink/src/main/java/com/hellofresh/deeplink/Action.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | interface Action { 20 | 21 | fun run(uri: DeepLinkUri, params: Map, env: Environment): T 22 | } 23 | -------------------------------------------------------------------------------- /deeplink/src/main/java/com/hellofresh/deeplink/BaseRoute.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | import java.util.regex.Pattern 20 | 21 | abstract class BaseRoute(private vararg val routes: String) : Action { 22 | 23 | internal fun matchWith(uri: DeepLinkUri): MatchResult { 24 | val (_, inputParts) = retrieveHostAndPathSegments(uri) 25 | routes.forEach { route -> 26 | val params = hashMapOf() 27 | 28 | val parts = route.split("/").dropLastWhile { it.isEmpty() } 29 | if (inputParts.size != parts.size) { 30 | return@forEach 31 | } 32 | inputParts.zip(parts) { inPart, routePart -> 33 | when { 34 | routePart.startsWith(":") -> { 35 | val (key, value) = resolveParameterizedPath(routePart, inPart) ?: return@forEach 36 | val parameterKey = key.takeUnless { it.isEmpty() } ?: return@zip 37 | params[parameterKey] = value 38 | } 39 | routePart == "*" -> return@zip 40 | routePart != inPart -> return@forEach 41 | } 42 | } 43 | return MatchResult(true, params) 44 | } 45 | return MatchResult(false) 46 | } 47 | 48 | private fun retrieveHostAndPathSegments(uri: DeepLinkUri): Pair> { 49 | val trimmedPathSegments = uri.pathSegments().dropLastWhile { it.isEmpty() } 50 | if (treatHostAsPath(uri)) { 51 | val pathSegments = ArrayList(uri.pathSize() + 1) 52 | pathSegments.add(uri.host()) 53 | pathSegments.addAll(trimmedPathSegments) 54 | return "" to pathSegments 55 | } 56 | return uri.host() to trimmedPathSegments 57 | } 58 | 59 | private fun resolveParameterizedPath(routePart: String, inPart: String): Pair? { 60 | val partialMatcher = PARAMETERIZED_PATH_PATTERN.matcher(routePart) 61 | if (!partialMatcher.matches()) return null 62 | 63 | val key = partialMatcher.group(1) 64 | val userPattern = partialMatcher.group(3) ?: return Pair(key, inPart) 65 | val inputMatcher = Pattern.compile(userPattern).matcher(inPart) 66 | if (!inputMatcher.matches()) return null 67 | 68 | return Pair(key, inPart) 69 | } 70 | 71 | /** 72 | * Returns whether or not to treat the [uri] host as part of the path segments. 73 | * 74 | * This is useful for URIs with custom schemes that do not have an explicit 75 | * host, but rather uses the scheme as the deeplink identifier. In such cases, 76 | * one might prefer to treat the host itself as a path segment. 77 | */ 78 | protected open fun treatHostAsPath(uri: DeepLinkUri): Boolean { 79 | return false 80 | } 81 | 82 | override fun equals(other: Any?): Boolean { 83 | if (this === other) return true 84 | if (other !is BaseRoute<*>) return false 85 | 86 | return routes.contentEquals(other.routes) 87 | } 88 | 89 | override fun hashCode(): Int { 90 | return routes.contentHashCode() 91 | } 92 | 93 | companion object { 94 | 95 | /** 96 | * Regex pattern that will be used to validate and retrieve path values from a specified input. 97 | * 98 | * This pattern is made up of 3 groups. 99 | * Group 1: An optional parameter name that will be used to reference the actual path value 100 | * Group 2: The entirety of the (optional) regex pattern for the path 101 | * Group 3: The actual regex pattern that will be supplied by the user 102 | */ 103 | private val PARAMETERIZED_PATH_PATTERN = Pattern.compile("^:(\\w*)(\\((.*)\\))?$") 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /deeplink/src/main/java/com/hellofresh/deeplink/DeepLinkParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | class DeepLinkParser( 20 | private val environment: Environment, 21 | private val routes: List>, 22 | private val fallback: Action 23 | ) { 24 | 25 | fun parse(uri: DeepLinkUri): T { 26 | val (route, matchResult) = routes.asSequence() 27 | .map { it to it.matchWith(uri) } 28 | .firstOrNull { it.second.isMatch } ?: return fallback.run(uri, emptyMap(), environment) 29 | 30 | return route.run(uri, matchResult.params, environment) 31 | } 32 | 33 | 34 | class Builder(private val environment: Environment) { 35 | 36 | private val routes: MutableSet> = LinkedHashSet() 37 | private var defaultFallback: Action? = null 38 | 39 | fun addRoute(route: BaseRoute) = apply { 40 | routes.add(route) 41 | } 42 | 43 | fun addFallbackAction(action: Action) = apply { 44 | defaultFallback = action 45 | } 46 | 47 | fun build(): DeepLinkParser { 48 | val fallback = defaultFallback ?: error("Default fallback is not provided!") 49 | 50 | return DeepLinkParser(environment, routes.toList(), fallback) 51 | } 52 | } 53 | 54 | companion object { 55 | 56 | @JvmStatic 57 | fun of(environment: Environment): Builder { 58 | return Builder(environment) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /deeplink/src/main/java/com/hellofresh/deeplink/DeepLinkUri.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("unused", "MemberVisibilityCanBePrivate") 18 | 19 | package com.hellofresh.deeplink 20 | 21 | import com.hellofresh.deeplink.DeepLinkUri.Builder.ParseResult 22 | import okio.Buffer 23 | import java.net.IDN 24 | import java.net.InetAddress 25 | import java.net.MalformedURLException 26 | import java.net.URI 27 | import java.net.URISyntaxException 28 | import java.net.URL 29 | import java.net.UnknownHostException 30 | import java.util.ArrayList 31 | import java.util.Arrays 32 | import java.util.Collections 33 | import java.util.LinkedHashSet 34 | import java.util.Locale 35 | 36 | /** 37 | * Adapted from OkHttp's HttpUrl class. 38 | * 39 | * Changes: 40 | * - Allow any scheme, instead of just http or https 41 | * - Migrated the class to Kotlin 42 | * - Made a few API modifications for nullability support 43 | * 44 | * Original implementation: 45 | * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/com/squareup/okhttp/HttpUri.java 46 | */ 47 | class DeepLinkUri private constructor(builder: Builder) { 48 | 49 | /** Either "http" or "https" or some custom value as the case may be. */ 50 | private val scheme: String 51 | 52 | /** Decoded username. */ 53 | private val username: String 54 | 55 | /** Decoded password. */ 56 | private val password: String 57 | 58 | /** Canonical hostname. */ 59 | private val host: String 60 | 61 | /** Either 80, 443 or a user-specified port. In range [1..65535]. */ 62 | private val port: Int 63 | 64 | /** 65 | * A list of canonical path segments. This list always contains at least one element, which may 66 | * be the empty string. Each segment is formatted with a leading '/', so if path segments were 67 | * ["a", "b", ""], then the encoded path would be "/a/b/". 68 | */ 69 | private val pathSegments: List 70 | 71 | /** 72 | * Alternating, decoded query names and values, or null for no query. Names may be empty or 73 | * non-empty, but never null. Values are null if the name has no corresponding '=' separator, or 74 | * empty, or non-empty. 75 | */ 76 | private val queryNamesAndValues: List? 77 | 78 | /** Decoded fragment. */ 79 | private val fragment: String? 80 | 81 | /** Canonical URL. */ 82 | private val url: String 83 | 84 | val isHttps: Boolean 85 | get() = scheme == "https" 86 | 87 | init { 88 | this.scheme = checkNotNull(builder.scheme) 89 | this.username = percentDecode(builder.encodedUsername, plusIsSpace = false) 90 | this.password = percentDecode(builder.encodedPassword, plusIsSpace = false) 91 | this.host = checkNotNull(builder.host) 92 | this.port = builder.effectivePort() 93 | this.pathSegments = percentDecode(builder.encodedPathSegments, plusIsSpace = false).filterNotNull() 94 | this.queryNamesAndValues = builder.encodedQueryNamesAndValues?.let { percentDecode(it, plusIsSpace = true) } 95 | this.fragment = builder.encodedFragment?.let { percentDecode(it, plusIsSpace = false) } 96 | this.url = builder.toString() 97 | } 98 | 99 | /** Returns this URL as a [java.net.URL][URL]. */ 100 | fun url(): URL { 101 | try { 102 | return URL(url) 103 | } catch (e: MalformedURLException) { 104 | throw RuntimeException(e) // Unexpected! 105 | } 106 | 107 | } 108 | 109 | /** 110 | * Attempt to convert this URL to a [java.net.URI][URI]. This method throws an unchecked 111 | * [IllegalStateException] if the URL it holds isn't valid by URI's overly-stringent 112 | * standard. For example, URI rejects paths containing the '[' character. Consult that class for 113 | * the exact rules of what URLs are permitted. 114 | */ 115 | fun uri(): URI { 116 | val uri = newBuilder().reencodeForUri().toString() 117 | return try { 118 | URI(uri) 119 | } catch (e: URISyntaxException) { 120 | // Unlikely edge case: the URI has a forbidden character in the fragment. Strip it & retry. 121 | try { 122 | val stripped = uri.replace("[\u0000-\u001F\u007F-\u009F\\p{javaWhitespace}]".toRegex(), "") 123 | URI.create(stripped) 124 | } catch (e1: Exception) { 125 | throw RuntimeException(e) // Unexpected! 126 | } 127 | } 128 | 129 | } 130 | 131 | /** Returns either "http" or "https" or some custom scheme as the case may be. */ 132 | fun scheme(): String { 133 | return scheme 134 | } 135 | 136 | /** Returns the username, or an empty string if none is set. */ 137 | fun encodedUsername(): String { 138 | if (username.isEmpty()) return "" 139 | val usernameStart = scheme.length + 3 // "://".length() == 3. 140 | val usernameEnd = delimiterOffset(url, usernameStart, url.length, ":@") 141 | return url.substring(usernameStart, usernameEnd) 142 | } 143 | 144 | fun username(): String { 145 | return username 146 | } 147 | 148 | /** Returns the password, or an empty string if none is set. */ 149 | fun encodedPassword(): String { 150 | if (password.isEmpty()) return "" 151 | val passwordStart = url.indexOf(':', scheme.length + 3) + 1 152 | val passwordEnd = url.indexOf('@') 153 | return url.substring(passwordStart, passwordEnd) 154 | } 155 | 156 | /** Returns the decoded password, or an empty string if none is present. */ 157 | fun password(): String { 158 | return password 159 | } 160 | 161 | /** 162 | * Returns the host address suitable for use with [InetAddress.getAllByName]. May 163 | * be: 164 | * 165 | * * A regular host name, like `android.com`. 166 | * * An IPv4 address, like `127.0.0.1`. 167 | * * An IPv6 address, like `::1`. Note that there are no square braces. 168 | * * An encoded IDN, like `xn--n3h.net`. 169 | * 170 | */ 171 | fun host(): String { 172 | return host 173 | } 174 | 175 | /** 176 | * Returns the explicitly-specified port if one was provided, or the default port for this URL's 177 | * scheme. For example, this returns 8443 for `https://square.com:8443/` and 443 for `https://square.com/`. The result is in `[1..65535]`. 178 | */ 179 | fun port(): Int { 180 | return port 181 | } 182 | 183 | fun pathSize(): Int { 184 | return pathSegments.size 185 | } 186 | 187 | /** 188 | * Returns the entire path of this URL, encoded for use in HTTP resource resolution. The 189 | * returned path is always nonempty and is prefixed with `/`. 190 | */ 191 | fun encodedPath(): String { 192 | val pathStart = url.indexOf('/', scheme.length + 3) // "://".length() == 3. 193 | val pathEnd = delimiterOffset(url, pathStart, url.length, "?#") 194 | return url.substring(pathStart, pathEnd) 195 | } 196 | 197 | fun encodedPathSegments(): List { 198 | val pathStart = url.indexOf('/', scheme.length + 3) 199 | val pathEnd = delimiterOffset(url, pathStart, url.length, "?#") 200 | val result = ArrayList() 201 | var i = pathStart 202 | while (i < pathEnd) { 203 | i++ // Skip the '/'. 204 | val segmentEnd = delimiterOffset(url, i, pathEnd, "/") 205 | result.add(url.substring(i, segmentEnd)) 206 | i = segmentEnd 207 | } 208 | return result 209 | } 210 | 211 | fun pathSegments(): List { 212 | return pathSegments 213 | } 214 | 215 | /** 216 | * Returns the query of this URL, encoded for use in HTTP resource resolution. The returned string 217 | * may be null (for URLs with no query), empty (for URLs with an empty query) or non-empty (all 218 | * other URLs). 219 | */ 220 | fun encodedQuery(): String? { 221 | if (queryNamesAndValues == null) return null // No query. 222 | val queryStart = url.indexOf('?') + 1 223 | val queryEnd = delimiterOffset(url, queryStart, url.length, "#") 224 | return url.substring(queryStart, queryEnd) 225 | } 226 | 227 | fun query(): String? { 228 | if (queryNamesAndValues == null) return null // No query. 229 | val result = StringBuilder() 230 | namesAndValuesToQueryString(result, queryNamesAndValues) 231 | return result.toString() 232 | } 233 | 234 | fun querySize(): Int { 235 | val queryNameAndValuesSize = queryNamesAndValues?.size ?: return 0 236 | return queryNameAndValuesSize / 2 237 | } 238 | 239 | /** 240 | * Returns the first query parameter named `name` decoded using UTF-8, or null if there is 241 | * no such query parameter. 242 | */ 243 | fun queryParameter(name: String): String? { 244 | queryNamesAndValues ?: return null 245 | var i = 0 246 | val size = queryNamesAndValues.size 247 | while (i < size) { 248 | if (name == queryNamesAndValues[i]) { 249 | return queryNamesAndValues[i + 1] 250 | } 251 | i += 2 252 | } 253 | return null 254 | } 255 | 256 | fun queryParameterNames(): Set { 257 | queryNamesAndValues ?: return emptySet() 258 | val result = LinkedHashSet() 259 | var i = 0 260 | val size = queryNamesAndValues.size 261 | while (i < size) { 262 | result.add(queryNamesAndValues[i]!!) 263 | i += 2 264 | } 265 | return Collections.unmodifiableSet(result) 266 | } 267 | 268 | fun queryParameterValues(name: String): List { 269 | queryNamesAndValues ?: return emptyList() 270 | val result = ArrayList() 271 | var i = 0 272 | val size = queryNamesAndValues.size 273 | while (i < size) { 274 | if (name == queryNamesAndValues[i]) { 275 | result.add(queryNamesAndValues[i + 1]) 276 | } 277 | i += 2 278 | } 279 | return Collections.unmodifiableList(result) 280 | } 281 | 282 | fun queryParameterName(index: Int): String? { 283 | return queryNamesAndValues?.get(index * 2) 284 | } 285 | 286 | fun queryParameterValue(index: Int): String? { 287 | return queryNamesAndValues?.get(index * 2 + 1) 288 | } 289 | 290 | fun encodedFragment(): String? { 291 | fragment ?: return null 292 | val fragmentStart = url.indexOf('#') + 1 293 | return url.substring(fragmentStart) 294 | } 295 | 296 | fun fragment(): String? { 297 | return fragment 298 | } 299 | 300 | /** Returns the URL that would be retrieved by following `link` from this URL. */ 301 | fun resolve(link: String): DeepLinkUri? { 302 | return newBuilder(link)?.build() 303 | } 304 | 305 | fun newBuilder(): Builder { 306 | val result = Builder() 307 | result.scheme = scheme 308 | result.encodedUsername = encodedUsername() 309 | result.encodedPassword = encodedPassword() 310 | result.host = host 311 | result.port = if (port != defaultPort(scheme)) port else -1 312 | result.encodedPathSegments.clear() 313 | result.encodedPathSegments.addAll(encodedPathSegments()) 314 | result.encodedQuery(encodedQuery()) 315 | result.encodedFragment = encodedFragment() 316 | return result 317 | } 318 | 319 | /** 320 | * Returns a builder for the URL that would be retrieved by following `link` from this URL, 321 | * or null if the resulting URL is not well-formed. 322 | */ 323 | fun newBuilder(link: String): Builder? { 324 | val builder = Builder() 325 | val result = builder.parse(this, link) 326 | return if (result === Builder.ParseResult.SUCCESS) builder else null 327 | } 328 | 329 | override fun equals(other: Any?): Boolean { 330 | return other is DeepLinkUri && other.url == url 331 | } 332 | 333 | override fun hashCode(): Int { 334 | return url.hashCode() 335 | } 336 | 337 | override fun toString(): String { 338 | return url 339 | } 340 | 341 | class Builder { 342 | var scheme: String? = null 343 | var encodedUsername = "" 344 | var encodedPassword = "" 345 | var host: String? = null 346 | var port = -1 347 | val encodedPathSegments: MutableList = ArrayList() 348 | var encodedQueryNamesAndValues: MutableList? = null 349 | var encodedFragment: String? = null 350 | 351 | init { 352 | encodedPathSegments.add("") // The default path is '/' which needs a trailing space. 353 | } 354 | 355 | fun scheme(scheme: String): Builder = apply { 356 | this.scheme = scheme.toLowerCase(Locale.US) 357 | } 358 | 359 | fun username(username: String): Builder = apply { 360 | this.encodedUsername = canonicalize( 361 | username, 362 | USERNAME_ENCODE_SET, 363 | alreadyEncoded = false, 364 | strict = false, 365 | plusIsSpace = false, 366 | asciiOnly = true 367 | ) 368 | } 369 | 370 | fun encodedUsername(encodedUsername: String): Builder = apply { 371 | this.encodedUsername = canonicalize( 372 | encodedUsername, USERNAME_ENCODE_SET, 373 | alreadyEncoded = true, 374 | strict = false, 375 | plusIsSpace = false, 376 | asciiOnly = true 377 | ) 378 | } 379 | 380 | fun password(password: String): Builder = apply { 381 | this.encodedPassword = canonicalize( 382 | password, 383 | PASSWORD_ENCODE_SET, 384 | alreadyEncoded = false, 385 | strict = false, 386 | plusIsSpace = false, 387 | asciiOnly = true 388 | ) 389 | } 390 | 391 | fun encodedPassword(encodedPassword: String): Builder = apply { 392 | this.encodedPassword = canonicalize( 393 | encodedPassword, PASSWORD_ENCODE_SET, 394 | alreadyEncoded = true, 395 | strict = false, 396 | plusIsSpace = false, 397 | asciiOnly = true 398 | ) 399 | } 400 | 401 | /** 402 | * @param host either a regular hostname, International Domain Name, IPv4 address, or IPv6 403 | * address. 404 | */ 405 | fun host(host: String): Builder = apply { 406 | val encoded = 407 | canonicalizeHost(host, 0, host.length) ?: throw IllegalArgumentException("unexpected host: $host") 408 | this.host = encoded 409 | } 410 | 411 | fun port(port: Int): Builder = apply { 412 | if (port !in 1..65535) throw IllegalArgumentException("unexpected port: $port") 413 | this.port = port 414 | } 415 | 416 | fun effectivePort(): Int { 417 | return if (port != -1) port else defaultPort(scheme!!) 418 | } 419 | 420 | fun addPathSegment(pathSegment: String): Builder = apply { 421 | push(pathSegment, 0, pathSegment.length, addTrailingSlash = false, alreadyEncoded = false) 422 | } 423 | 424 | fun addEncodedPathSegment(encodedPathSegment: String): Builder = apply { 425 | push(encodedPathSegment, 0, encodedPathSegment.length, addTrailingSlash = false, alreadyEncoded = true) 426 | } 427 | 428 | 429 | /** 430 | * Adds a set of path segments separated by a slash (either `\` or `/`). If 431 | * `pathSegments` starts with a slash, the resulting URL will have empty path segment. 432 | */ 433 | fun addPathSegments(pathSegments: String): Builder { 434 | return addPathSegments(pathSegments, false) 435 | } 436 | 437 | /** 438 | * Adds a set of encoded path segments separated by a slash (either `\` or `/`). If 439 | * `encodedPathSegments` starts with a slash, the resulting URL will have empty path 440 | * segment. 441 | */ 442 | fun addEncodedPathSegments(encodedPathSegments: String): Builder { 443 | return addPathSegments(encodedPathSegments, true) 444 | } 445 | 446 | private fun addPathSegments(pathSegments: String, alreadyEncoded: Boolean): Builder = apply { 447 | var offset = 0 448 | do { 449 | val segmentEnd = delimiterOffset(pathSegments, offset, pathSegments.length, "/\\") 450 | val addTrailingSlash = segmentEnd < pathSegments.length 451 | push(pathSegments, offset, segmentEnd, addTrailingSlash, alreadyEncoded) 452 | offset = segmentEnd + 1 453 | } while (offset <= pathSegments.length) 454 | } 455 | 456 | fun setPathSegment(index: Int, pathSegment: String): Builder = apply { 457 | val canonicalPathSegment = canonicalize( 458 | pathSegment, 459 | 0, 460 | pathSegment.length, 461 | PATH_SEGMENT_ENCODE_SET, 462 | alreadyEncoded = false, 463 | strict = false, 464 | plusIsSpace = false, 465 | asciiOnly = true 466 | ) 467 | if (isDot(canonicalPathSegment) || isDotDot(canonicalPathSegment)) { 468 | throw IllegalArgumentException("unexpected path segment: $pathSegment") 469 | } 470 | encodedPathSegments[index] = canonicalPathSegment 471 | } 472 | 473 | fun setEncodedPathSegment(index: Int, encodedPathSegment: String): Builder = apply { 474 | val canonicalPathSegment = canonicalize( 475 | encodedPathSegment, 476 | 0, 477 | encodedPathSegment.length, 478 | PATH_SEGMENT_ENCODE_SET, 479 | alreadyEncoded = true, 480 | strict = false, 481 | plusIsSpace = false, 482 | asciiOnly = true 483 | ) 484 | encodedPathSegments[index] = canonicalPathSegment 485 | if (isDot(canonicalPathSegment) || isDotDot(canonicalPathSegment)) { 486 | throw IllegalArgumentException("unexpected path segment: $encodedPathSegment") 487 | } 488 | } 489 | 490 | fun removePathSegment(index: Int): Builder = apply { 491 | encodedPathSegments.removeAt(index) 492 | if (encodedPathSegments.isEmpty()) { 493 | encodedPathSegments.add("") // Always leave at least one '/'. 494 | } 495 | } 496 | 497 | fun encodedPath(encodedPath: String): Builder = apply { 498 | if (!encodedPath.startsWith("/")) { 499 | throw IllegalArgumentException("unexpected encodedPath: $encodedPath") 500 | } 501 | resolvePath(encodedPath, 0, encodedPath.length) 502 | } 503 | 504 | fun query(query: String?): Builder = apply { 505 | this.encodedQueryNamesAndValues = if (query != null) 506 | queryStringToNamesAndValues( 507 | canonicalize( 508 | query, 509 | QUERY_ENCODE_SET, 510 | alreadyEncoded = false, 511 | strict = false, 512 | plusIsSpace = true, 513 | asciiOnly = true 514 | ) 515 | ) 516 | else 517 | null 518 | } 519 | 520 | fun encodedQuery(encodedQuery: String?): Builder = apply { 521 | this.encodedQueryNamesAndValues = if (encodedQuery != null) 522 | queryStringToNamesAndValues( 523 | canonicalize( 524 | encodedQuery, QUERY_ENCODE_SET, 525 | alreadyEncoded = true, 526 | strict = false, 527 | plusIsSpace = true, 528 | asciiOnly = true 529 | ) 530 | ) 531 | else 532 | null 533 | } 534 | 535 | /** Encodes the query parameter using UTF-8 and adds it to this URL's query string. */ 536 | fun addQueryParameter(name: String, value: String?): Builder = apply { 537 | val queries = encodedQueryNamesAndValues ?: arrayListOf().also { 538 | encodedQueryNamesAndValues = it 539 | } 540 | queries.add( 541 | canonicalize( 542 | name, 543 | QUERY_COMPONENT_ENCODE_SET, 544 | alreadyEncoded = false, 545 | strict = false, 546 | plusIsSpace = true, 547 | asciiOnly = true 548 | ) 549 | ) 550 | queries.add( 551 | if (value != null) 552 | canonicalize( 553 | value, 554 | QUERY_COMPONENT_ENCODE_SET, 555 | alreadyEncoded = false, 556 | strict = false, 557 | plusIsSpace = true, 558 | asciiOnly = true 559 | ) 560 | else 561 | null 562 | ) 563 | } 564 | 565 | /** Adds the pre-encoded query parameter to this URL's query string. */ 566 | fun addEncodedQueryParameter(encodedName: String, encodedValue: String?): Builder = apply { 567 | val queries = encodedQueryNamesAndValues ?: arrayListOf().also { 568 | encodedQueryNamesAndValues = it 569 | } 570 | queries.add( 571 | canonicalize( 572 | encodedName, 573 | QUERY_COMPONENT_REENCODE_SET, 574 | alreadyEncoded = true, 575 | strict = false, 576 | plusIsSpace = true, 577 | asciiOnly = true 578 | ) 579 | ) 580 | queries.add( 581 | if (encodedValue != null) 582 | canonicalize( 583 | encodedValue, 584 | QUERY_COMPONENT_REENCODE_SET, 585 | alreadyEncoded = true, 586 | strict = false, 587 | plusIsSpace = true, 588 | asciiOnly = true 589 | ) 590 | else 591 | null 592 | ) 593 | } 594 | 595 | fun setQueryParameter(name: String, value: String): Builder = apply { 596 | removeAllQueryParameters(name) 597 | addQueryParameter(name, value) 598 | } 599 | 600 | fun setEncodedQueryParameter(encodedName: String, encodedValue: String): Builder = apply { 601 | removeAllEncodedQueryParameters(encodedName) 602 | addEncodedQueryParameter(encodedName, encodedValue) 603 | } 604 | 605 | fun removeAllQueryParameters(name: String): Builder = apply { 606 | if (encodedQueryNamesAndValues == null) return@apply 607 | val nameToRemove = canonicalize( 608 | name, 609 | QUERY_COMPONENT_ENCODE_SET, 610 | alreadyEncoded = false, 611 | strict = false, 612 | plusIsSpace = true, 613 | asciiOnly = true 614 | ) 615 | removeAllCanonicalQueryParameters(nameToRemove) 616 | } 617 | 618 | fun removeAllEncodedQueryParameters(encodedName: String): Builder = apply { 619 | if (encodedQueryNamesAndValues == null) return@apply 620 | removeAllCanonicalQueryParameters( 621 | canonicalize( 622 | encodedName, 623 | QUERY_COMPONENT_REENCODE_SET, 624 | alreadyEncoded = true, 625 | strict = false, 626 | plusIsSpace = true, 627 | asciiOnly = true 628 | ) 629 | ) 630 | } 631 | 632 | private fun removeAllCanonicalQueryParameters(canonicalName: String) { 633 | val queries = encodedQueryNamesAndValues ?: return 634 | var i = queries.size - 2 635 | while (i >= 0) { 636 | if (canonicalName == queries[i]) { 637 | queries.removeAt(i + 1) 638 | queries.removeAt(i) 639 | if (queries.isEmpty()) { 640 | encodedQueryNamesAndValues = null 641 | return 642 | } 643 | } 644 | i -= 2 645 | } 646 | } 647 | 648 | fun fragment(fragment: String?): Builder = apply { 649 | this.encodedFragment = fragment?.let { 650 | canonicalize(it, FRAGMENT_ENCODE_SET, alreadyEncoded = false, strict = false, plusIsSpace = false, asciiOnly = false) 651 | } 652 | } 653 | 654 | fun encodedFragment(encodedFragment: String?): Builder = apply { 655 | this.encodedFragment = encodedFragment?.let { 656 | canonicalize(it, FRAGMENT_ENCODE_SET, alreadyEncoded = true, strict = false, plusIsSpace = false, asciiOnly = false) 657 | } 658 | } 659 | 660 | /** 661 | * Re-encodes the components of this URL so that it satisfies (obsolete) RFC 2396, which is 662 | * particularly strict for certain components. 663 | */ 664 | fun reencodeForUri(): Builder { 665 | run { 666 | var i = 0 667 | val size = encodedPathSegments.size 668 | while (i < size) { 669 | val pathSegment = encodedPathSegments[i] 670 | encodedPathSegments[i] = canonicalize( 671 | pathSegment, PATH_SEGMENT_ENCODE_SET_URI, 672 | alreadyEncoded = true, 673 | strict = true, 674 | plusIsSpace = false, 675 | asciiOnly = true 676 | ) 677 | i++ 678 | } 679 | } 680 | encodedQueryNamesAndValues?.let { 681 | var i = 0 682 | val size = it.size 683 | while (i < size) { 684 | val component = it[i] 685 | if (component != null) { 686 | it[i] = canonicalize( 687 | component, QUERY_COMPONENT_ENCODE_SET_URI, 688 | alreadyEncoded = true, 689 | strict = true, 690 | plusIsSpace = true, 691 | asciiOnly = true 692 | ) 693 | } 694 | i++ 695 | } 696 | } 697 | encodedFragment = encodedFragment?.let { 698 | canonicalize(it, FRAGMENT_ENCODE_SET_URI, alreadyEncoded = true, strict = true, plusIsSpace = false, asciiOnly = false) 699 | } 700 | return this 701 | } 702 | 703 | fun build(): DeepLinkUri { 704 | if (scheme == null) throw IllegalStateException("scheme == null") 705 | if (host == null) throw IllegalStateException("host == null") 706 | return DeepLinkUri(this) 707 | } 708 | 709 | override fun toString(): String { 710 | val scheme = scheme 711 | val host = host 712 | val result = StringBuilder() 713 | if (scheme != null) { 714 | result.append(scheme) 715 | result.append("://") 716 | } else { 717 | result.append("//") 718 | } 719 | 720 | if (!encodedUsername.isEmpty() || !encodedPassword.isEmpty()) { 721 | result.append(encodedUsername) 722 | if (!encodedPassword.isEmpty()) { 723 | result.append(':') 724 | result.append(encodedPassword) 725 | } 726 | result.append('@') 727 | } 728 | if (host != null) { 729 | if (host.indexOf(':') != -1) { 730 | // Host is an IPv6 address. 731 | result.append('[') 732 | result.append(host) 733 | result.append(']') 734 | } else { 735 | result.append(host) 736 | } 737 | } 738 | if (port != -1 || scheme != null) { 739 | val effectivePort = effectivePort() 740 | if (scheme == null || effectivePort != defaultPort(scheme)) { 741 | result.append(':') 742 | result.append(effectivePort) 743 | } 744 | } 745 | 746 | pathSegmentsToString(result, encodedPathSegments) 747 | 748 | encodedQueryNamesAndValues?.let { 749 | result.append('?') 750 | namesAndValuesToQueryString(result, it) 751 | } 752 | 753 | if (encodedFragment != null) { 754 | result.append('#') 755 | result.append(encodedFragment) 756 | } 757 | 758 | return result.toString() 759 | } 760 | 761 | internal enum class ParseResult { 762 | SUCCESS, 763 | MISSING_SCHEME, 764 | UNSUPPORTED_SCHEME, 765 | INVALID_PORT, 766 | INVALID_HOST 767 | } 768 | 769 | internal fun parse(base: DeepLinkUri?, input: String): ParseResult { 770 | var pos = skipLeadingAsciiWhitespace(input, 0, input.length) 771 | val limit = skipTrailingAsciiWhitespace(input, pos, input.length) 772 | 773 | // Scheme. 774 | val schemeDelimiterOffset = schemeDelimiterOffset(input, pos, limit) 775 | when { 776 | schemeDelimiterOffset != -1 -> when { 777 | input.regionMatches(pos, "https:", 0, 6, ignoreCase = true) -> { 778 | this.scheme = "https" 779 | pos += "https:".length 780 | } 781 | input.regionMatches(pos, "http:", 0, 5, ignoreCase = true) -> { 782 | this.scheme = "http" 783 | pos += "http:".length 784 | } 785 | else -> { 786 | this.scheme = input.substring(pos, schemeDelimiterOffset) 787 | pos += scheme!!.length + 1 788 | } 789 | } 790 | base != null -> this.scheme = base.scheme 791 | else -> return ParseResult.MISSING_SCHEME // No scheme. 792 | } 793 | 794 | // Authority. 795 | var hasUsername = false 796 | var hasPassword = false 797 | val slashCount = slashCount(input, pos, limit) 798 | if (slashCount >= 2 || base == null || base.scheme != this.scheme) { 799 | // Read an authority if either: 800 | // * The input starts with 2 or more slashes. These follow the scheme if it exists. 801 | // * The input scheme exists and is different from the base URL's scheme. 802 | // 803 | // The structure of an authority is: 804 | // username:password@host:port 805 | // 806 | // Username, password and port are optional. 807 | // [username[:password]@]host[:port] 808 | pos += slashCount 809 | authority@ while (true) { 810 | val componentDelimiterOffset = delimiterOffset(input, pos, limit, "@/\\?#") 811 | val c = if (componentDelimiterOffset != limit) 812 | input[componentDelimiterOffset] 813 | else 814 | (-1).toChar() 815 | when (c) { 816 | '@' -> { 817 | // User info precedes. 818 | if (!hasPassword) { 819 | val passwordColonOffset = delimiterOffset( 820 | input, pos, componentDelimiterOffset, ":" 821 | ) 822 | val canonicalUsername = canonicalize( 823 | input, 824 | pos, 825 | passwordColonOffset, 826 | USERNAME_ENCODE_SET, 827 | alreadyEncoded = true, 828 | strict = false, 829 | plusIsSpace = false, 830 | asciiOnly = true 831 | ) 832 | this.encodedUsername = if (hasUsername) 833 | this.encodedUsername + "%40" + canonicalUsername 834 | else 835 | canonicalUsername 836 | if (passwordColonOffset != componentDelimiterOffset) { 837 | hasPassword = true 838 | this.encodedPassword = canonicalize( 839 | input, 840 | passwordColonOffset + 1, 841 | componentDelimiterOffset, 842 | PASSWORD_ENCODE_SET, 843 | alreadyEncoded = true, 844 | strict = false, 845 | plusIsSpace = false, 846 | asciiOnly = true 847 | ) 848 | } 849 | hasUsername = true 850 | } else { 851 | this.encodedPassword = this.encodedPassword + "%40" + canonicalize( 852 | input, pos, componentDelimiterOffset, PASSWORD_ENCODE_SET, 853 | alreadyEncoded = true, 854 | strict = false, 855 | plusIsSpace = false, asciiOnly = true 856 | ) 857 | } 858 | pos = componentDelimiterOffset + 1 859 | } 860 | 861 | (-1).toChar(), '/', '\\', '?', '#' -> { 862 | // Host info precedes. 863 | val portColonOffset = portColonOffset(input, pos, componentDelimiterOffset) 864 | if (portColonOffset + 1 < componentDelimiterOffset) { 865 | this.host = canonicalizeHost(input, pos, portColonOffset) 866 | this.port = parsePort(input, portColonOffset + 1, componentDelimiterOffset) 867 | if (this.port == -1) return ParseResult.INVALID_PORT // Invalid port. 868 | } else { 869 | this.host = canonicalizeHost(input, pos, portColonOffset) 870 | this.port = defaultPort(this.scheme!!) 871 | } 872 | if (this.host == null) return ParseResult.INVALID_HOST // Invalid host. 873 | pos = componentDelimiterOffset 874 | break@authority 875 | } 876 | else -> { 877 | } 878 | } 879 | } 880 | } else { 881 | // This is a relative link. Copy over all authority components. Also maybe the path & query. 882 | this.encodedUsername = base.encodedUsername() 883 | this.encodedPassword = base.encodedPassword() 884 | this.host = base.host 885 | this.port = base.port 886 | this.encodedPathSegments.clear() 887 | this.encodedPathSegments.addAll(base.encodedPathSegments()) 888 | if (pos == limit || input[pos] == '#') { 889 | encodedQuery(base.encodedQuery()) 890 | } 891 | } 892 | 893 | // Resolve the relative path. 894 | val pathDelimiterOffset = delimiterOffset(input, pos, limit, "?#") 895 | resolvePath(input, pos, pathDelimiterOffset) 896 | pos = pathDelimiterOffset 897 | 898 | // Query. 899 | if (pos < limit && input[pos] == '?') { 900 | val queryDelimiterOffset = delimiterOffset(input, pos, limit, "#") 901 | this.encodedQueryNamesAndValues = queryStringToNamesAndValues( 902 | canonicalize( 903 | input, 904 | pos + 1, 905 | queryDelimiterOffset, 906 | QUERY_ENCODE_SET, 907 | alreadyEncoded = true, 908 | strict = false, 909 | plusIsSpace = true, 910 | asciiOnly = true 911 | ) 912 | ) 913 | pos = queryDelimiterOffset 914 | } 915 | 916 | // Fragment. 917 | if (pos < limit && input[pos] == '#') { 918 | this.encodedFragment = canonicalize( 919 | input, 920 | pos + 1, 921 | limit, 922 | FRAGMENT_ENCODE_SET, 923 | alreadyEncoded = true, 924 | strict = false, 925 | plusIsSpace = false, 926 | asciiOnly = false 927 | ) 928 | } 929 | 930 | return ParseResult.SUCCESS 931 | } 932 | 933 | private fun resolvePath(input: String, pos: Int, limit: Int) { 934 | var mutablePos = pos 935 | // Read a delimiter. 936 | if (mutablePos == limit) { 937 | // Empty path: keep the base path as-is. 938 | return 939 | } 940 | val c = input[mutablePos] 941 | if (c == '/' || c == '\\') { 942 | // Absolute path: reset to the default "/". 943 | encodedPathSegments.clear() 944 | encodedPathSegments.add("") 945 | mutablePos++ 946 | } else { 947 | // Relative path: clear everything after the last '/'. 948 | encodedPathSegments[encodedPathSegments.size - 1] = "" 949 | } 950 | 951 | // Read path segments. 952 | var i = mutablePos 953 | while (i < limit) { 954 | val pathSegmentDelimiterOffset = delimiterOffset(input, i, limit, "/\\") 955 | val segmentHasTrailingSlash = pathSegmentDelimiterOffset < limit 956 | push(input, i, pathSegmentDelimiterOffset, segmentHasTrailingSlash, true) 957 | i = pathSegmentDelimiterOffset 958 | if (segmentHasTrailingSlash) i++ 959 | } 960 | } 961 | 962 | /** Adds a path segment. If the input is ".." or equivalent, this pops a path segment. */ 963 | private fun push(input: String, pos: Int, limit: Int, addTrailingSlash: Boolean, alreadyEncoded: Boolean) { 964 | val segment = canonicalize( 965 | input, pos, limit, PATH_SEGMENT_ENCODE_SET, alreadyEncoded, strict = false, plusIsSpace = false, asciiOnly = true 966 | ) 967 | if (isDot(segment)) { 968 | return // Skip '.' path segments. 969 | } 970 | if (isDotDot(segment)) { 971 | pop() 972 | return 973 | } 974 | if (encodedPathSegments[encodedPathSegments.size - 1].isEmpty()) { 975 | encodedPathSegments[encodedPathSegments.size - 1] = segment 976 | } else { 977 | encodedPathSegments.add(segment) 978 | } 979 | if (addTrailingSlash) { 980 | encodedPathSegments.add("") 981 | } 982 | } 983 | 984 | private fun isDot(input: String): Boolean { 985 | return input == "." || input.equals("%2e", ignoreCase = true) 986 | } 987 | 988 | private fun isDotDot(input: String): Boolean { 989 | return (input == ".." 990 | || input.equals("%2e.", ignoreCase = true) 991 | || input.equals(".%2e", ignoreCase = true) 992 | || input.equals("%2e%2e", ignoreCase = true)) 993 | } 994 | 995 | /** 996 | * Removes a path segment. When this method returns the last segment is always "", which means 997 | * the encoded path will have a trailing '/'. 998 | * 999 | * 1000 | * Popping "/a/b/c/" yields "/a/b/". In this case the list of path segments goes from 1001 | * ["a", "b", "c", ""] to ["a", "b", ""]. 1002 | * 1003 | * 1004 | * Popping "/a/b/c" also yields "/a/b/". The list of path segments goes from ["a", "b", "c"] 1005 | * to ["a", "b", ""]. 1006 | */ 1007 | private fun pop() { 1008 | val removed = encodedPathSegments.removeAt(encodedPathSegments.size - 1) 1009 | 1010 | // Make sure the path ends with a '/' by either adding an empty string or clearing a segment. 1011 | if (removed.isEmpty() && !encodedPathSegments.isEmpty()) { 1012 | encodedPathSegments[encodedPathSegments.size - 1] = "" 1013 | } else { 1014 | encodedPathSegments.add("") 1015 | } 1016 | } 1017 | 1018 | /** 1019 | * Increments `pos` until `input[pos]` is not ASCII whitespace. Stops at `limit`. 1020 | */ 1021 | private fun skipLeadingAsciiWhitespace(input: String, pos: Int, limit: Int): Int { 1022 | loop@ for (i in pos until limit) { 1023 | when (input[i]) { 1024 | '\t', '\n', '\u000c', '\r', ' ' -> continue@loop 1025 | else -> return i 1026 | } 1027 | } 1028 | return limit 1029 | } 1030 | 1031 | /** 1032 | * Decrements `limit` until `input[limit - 1]` is not ASCII whitespace. Stops at 1033 | * `pos`. 1034 | */ 1035 | private fun skipTrailingAsciiWhitespace(input: String, pos: Int, limit: Int): Int { 1036 | loop@ for (i in limit - 1 downTo pos) { 1037 | when (input[i]) { 1038 | '\t', '\n', '\u000c', '\r', ' ' -> continue@loop 1039 | else -> return i + 1 1040 | } 1041 | } 1042 | return pos 1043 | } 1044 | 1045 | /** 1046 | * Returns the index of the ':' in `input` that is after scheme characters. Returns -1 if 1047 | * `input` does not have a scheme that starts at `pos`. 1048 | */ 1049 | private fun schemeDelimiterOffset(input: String, pos: Int, limit: Int): Int { 1050 | if (limit - pos < 2) return -1 1051 | 1052 | val c0 = input[pos] 1053 | if ((c0 < 'a' || c0 > 'z') && (c0 < 'A' || c0 > 'Z')) return -1 // Not a scheme start char. 1054 | 1055 | for (i in pos + 1 until limit) { 1056 | val c = input[i] 1057 | 1058 | return if (c in 'a'..'z' 1059 | || c in 'A'..'Z' 1060 | || c in '0'..'9' 1061 | || c == '+' 1062 | || c == '-' 1063 | || c == '.' 1064 | ) { 1065 | continue // Scheme character. Keep going. 1066 | } else if (c == ':') { 1067 | i // Scheme prefix! 1068 | } else { 1069 | -1 // Non-scheme character before the first ':'. 1070 | } 1071 | } 1072 | 1073 | return -1 // No ':'; doesn't start with a scheme. 1074 | } 1075 | 1076 | /** Returns the number of '/' and '\' slashes in `input`, starting at `pos`. */ 1077 | private fun slashCount(input: String, pos: Int, limit: Int): Int { 1078 | var mutablePos = pos 1079 | var slashCount = 0 1080 | while (mutablePos < limit) { 1081 | val c = input[mutablePos] 1082 | if (c == '\\' || c == '/') { 1083 | slashCount++ 1084 | mutablePos++ 1085 | } else { 1086 | break 1087 | } 1088 | } 1089 | return slashCount 1090 | } 1091 | 1092 | /** Finds the first ':' in `input`, skipping characters between square braces "[...]". */ 1093 | private fun portColonOffset(input: String, pos: Int, limit: Int): Int { 1094 | var i = pos 1095 | while (i < limit) { 1096 | when (input[i]) { 1097 | '[' -> while (++i < limit) { 1098 | if (input[i] == ']') break 1099 | } 1100 | ':' -> return i 1101 | else -> { 1102 | } 1103 | } 1104 | i++ 1105 | } 1106 | return limit // No colon. 1107 | } 1108 | 1109 | private fun canonicalizeHost(input: String, pos: Int, limit: Int): String? { 1110 | // Start by percent decoding the host. The WHATWG spec suggests doing this only after we've 1111 | // checked for IPv6 square braces. But Chrome does it first, and that's more lenient. 1112 | val percentDecoded = percentDecode(input, pos, limit, false) 1113 | 1114 | // If the input contains a :, it’s an IPv6 address. 1115 | if (percentDecoded.contains(":")) { 1116 | // If the input is encased in square braces "[...]", drop 'em. 1117 | val inetAddress = if (percentDecoded.startsWith("[") && percentDecoded.endsWith("]")) 1118 | decodeIpv6(percentDecoded, 1, percentDecoded.length - 1) 1119 | else 1120 | decodeIpv6(percentDecoded, 0, percentDecoded.length) 1121 | 1122 | val address = inetAddress?.address ?: return null 1123 | if (address.size == 16) return inet6AddressToAscii(address) 1124 | if (address.size == 4) return inetAddress.hostAddress // An IPv4-mapped IPv6 address. 1125 | throw AssertionError() 1126 | } 1127 | 1128 | return domainToAscii(percentDecoded) 1129 | } 1130 | 1131 | /** Decodes an IPv6 address like 1111:2222:3333:4444:5555:6666:7777:8888 or ::1. */ 1132 | private fun decodeIpv6(input: String, pos: Int, limit: Int): InetAddress? { 1133 | val address = ByteArray(16) 1134 | var b = 0 1135 | var compress = -1 1136 | var groupOffset = -1 1137 | 1138 | var i = pos 1139 | while (i < limit) { 1140 | if (b == address.size) return null // Too many groups. 1141 | 1142 | // Read a delimiter. 1143 | if (i + 2 <= limit && input.regionMatches(i, "::", 0, 2)) { 1144 | // Compression "::" delimiter, which is anywhere in the input, including its prefix. 1145 | if (compress != -1) return null // Multiple "::" delimiters. 1146 | i += 2 1147 | b += 2 1148 | compress = b 1149 | if (i == limit) break 1150 | } else if (b != 0) { 1151 | // Group separator ":" delimiter. 1152 | if (input.regionMatches(i, ":", 0, 1)) { 1153 | i++ 1154 | } else if (input.regionMatches(i, ".", 0, 1)) { 1155 | // If we see a '.', rewind to the beginning of the previous group and parse as IPv4. 1156 | if (!decodeIpv4Suffix(input, groupOffset, limit, address, b - 2)) return null 1157 | b += 2 // We rewound two bytes and then added four. 1158 | break 1159 | } else { 1160 | return null // Wrong delimiter. 1161 | } 1162 | } 1163 | 1164 | // Read a group, one to four hex digits. 1165 | var value = 0 1166 | groupOffset = i 1167 | while (i < limit) { 1168 | val c = input[i] 1169 | val hexDigit = decodeHexDigit(c) 1170 | if (hexDigit == -1) break 1171 | value = (value shl 4) + hexDigit 1172 | i++ 1173 | } 1174 | val groupLength = i - groupOffset 1175 | if (groupLength == 0 || groupLength > 4) return null // Group is the wrong size. 1176 | 1177 | // We've successfully read a group. Assign its value to our byte array. 1178 | address[b++] = (value.ushr(8) and 0xff).toByte() 1179 | address[b++] = (value and 0xff).toByte() 1180 | } 1181 | 1182 | // All done. If compression happened, we need to move bytes to the right place in the 1183 | // address. Here's a sample: 1184 | // 1185 | // input: "1111:2222:3333::7777:8888" 1186 | // before: { 11, 11, 22, 22, 33, 33, 00, 00, 77, 77, 88, 88, 00, 00, 00, 00 } 1187 | // compress: 6 1188 | // b: 10 1189 | // after: { 11, 11, 22, 22, 33, 33, 00, 00, 00, 00, 00, 00, 77, 77, 88, 88 } 1190 | // 1191 | if (b != address.size) { 1192 | if (compress == -1) return null // Address didn't have compression or enough groups. 1193 | System.arraycopy(address, compress, address, address.size - (b - compress), b - compress) 1194 | Arrays.fill(address, compress, compress + (address.size - b), 0.toByte()) 1195 | } 1196 | 1197 | try { 1198 | return InetAddress.getByAddress(address) 1199 | } catch (e: UnknownHostException) { 1200 | throw AssertionError() 1201 | } 1202 | 1203 | } 1204 | 1205 | /** Decodes an IPv4 address suffix of an IPv6 address, like 1111::5555:6666:192.168.0.1. */ 1206 | private fun decodeIpv4Suffix( 1207 | input: String, pos: Int, limit: Int, address: ByteArray, addressOffset: Int 1208 | ): Boolean { 1209 | var b = addressOffset 1210 | 1211 | var i = pos 1212 | while (i < limit) { 1213 | if (b == address.size) return false // Too many groups. 1214 | 1215 | // Read a delimiter. 1216 | if (b != addressOffset) { 1217 | if (input[i] != '.') return false // Wrong delimiter. 1218 | i++ 1219 | } 1220 | 1221 | // Read 1 or more decimal digits for a value in 0..255. 1222 | var value = 0 1223 | val groupOffset = i 1224 | while (i < limit) { 1225 | val c = input[i] 1226 | if (c < '0' || c > '9') break 1227 | if (value == 0 && groupOffset != i) return false // Reject unnecessary leading '0's. 1228 | value = value * 10 + c.toInt() - '0'.toInt() 1229 | if (value > 255) return false // Value out of range. 1230 | i++ 1231 | } 1232 | val groupLength = i - groupOffset 1233 | if (groupLength == 0) return false // No digits. 1234 | 1235 | // We've successfully read a byte. 1236 | address[b++] = value.toByte() 1237 | } 1238 | 1239 | return b == addressOffset + 4 // Too few groups. We wanted exactly four. 1240 | // Success. 1241 | } 1242 | 1243 | /** 1244 | * Performs IDN ToASCII encoding and canonicalize the result to lowercase. e.g. This converts 1245 | * `☃.net` to `xn--n3h.net`, and `WwW.GoOgLe.cOm` to `www.google.com`. 1246 | * `null` will be returned if the input cannot be ToASCII encoded or if the result 1247 | * contains unsupported ASCII characters. 1248 | */ 1249 | private fun domainToAscii(input: String): String? { 1250 | try { 1251 | val result = IDN.toASCII(input).toLowerCase(Locale.US) 1252 | if (result.isEmpty()) return null 1253 | 1254 | // Confirm that the IDN ToASCII result doesn't contain any illegal characters. 1255 | return if (containsInvalidHostnameAsciiCodes(result)) { 1256 | null 1257 | } else result 1258 | // TODO: implement all label limits. 1259 | } catch (e: IllegalArgumentException) { 1260 | return null 1261 | } 1262 | 1263 | } 1264 | 1265 | private fun containsInvalidHostnameAsciiCodes(hostnameAscii: String): Boolean { 1266 | for (i in 0 until hostnameAscii.length) { 1267 | val c = hostnameAscii[i] 1268 | // The WHATWG Host parsing rules accepts some character codes which are invalid by 1269 | // definition for OkHttp's host header checks (and the WHATWG Host syntax definition). Here 1270 | // we rule out characters that would cause problems in host headers. 1271 | if (c <= '\u001f' || c >= '\u007f') { 1272 | return true 1273 | } 1274 | // Check for the characters mentioned in the WHATWG Host parsing spec: 1275 | // U+0000, U+0009, U+000A, U+000D, U+0020, "#", "%", "/", ":", "?", "@", "[", "\", and "]" 1276 | // (excluding the characters covered above). 1277 | if (" #%/:?@[\\]".indexOf(c) != -1) { 1278 | return true 1279 | } 1280 | } 1281 | return false 1282 | } 1283 | 1284 | private fun inet6AddressToAscii(address: ByteArray): String { 1285 | // Go through the address looking for the longest run of 0s. Each group is 2-bytes. 1286 | // A run must be longer than one group (section 4.2.2). 1287 | // If there are multiple equal runs, the first one must be used (section 4.2.3). 1288 | var longestRunOffset = -1 1289 | var longestRunLength = 0 1290 | run { 1291 | var i = 0 1292 | while (i < address.size) { 1293 | val currentRunOffset = i 1294 | while (i < 16 && address[i].toInt() == 0 && address[i + 1].toInt() == 0) { 1295 | i += 2 1296 | } 1297 | val currentRunLength = i - currentRunOffset 1298 | if (currentRunLength > longestRunLength && currentRunLength >= 4) { 1299 | longestRunOffset = currentRunOffset 1300 | longestRunLength = currentRunLength 1301 | } 1302 | i += 2 1303 | } 1304 | } 1305 | 1306 | // Emit each 2-byte group in hex, separated by ':'. The longest run of zeroes is "::". 1307 | val result = Buffer() 1308 | var i = 0 1309 | while (i < address.size) { 1310 | if (i == longestRunOffset) { 1311 | result.writeByte(':'.toInt()) 1312 | i += longestRunLength 1313 | if (i == 16) result.writeByte(':'.toInt()) 1314 | } else { 1315 | if (i > 0) result.writeByte(':'.toInt()) 1316 | val group = ((address[i].toInt() and 0xff) shl 8) or (address[i + 1].toInt() and 0xff) 1317 | result.writeHexadecimalUnsignedLong(group.toLong()) 1318 | i += 2 1319 | } 1320 | } 1321 | return result.readUtf8() 1322 | } 1323 | 1324 | private fun parsePort(input: String, pos: Int, limit: Int): Int { 1325 | return try { 1326 | // Canonicalize the port string to skip '\n' etc. 1327 | val portString = 1328 | canonicalize(input, pos, limit, "", alreadyEncoded = false, strict = false, plusIsSpace = false, asciiOnly = true) 1329 | val i = Integer.parseInt(portString) 1330 | if (i in 1..65535) i else -1 1331 | } catch (e: NumberFormatException) { 1332 | -1 // Invalid port. 1333 | } 1334 | 1335 | } 1336 | } 1337 | 1338 | private fun percentDecode(list: List, plusIsSpace: Boolean): List { 1339 | val result = ArrayList(list.size) 1340 | for (s in list) { 1341 | result.add(if (s != null) percentDecode(s, plusIsSpace = plusIsSpace) else null) 1342 | } 1343 | return Collections.unmodifiableList(result) 1344 | } 1345 | 1346 | companion object { 1347 | private val HEX_DIGITS = 1348 | charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') 1349 | private const val USERNAME_ENCODE_SET = " \"':;<=>@[]^`{}|/\\?#" 1350 | private const val PASSWORD_ENCODE_SET = " \"':;<=>@[]^`{}|/\\?#" 1351 | private const val PATH_SEGMENT_ENCODE_SET = " \"<>^`{}|/\\?#" 1352 | private const val PATH_SEGMENT_ENCODE_SET_URI = "[]" 1353 | private const val QUERY_ENCODE_SET = " \"'<>#" 1354 | private const val QUERY_COMPONENT_REENCODE_SET = " \"'<>#&=" 1355 | private const val QUERY_COMPONENT_ENCODE_SET = " !\"#$&'(),/:;<=>?@[]\\^`{|}~" 1356 | private const val QUERY_COMPONENT_ENCODE_SET_URI = "\\^`{|}" 1357 | private const val CONVERT_TO_URI_ENCODE_SET = "^`{}|\\" 1358 | private const val FORM_ENCODE_SET = " \"':;<=>@[]^`{}|/\\?#&!$(),~" 1359 | private const val FRAGMENT_ENCODE_SET = "" 1360 | private const val FRAGMENT_ENCODE_SET_URI = " \"#<>\\^`{|}" 1361 | 1362 | /** 1363 | * Returns 80 if `scheme.equals("http")`, 443 if `scheme.equals("https")` and -1 1364 | * otherwise. 1365 | */ 1366 | internal fun defaultPort(scheme: String): Int { 1367 | return when (scheme) { 1368 | "http" -> 80 1369 | "https" -> 443 1370 | else -> -1 1371 | } 1372 | } 1373 | 1374 | internal fun pathSegmentsToString(out: StringBuilder, pathSegments: List) { 1375 | var i = 0 1376 | val size = pathSegments.size 1377 | while (i < size) { 1378 | out.append('/') 1379 | out.append(pathSegments[i]) 1380 | i++ 1381 | } 1382 | } 1383 | 1384 | internal fun namesAndValuesToQueryString(out: StringBuilder, namesAndValues: List) { 1385 | var i = 0 1386 | val size = namesAndValues.size 1387 | while (i < size) { 1388 | val name = namesAndValues[i] 1389 | val value = namesAndValues[i + 1] 1390 | if (i > 0) out.append('&') 1391 | out.append(name) 1392 | if (value != null) { 1393 | out.append('=') 1394 | out.append(value) 1395 | } 1396 | i += 2 1397 | } 1398 | } 1399 | 1400 | /** 1401 | * Cuts `encodedQuery` up into alternating parameter names and values. This divides a 1402 | * query string like `subject=math&easy&problem=5-2=3` into the list `["subject", 1403 | * "math", "easy", null, "problem", "5-2=3"]`. Note that values may be null and may contain 1404 | * '=' characters. 1405 | */ 1406 | internal fun queryStringToNamesAndValues(encodedQuery: String): MutableList { 1407 | val result = ArrayList() 1408 | var pos = 0 1409 | while (pos <= encodedQuery.length) { 1410 | var ampersandOffset = encodedQuery.indexOf('&', pos) 1411 | if (ampersandOffset == -1) ampersandOffset = encodedQuery.length 1412 | 1413 | val equalsOffset = encodedQuery.indexOf('=', pos) 1414 | if (equalsOffset == -1 || equalsOffset > ampersandOffset) { 1415 | result.add(encodedQuery.substring(pos, ampersandOffset)) 1416 | result.add(null) // No value for this name. 1417 | } else { 1418 | result.add(encodedQuery.substring(pos, equalsOffset)) 1419 | result.add(encodedQuery.substring(equalsOffset + 1, ampersandOffset)) 1420 | } 1421 | pos = ampersandOffset + 1 1422 | } 1423 | return result 1424 | } 1425 | 1426 | /** 1427 | * Returns a new `DeepLinkUri` representing `uri` if it is a well-formed 1428 | * URI, or throws an `IllegalArgumentException` if it isn't. 1429 | */ 1430 | @JvmStatic 1431 | fun parse(uri: String): DeepLinkUri { 1432 | val builder = Builder() 1433 | val result = builder.parse(null, uri) 1434 | return when (result) { 1435 | ParseResult.SUCCESS -> builder.build() 1436 | else -> throw IllegalArgumentException("Invalid URL: $result for $uri") 1437 | } 1438 | } 1439 | 1440 | /** 1441 | * Returns a new `DeepLinkUri` representing `uri` if it is a well-formed 1442 | * URI, or null if it isn't. 1443 | */ 1444 | @JvmStatic 1445 | fun parseOrNull(uri: String): DeepLinkUri? { 1446 | val builder = Builder() 1447 | val result = builder.parse(null, uri) 1448 | return if (result == ParseResult.SUCCESS) builder.build() else null 1449 | } 1450 | 1451 | /** 1452 | * Returns an [DeepLinkUri] for `url` or throw. 1453 | * 1454 | * Use `parseOrNull(url.toString())` if you'd prefer a nullable version than throwing. 1455 | */ 1456 | @JvmStatic 1457 | fun get(url: URL): DeepLinkUri { 1458 | return parse(url.toString()) 1459 | } 1460 | 1461 | /** 1462 | * Returns an [DeepLinkUri] for `uri` or throw. 1463 | * 1464 | * Use `parseOrNull(uri.toString())` if you'd prefer a nullable version than throwing. 1465 | */ 1466 | @JvmStatic 1467 | fun get(uri: URI): DeepLinkUri { 1468 | return parse(uri.toString()) 1469 | } 1470 | 1471 | /** 1472 | * Returns the index of the first character in `input` that contains a character in `delimiters`. Returns limit if there is no such character. 1473 | */ 1474 | private fun delimiterOffset(input: String, pos: Int, limit: Int, delimiters: String): Int { 1475 | for (i in pos until limit) { 1476 | if (delimiters.indexOf(input[i]) != -1) return i 1477 | } 1478 | return limit 1479 | } 1480 | 1481 | @JvmOverloads 1482 | internal fun percentDecode( 1483 | encoded: String, 1484 | pos: Int = 0, 1485 | limit: Int = encoded.length, 1486 | plusIsSpace: Boolean = false 1487 | ): String { 1488 | for (i in pos until limit) { 1489 | val c = encoded[i] 1490 | if (c == '%' || c == '+' && plusIsSpace) { 1491 | // Slow path: the character at i requires decoding! 1492 | val out = Buffer() 1493 | out.writeUtf8(encoded, pos, i) 1494 | percentDecode(out, encoded, i, limit, plusIsSpace) 1495 | return out.readUtf8() 1496 | } 1497 | } 1498 | 1499 | // Fast path: no characters in [pos..limit) required decoding. 1500 | return encoded.substring(pos, limit) 1501 | } 1502 | 1503 | private fun percentDecode(out: Buffer, encoded: String, pos: Int, limit: Int, plusIsSpace: Boolean) { 1504 | var codePoint: Int 1505 | var i = pos 1506 | while (i < limit) { 1507 | codePoint = encoded.codePointAt(i) 1508 | if (codePoint == '%'.toInt() && i + 2 < limit) { 1509 | val d1 = decodeHexDigit(encoded[i + 1]) 1510 | val d2 = decodeHexDigit(encoded[i + 2]) 1511 | if (d1 != -1 && d2 != -1) { 1512 | out.writeByte((d1 shl 4) + d2) 1513 | i += 2 1514 | i += Character.charCount(codePoint) 1515 | continue 1516 | } 1517 | } else if (codePoint == '+'.toInt() && plusIsSpace) { 1518 | out.writeByte(' '.toInt()) 1519 | i += Character.charCount(codePoint) 1520 | continue 1521 | } 1522 | out.writeUtf8CodePoint(codePoint) 1523 | i += Character.charCount(codePoint) 1524 | } 1525 | } 1526 | 1527 | internal fun percentEncoded(encoded: String, pos: Int, limit: Int): Boolean { 1528 | return (pos + 2 < limit 1529 | && encoded[pos] == '%' 1530 | && decodeHexDigit(encoded[pos + 1]) != -1 1531 | && decodeHexDigit(encoded[pos + 2]) != -1) 1532 | } 1533 | 1534 | internal fun decodeHexDigit(c: Char): Int { 1535 | return when (c) { 1536 | in '0'..'9' -> c - '0' 1537 | in 'a'..'f' -> c - 'a' + 10 1538 | in 'A'..'F' -> c - 'A' + 10 1539 | else -> -1 1540 | } 1541 | } 1542 | 1543 | /** 1544 | * Returns a substring of `input` on the range `[pos..limit)` with the following 1545 | * transformations: 1546 | * 1547 | * * Tabs, newlines, form feeds and carriage returns are skipped. 1548 | * * In queries, ' ' is encoded to '+' and '+' is encoded to "%2B". 1549 | * * Characters in `encodeSet` are percent-encoded. 1550 | * * Control characters and non-ASCII characters are percent-encoded. 1551 | * * All other characters are copied without transformation. 1552 | * 1553 | * 1554 | * @param alreadyEncoded true to leave '%' as-is; false to convert it to '%25'. 1555 | * @param strict true to encode '%' if it is not the prefix of a valid percent encoding. 1556 | * @param plusIsSpace true to encode '+' as "%2B" if it is not already encoded. 1557 | * @param asciiOnly true to encode all non-ASCII codepoints. 1558 | */ 1559 | internal fun canonicalize( 1560 | input: String, pos: Int, limit: Int, encodeSet: String, 1561 | alreadyEncoded: Boolean, strict: Boolean, plusIsSpace: Boolean, asciiOnly: Boolean 1562 | ): String { 1563 | var codePoint: Int 1564 | var i = pos 1565 | while (i < limit) { 1566 | codePoint = input.codePointAt(i) 1567 | if (codePoint < 0x20 1568 | || codePoint == 0x7f 1569 | || codePoint >= 0x80 && asciiOnly 1570 | || encodeSet.indexOf(codePoint.toChar()) != -1 1571 | || codePoint == '%'.toInt() && (!alreadyEncoded || strict && !percentEncoded(input, i, limit)) 1572 | || codePoint == '+'.toInt() && plusIsSpace 1573 | ) { 1574 | // Slow path: the character at i requires encoding! 1575 | val out = Buffer() 1576 | out.writeUtf8(input, pos, i) 1577 | canonicalize(out, input, i, limit, encodeSet, alreadyEncoded, strict, plusIsSpace, asciiOnly) 1578 | return out.readUtf8() 1579 | } 1580 | i += Character.charCount(codePoint) 1581 | } 1582 | 1583 | // Fast path: no characters in [pos..limit) required encoding. 1584 | return input.substring(pos, limit) 1585 | } 1586 | 1587 | private fun canonicalize( 1588 | out: Buffer, input: String, pos: Int, limit: Int, 1589 | encodeSet: String, alreadyEncoded: Boolean, strict: Boolean, plusIsSpace: Boolean, asciiOnly: Boolean 1590 | ) { 1591 | var utf8Buffer: Buffer? = null // Lazily allocated. 1592 | var codePoint: Int 1593 | var i = pos 1594 | while (i < limit) { 1595 | codePoint = input.codePointAt(i) 1596 | if (alreadyEncoded && (codePoint == '\t'.toInt() || codePoint == '\n'.toInt() || codePoint == '\u000c'.toInt() || codePoint == '\r'.toInt())) { 1597 | // Skip this character. 1598 | } else if (codePoint == '+'.toInt() && plusIsSpace) { 1599 | // Encode '+' as '%2B' since we permit ' ' to be encoded as either '+' or '%20'. 1600 | out.writeUtf8(if (alreadyEncoded) "+" else "%2B") 1601 | } else if (codePoint < 0x20 1602 | || codePoint == 0x7f 1603 | || codePoint >= 0x80 && asciiOnly 1604 | || encodeSet.indexOf(codePoint.toChar()) != -1 1605 | || codePoint == '%'.toInt() && (!alreadyEncoded || strict && !percentEncoded(input, i, limit)) 1606 | ) { 1607 | // Percent encode this character. 1608 | if (utf8Buffer == null) { 1609 | utf8Buffer = Buffer() 1610 | } 1611 | utf8Buffer.writeUtf8CodePoint(codePoint) 1612 | while (!utf8Buffer.exhausted()) { 1613 | val b = utf8Buffer.readByte().toInt() and 0xff 1614 | out.writeByte('%'.toInt()) 1615 | out.writeByte(HEX_DIGITS[b shr 4 and 0xf].toInt()) 1616 | out.writeByte(HEX_DIGITS[b and 0xf].toInt()) 1617 | } 1618 | } else { 1619 | // This character doesn't need encoding. Just copy it over. 1620 | out.writeUtf8CodePoint(codePoint) 1621 | } 1622 | i += Character.charCount(codePoint) 1623 | } 1624 | } 1625 | 1626 | internal fun canonicalize( 1627 | input: String, 1628 | encodeSet: String, 1629 | alreadyEncoded: Boolean, 1630 | strict: Boolean, 1631 | plusIsSpace: Boolean, 1632 | asciiOnly: Boolean 1633 | ): String { 1634 | return canonicalize(input, 0, input.length, encodeSet, alreadyEncoded, strict, plusIsSpace, asciiOnly) 1635 | } 1636 | } 1637 | } 1638 | -------------------------------------------------------------------------------- /deeplink/src/main/java/com/hellofresh/deeplink/Environment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | import android.content.Context 20 | 21 | interface Environment { 22 | 23 | val context: Context 24 | val isAuthenticated: Boolean 25 | } 26 | -------------------------------------------------------------------------------- /deeplink/src/main/java/com/hellofresh/deeplink/MatchResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | internal class MatchResult(val isMatch: Boolean, val params: Map = emptyMap()) 20 | -------------------------------------------------------------------------------- /deeplink/src/main/java/com/hellofresh/deeplink/extension/DeepLinkUri.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink.extension 18 | 19 | import android.net.Uri 20 | import com.hellofresh.deeplink.DeepLinkUri 21 | 22 | fun DeepLinkUri.Companion.get(uri: Uri): DeepLinkUri { 23 | return parse(uri.toString()) 24 | } 25 | 26 | fun DeepLinkUri.toAndroidUri(): Uri { 27 | return Uri.parse(toString()) 28 | } 29 | -------------------------------------------------------------------------------- /deeplink/src/test/java/com/hellofresh/deeplink/BaseRouteTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | import org.junit.Test 20 | import kotlin.test.assertEquals 21 | import kotlin.test.assertFalse 22 | import kotlin.test.assertTrue 23 | 24 | class BaseRouteTest { 25 | 26 | @Test 27 | fun matchWith_pathVariations() { 28 | var uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes") 29 | assertTrue(TestRoute.matchWith(uri).isMatch) 30 | 31 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/") 32 | assertTrue(TestRoute.matchWith(uri).isMatch) 33 | 34 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/x") 35 | assertFalse(TestRoute.matchWith(uri).isMatch) 36 | 37 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/1234") 38 | assertTrue(TestRoute.matchWith(uri).isMatch) 39 | 40 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/") 41 | assertFalse(TestRoute.matchWith(uri).isMatch) 42 | 43 | uri = DeepLinkUri.parse("hellofresh://host/recipes") 44 | assertTrue(TestRoute.matchWith(uri).isMatch) 45 | 46 | uri = DeepLinkUri.parse("hellofresh://host/recipes/") 47 | assertTrue(TestRoute.matchWith(uri).isMatch) 48 | 49 | uri = DeepLinkUri.parse("hellofresh://host/recipes/x") 50 | assertFalse(TestRoute.matchWith(uri).isMatch) 51 | 52 | uri = DeepLinkUri.parse("hellofresh://host/recipe/1234") 53 | assertTrue(TestRoute.matchWith(uri).isMatch) 54 | 55 | uri = DeepLinkUri.parse("hellofresh://host/recipe/") 56 | assertFalse(TestRoute.matchWith(uri).isMatch) 57 | } 58 | 59 | @Test 60 | fun matchWith_pathVariationsWithOverride() { 61 | var uri = DeepLinkUri.parse("hellofresh://recipes") 62 | assertTrue(PathOverrideRoute.matchWith(uri).isMatch) 63 | 64 | uri = DeepLinkUri.parse("hellofresh://recipes/") 65 | assertTrue(PathOverrideRoute.matchWith(uri).isMatch) 66 | 67 | uri = DeepLinkUri.parse("hellofresh://recipes/x") 68 | assertFalse(PathOverrideRoute.matchWith(uri).isMatch) 69 | 70 | uri = DeepLinkUri.parse("hellofresh://recipe/1234") 71 | assertTrue(PathOverrideRoute.matchWith(uri).isMatch) 72 | 73 | uri = DeepLinkUri.parse("hellofresh://recipe/") 74 | assertFalse(PathOverrideRoute.matchWith(uri).isMatch) 75 | } 76 | 77 | @Test 78 | fun matchWith_pathVariationsWithNamelessParameter() { 79 | var uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/x/1234") 80 | assertTrue(NamelessPathRoute.matchWith(uri).isMatch) 81 | 82 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes//1234") 83 | assertTrue(NamelessPathRoute.matchWith(uri).isMatch) 84 | 85 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/") 86 | assertFalse(NamelessPathRoute.matchWith(uri).isMatch) 87 | 88 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes//") 89 | assertFalse(NamelessPathRoute.matchWith(uri).isMatch) 90 | 91 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/x") 92 | assertTrue(NamelessPathRoute.matchWith(uri).isMatch) 93 | 94 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/1234") 95 | assertTrue(NamelessPathRoute.matchWith(uri).isMatch) 96 | 97 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/") 98 | assertFalse(NamelessPathRoute.matchWith(uri).isMatch) 99 | 100 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe") 101 | assertFalse(NamelessPathRoute.matchWith(uri).isMatch) 102 | } 103 | 104 | @Test 105 | fun matchWith_InputWithOnlyPathParams_ReturnsPathData() { 106 | val uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/1234") 107 | val params = TestRoute.matchWith(uri).params 108 | 109 | assertEquals(1, params.size) 110 | assertEquals("1234", params["id"]) 111 | } 112 | 113 | @Test 114 | fun matchWith_DefaultPathResolution() { 115 | val uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/1234") 116 | assertTrue(TestRoute.matchWith(uri).isMatch) 117 | 118 | val customUriWithHost = DeepLinkUri.parse("hellofresh://host/recipe/1234") 119 | assertTrue(TestRoute.matchWith(customUriWithHost).isMatch) 120 | 121 | val customUriNoHost = DeepLinkUri.parse("hellofresh://recipe/1234") 122 | assertFalse(TestRoute.matchWith(customUriNoHost).isMatch) 123 | } 124 | 125 | @Test 126 | fun matchWith_OverridePathResolution() { 127 | val uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/1234") 128 | assertTrue(PathOverrideRoute.matchWith(uri).isMatch) 129 | 130 | val customUriWithHost = DeepLinkUri.parse("hellofresh://host/recipe/1234") 131 | assertFalse(PathOverrideRoute.matchWith(customUriWithHost).isMatch) 132 | 133 | val customUriNoHost = DeepLinkUri.parse("hellofresh://recipe/1234") 134 | assertTrue(PathOverrideRoute.matchWith(customUriNoHost).isMatch) 135 | } 136 | 137 | @Test 138 | fun matchWith_namelessPathResolution() { 139 | var uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/me/1234") 140 | assertTrue(NamelessPathRoute.matchWith(uri).isMatch) 141 | 142 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/customer-key/1234") 143 | assertTrue(NamelessPathRoute.matchWith(uri).isMatch) 144 | 145 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/anything/1234") 146 | assertTrue(NamelessPathRoute.matchWith(uri).isMatch) 147 | } 148 | 149 | @Test 150 | fun matchWith_regexPathResolution() { 151 | var uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/detail/abc-1234") 152 | var res = RegexPathRoute.matchWith(uri) 153 | assertTrue(res.isMatch) 154 | assertEquals("detail", res.params["action"]) 155 | assertEquals("abc-1234", res.params["id"]) 156 | 157 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/info/abc-1234") 158 | res = RegexPathRoute.matchWith(uri) 159 | assertTrue(res.isMatch) 160 | assertEquals("info", res.params["action"]) 161 | assertEquals("abc-1234", res.params["id"]) 162 | 163 | // action does not match 164 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/invalid/abc-1234") 165 | assertFalse(RegexPathRoute.matchWith(uri).isMatch) 166 | 167 | // id does not match 168 | uri = DeepLinkUri.parse("http://www.hellofresh.com/recipes/detail/1234") 169 | assertFalse(RegexPathRoute.matchWith(uri).isMatch) 170 | } 171 | 172 | @Test 173 | fun matchWith_regexPathResolutionUnnamed() { 174 | val uri = DeepLinkUri.parse("http://www.hellofresh.com/recipe/abc-1234") 175 | val res = UnnamedRegexPathRoute.matchWith(uri) 176 | assertTrue(res.isMatch) 177 | assertTrue(res.params.isEmpty()) 178 | } 179 | 180 | object TestRoute : BaseRoute("recipes", "recipe/:id") { 181 | 182 | override fun run(uri: DeepLinkUri, params: Map, env: Environment) = Unit 183 | } 184 | 185 | object PathOverrideRoute : BaseRoute("recipes", "recipe/:id") { 186 | 187 | override fun run(uri: DeepLinkUri, params: Map, env: Environment) = Unit 188 | 189 | override fun treatHostAsPath(uri: DeepLinkUri): Boolean { 190 | return uri.scheme() == "hellofresh" 191 | } 192 | } 193 | 194 | object NamelessPathRoute : BaseRoute("recipe/*", "recipes/*/:id") { 195 | 196 | override fun run(uri: DeepLinkUri, params: Map, env: Environment) = Unit 197 | } 198 | 199 | object RegexPathRoute : BaseRoute("recipes/:action(detail|info)/:id(.*-\\w+)") { 200 | 201 | override fun run(uri: DeepLinkUri, params: Map, env: Environment) = Unit 202 | } 203 | 204 | object UnnamedRegexPathRoute : BaseRoute("recipe/:(.*-\\w+)") { 205 | 206 | override fun run(uri: DeepLinkUri, params: Map, env: Environment) = Unit 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /deeplink/src/test/java/com/hellofresh/deeplink/DeepLinkUriTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2015 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.hellofresh.deeplink 17 | 18 | import org.junit.Ignore 19 | import org.junit.Test 20 | import org.junit.runner.RunWith 21 | import org.junit.runners.Parameterized 22 | import java.net.URI 23 | import java.net.URL 24 | import java.util.Arrays 25 | import java.util.LinkedHashSet 26 | import kotlin.test.assertEquals 27 | import kotlin.test.assertFailsWith 28 | import kotlin.test.assertNull 29 | import kotlin.test.fail 30 | 31 | @RunWith(Parameterized::class) 32 | class DeepLinkUriTest { 33 | 34 | @JvmField 35 | @Parameterized.Parameter 36 | var useGet: Boolean = false 37 | 38 | private fun parse(link: String): DeepLinkUri? { 39 | return when { 40 | useGet -> DeepLinkUri.parse(link) 41 | else -> DeepLinkUri.parseOrNull(link) 42 | } 43 | } 44 | 45 | @Test 46 | fun parseTrimsAsciiWhitespace() { 47 | val expected = parse("http://host/") 48 | assertEquals(expected, DeepLinkUri.parse("http://host/\u000c\n\t \r")) // Leading. 49 | assertEquals(expected, DeepLinkUri.parse("\r\n\u000c \thttp://host/")) // Trailing. 50 | assertEquals(expected, DeepLinkUri.parse(" http://host/ ")) // Both. 51 | assertEquals(expected, DeepLinkUri.parse(" http://host/ ")) // Both. 52 | assertEquals(expected, DeepLinkUri.parse("http://host/").resolve(" ")) 53 | assertEquals(expected, DeepLinkUri.parse("http://host/").resolve(" . ")) 54 | } 55 | 56 | @Test 57 | fun parseHostAsciiNonPrintable() { 58 | val host = "host\u0001" 59 | assertInvalid("http://$host/", "Invalid URL host: \"host\u0001\"") 60 | // TODO make exception message escape non-printable characters 61 | } 62 | 63 | @Test 64 | fun parseDoesNotTrimOtherWhitespaceCharacters() { 65 | // Whitespace characters list from Google's Guava team: http://goo.gl/IcR9RD 66 | assertEquals("/%0B", DeepLinkUri.parse("http://h/\u000b").encodedPath()) // line tabulation 67 | assertEquals("/%1C", DeepLinkUri.parse("http://h/\u001c").encodedPath()) // information separator 4 68 | assertEquals("/%1D", DeepLinkUri.parse("http://h/\u001d").encodedPath()) // information separator 3 69 | assertEquals("/%1E", DeepLinkUri.parse("http://h/\u001e").encodedPath()) // information separator 2 70 | assertEquals("/%1F", DeepLinkUri.parse("http://h/\u001f").encodedPath()) // information separator 1 71 | assertEquals("/%C2%85", DeepLinkUri.parse("http://h/\u0085").encodedPath()) // next line 72 | assertEquals("/%C2%A0", DeepLinkUri.parse("http://h/\u00a0").encodedPath()) // non-breaking space 73 | assertEquals("/%E1%9A%80", DeepLinkUri.parse("http://h/\u1680").encodedPath()) // ogham space mark 74 | assertEquals("/%E1%A0%8E", DeepLinkUri.parse("http://h/\u180e").encodedPath()) // mongolian vowel separator 75 | assertEquals("/%E2%80%80", DeepLinkUri.parse("http://h/\u2000").encodedPath()) // en quad 76 | assertEquals("/%E2%80%81", DeepLinkUri.parse("http://h/\u2001").encodedPath()) // em quad 77 | assertEquals("/%E2%80%82", DeepLinkUri.parse("http://h/\u2002").encodedPath()) // en space 78 | assertEquals("/%E2%80%83", DeepLinkUri.parse("http://h/\u2003").encodedPath()) // em space 79 | assertEquals("/%E2%80%84", DeepLinkUri.parse("http://h/\u2004").encodedPath()) // three-per-em space 80 | assertEquals("/%E2%80%85", DeepLinkUri.parse("http://h/\u2005").encodedPath()) // four-per-em space 81 | assertEquals("/%E2%80%86", DeepLinkUri.parse("http://h/\u2006").encodedPath()) // six-per-em space 82 | assertEquals("/%E2%80%87", DeepLinkUri.parse("http://h/\u2007").encodedPath()) // figure space 83 | assertEquals("/%E2%80%88", DeepLinkUri.parse("http://h/\u2008").encodedPath()) // punctuation space 84 | assertEquals("/%E2%80%89", DeepLinkUri.parse("http://h/\u2009").encodedPath()) // thin space 85 | assertEquals("/%E2%80%8A", DeepLinkUri.parse("http://h/\u200a").encodedPath()) // hair space 86 | assertEquals("/%E2%80%8B", DeepLinkUri.parse("http://h/\u200b").encodedPath()) // zero-width space 87 | assertEquals("/%E2%80%8C", DeepLinkUri.parse("http://h/\u200c").encodedPath()) // zero-width non-joiner 88 | assertEquals("/%E2%80%8D", DeepLinkUri.parse("http://h/\u200d").encodedPath()) // zero-width joiner 89 | assertEquals("/%E2%80%8E", DeepLinkUri.parse("http://h/\u200e").encodedPath()) // left-to-right mark 90 | assertEquals("/%E2%80%8F", DeepLinkUri.parse("http://h/\u200f").encodedPath()) // right-to-left mark 91 | assertEquals("/%E2%80%A8", DeepLinkUri.parse("http://h/\u2028").encodedPath()) // line separator 92 | assertEquals("/%E2%80%A9", DeepLinkUri.parse("http://h/\u2029").encodedPath()) // paragraph separator 93 | assertEquals("/%E2%80%AF", DeepLinkUri.parse("http://h/\u202f").encodedPath()) // narrow non-breaking space 94 | assertEquals("/%E2%81%9F", DeepLinkUri.parse("http://h/\u205f").encodedPath()) // medium mathematical space 95 | assertEquals("/%E3%80%80", DeepLinkUri.parse("http://h/\u3000").encodedPath()) // ideographic space 96 | } 97 | 98 | @Test 99 | fun scheme() { 100 | assertEquals(parse("http://host/"), DeepLinkUri.parse("http://host/")) 101 | assertEquals(parse("http://host/"), DeepLinkUri.parse("Http://host/")) 102 | assertEquals(parse("http://host/"), DeepLinkUri.parse("http://host/")) 103 | assertEquals(parse("http://host/"), DeepLinkUri.parse("HTTP://host/")) 104 | assertEquals(parse("https://host/"), DeepLinkUri.parse("https://host/")) 105 | assertEquals(parse("https://host/"), DeepLinkUri.parse("HTTPS://host/")) 106 | 107 | assertEquals(parse("image640://480.png"), DeepLinkUri.parse("image640://480.png")) 108 | assertEquals(parse("httpp://host/"), DeepLinkUri.parse("httpp://host/")) 109 | assertEquals(parse("ht+tp://host/"), DeepLinkUri.parse("ht+tp://host/")) 110 | assertEquals(parse("ht.tp://host/"), DeepLinkUri.parse("ht.tp://host/")) 111 | assertEquals(parse("ht-tp://host/"), DeepLinkUri.parse("ht-tp://host/")) 112 | assertEquals(parse("ht1tp://host/"), DeepLinkUri.parse("ht1tp://host/")) 113 | assertEquals(parse("httpss://host/"), DeepLinkUri.parse("httpss://host/")) 114 | 115 | assertInvalid("0ttp://host/", "Expected URL scheme 'http' or 'https' but no colon was found") 116 | } 117 | 118 | @Test 119 | fun parseNoScheme() { 120 | assertInvalid("//host", "Expected URL scheme 'http' or 'https' but no colon was found") 121 | assertInvalid("/path", "Expected URL scheme 'http' or 'https' but no colon was found") 122 | assertInvalid("path", "Expected URL scheme 'http' or 'https' but no colon was found") 123 | assertInvalid("?query", "Expected URL scheme 'http' or 'https' but no colon was found") 124 | assertInvalid("#fragment", "Expected URL scheme 'http' or 'https' but no colon was found") 125 | } 126 | 127 | @Test 128 | fun newBuilderResolve() { 129 | // Non-exhaustive tests because implementation is the same as resolve. 130 | val base = DeepLinkUri.parse("http://host/a/b") 131 | assertEquals(parse("https://host2/"), base.resolve("https://host2")) 132 | assertEquals(parse("http://host2/"), base.resolve("//host2")) 133 | assertEquals(parse("http://host/path"), base.resolve("/path")) 134 | assertEquals(parse("http://host/a/path"), base.resolve("path")) 135 | assertEquals(parse("http://host/a/b?query"), base.resolve("?query")) 136 | assertEquals(parse("http://host/a/b#fragment"), base.resolve("#fragment")) 137 | assertEquals(parse("http://host/a/b"), base.resolve("")) 138 | 139 | assertEquals(parse("ftp://b"), base.resolve("ftp://b")) 140 | assertEquals(parse("ht+tp://b"), base.resolve("ht+tp://b")) 141 | assertEquals(parse("ht-tp://b"), base.resolve("ht-tp://b")) 142 | assertEquals(parse("ht.tp://b"), base.resolve("ht.tp://b")) 143 | } 144 | 145 | @Test 146 | fun resolveNoScheme() { 147 | val base = DeepLinkUri.parse("http://host/a/b") 148 | assertEquals(parse("http://host2/"), base.resolve("//host2")) 149 | assertEquals(parse("http://host/path"), base.resolve("/path")) 150 | assertEquals(parse("http://host/a/path"), base.resolve("path")) 151 | assertEquals(parse("http://host/a/b?query"), base.resolve("?query")) 152 | assertEquals(parse("http://host/a/b#fragment"), base.resolve("#fragment")) 153 | assertEquals(parse("http://host/a/b"), base.resolve("")) 154 | assertEquals(parse("http://host/path"), base.resolve("\\path")) 155 | } 156 | 157 | @Test 158 | fun resolveCustomScheme() { 159 | val base = DeepLinkUri.parse("http://a/") 160 | assertEquals(parse("ftp://b"), base.resolve("ftp://b")) 161 | assertEquals(parse("ht+tp://b"), base.resolve("ht+tp://b")) 162 | assertEquals(parse("ht-tp://b"), base.resolve("ht-tp://b")) 163 | assertEquals(parse("ht.tp://b"), base.resolve("ht.tp://b")) 164 | } 165 | 166 | @Test 167 | fun resolveSchemeLikePath() { 168 | val base = DeepLinkUri.parse("http://a/") 169 | assertEquals(parse("http://a/http//b/"), base.resolve("http//b/")) 170 | assertEquals(parse("http://a/ht+tp//b/"), base.resolve("ht+tp//b/")) 171 | assertEquals(parse("http://a/ht-tp//b/"), base.resolve("ht-tp//b/")) 172 | assertEquals(parse("http://a/ht.tp//b/"), base.resolve("ht.tp//b/")) 173 | } 174 | 175 | /** https://tools.ietf.org/html/rfc3986#section-5.4.1 */ 176 | @Test 177 | fun rfc3886NormalExamples() { 178 | val url = DeepLinkUri.parse("http://a/b/c/d;p?q") 179 | assertEquals(parse("g://h/"), url.resolve("g:h")) 180 | assertEquals(parse("http://a/b/c/g"), url.resolve("g")) 181 | assertEquals(parse("http://a/b/c/g"), url.resolve("./g")) 182 | assertEquals(parse("http://a/b/c/g/"), url.resolve("g/")) 183 | assertEquals(parse("http://a/g"), url.resolve("/g")) 184 | assertEquals(parse("http://g"), url.resolve("//g")) 185 | assertEquals(parse("http://a/b/c/d;p?y"), url.resolve("?y")) 186 | assertEquals(parse("http://a/b/c/g?y"), url.resolve("g?y")) 187 | assertEquals(parse("http://a/b/c/d;p?q#s"), url.resolve("#s")) 188 | assertEquals(parse("http://a/b/c/g#s"), url.resolve("g#s")) 189 | assertEquals(parse("http://a/b/c/g?y#s"), url.resolve("g?y#s")) 190 | assertEquals(parse("http://a/b/c/;x"), url.resolve(";x")) 191 | assertEquals(parse("http://a/b/c/g;x"), url.resolve("g;x")) 192 | assertEquals(parse("http://a/b/c/g;x?y#s"), url.resolve("g;x?y#s")) 193 | assertEquals(parse("http://a/b/c/d;p?q"), url.resolve("")) 194 | assertEquals(parse("http://a/b/c/"), url.resolve(".")) 195 | assertEquals(parse("http://a/b/c/"), url.resolve("./")) 196 | assertEquals(parse("http://a/b/"), url.resolve("..")) 197 | assertEquals(parse("http://a/b/"), url.resolve("../")) 198 | assertEquals(parse("http://a/b/g"), url.resolve("../g")) 199 | assertEquals(parse("http://a/"), url.resolve("../..")) 200 | assertEquals(parse("http://a/"), url.resolve("../../")) 201 | assertEquals(parse("http://a/g"), url.resolve("../../g")) 202 | } 203 | 204 | /** https://tools.ietf.org/html/rfc3986#section-5.4.2 */ 205 | @Test 206 | fun rfc3886AbnormalExamples() { 207 | val url = DeepLinkUri.parse("http://a/b/c/d;p?q") 208 | assertEquals(parse("http://a/g"), url.resolve("../../../g")) 209 | assertEquals(parse("http://a/g"), url.resolve("../../../../g")) 210 | assertEquals(parse("http://a/g"), url.resolve("/./g")) 211 | assertEquals(parse("http://a/g"), url.resolve("/../g")) 212 | assertEquals(parse("http://a/b/c/g."), url.resolve("g.")) 213 | assertEquals(parse("http://a/b/c/.g"), url.resolve(".g")) 214 | assertEquals(parse("http://a/b/c/g.."), url.resolve("g..")) 215 | assertEquals(parse("http://a/b/c/..g"), url.resolve("..g")) 216 | assertEquals(parse("http://a/b/g"), url.resolve("./../g")) 217 | assertEquals(parse("http://a/b/c/g/"), url.resolve("./g/.")) 218 | assertEquals(parse("http://a/b/c/g/h"), url.resolve("g/./h")) 219 | assertEquals(parse("http://a/b/c/h"), url.resolve("g/../h")) 220 | assertEquals(parse("http://a/b/c/g;x=1/y"), url.resolve("g;x=1/./y")) 221 | assertEquals(parse("http://a/b/c/y"), url.resolve("g;x=1/../y")) 222 | assertEquals(parse("http://a/b/c/g?y/./x"), url.resolve("g?y/./x")) 223 | assertEquals(parse("http://a/b/c/g?y/../x"), url.resolve("g?y/../x")) 224 | assertEquals(parse("http://a/b/c/g#s/./x"), url.resolve("g#s/./x")) 225 | assertEquals(parse("http://a/b/c/g#s/../x"), url.resolve("g#s/../x")) 226 | assertEquals(parse("http://a/b/c/g"), url.resolve("http:g")) // "http:g" also okay. 227 | } 228 | 229 | @Test 230 | fun parseAuthoritySlashCountDoesntMatter() { 231 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:host/path")) 232 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:/host/path")) 233 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:\\host/path")) 234 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http://host/path")) 235 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:\\/host/path")) 236 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:/\\host/path")) 237 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:\\\\host/path")) 238 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:///host/path")) 239 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:\\//host/path")) 240 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:/\\/host/path")) 241 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http://\\host/path")) 242 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:\\\\/host/path")) 243 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:/\\\\host/path")) 244 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:\\\\\\host/path")) 245 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http:////host/path")) 246 | } 247 | 248 | @Test 249 | fun resolveAuthoritySlashCountDoesntMatterWithDifferentScheme() { 250 | val base = DeepLinkUri.parse("https://a/b/c") 251 | assertEquals(parse("http://host/path"), base.resolve("http:host/path")) 252 | assertEquals(parse("http://host/path"), base.resolve("http:/host/path")) 253 | assertEquals(parse("http://host/path"), base.resolve("http:\\host/path")) 254 | assertEquals(parse("http://host/path"), base.resolve("http://host/path")) 255 | assertEquals(parse("http://host/path"), base.resolve("http:\\/host/path")) 256 | assertEquals(parse("http://host/path"), base.resolve("http:/\\host/path")) 257 | assertEquals(parse("http://host/path"), base.resolve("http:\\\\host/path")) 258 | assertEquals(parse("http://host/path"), base.resolve("http:///host/path")) 259 | assertEquals(parse("http://host/path"), base.resolve("http:\\//host/path")) 260 | assertEquals(parse("http://host/path"), base.resolve("http:/\\/host/path")) 261 | assertEquals(parse("http://host/path"), base.resolve("http://\\host/path")) 262 | assertEquals(parse("http://host/path"), base.resolve("http:\\\\/host/path")) 263 | assertEquals(parse("http://host/path"), base.resolve("http:/\\\\host/path")) 264 | assertEquals(parse("http://host/path"), base.resolve("http:\\\\\\host/path")) 265 | assertEquals(parse("http://host/path"), base.resolve("http:////host/path")) 266 | } 267 | 268 | @Test 269 | fun resolveAuthoritySlashCountMattersWithSameScheme() { 270 | val base = DeepLinkUri.parse("http://a/b/c") 271 | assertEquals(parse("http://a/b/host/path"), base.resolve("http:host/path")) 272 | assertEquals(parse("http://a/host/path"), base.resolve("http:/host/path")) 273 | assertEquals(parse("http://a/host/path"), base.resolve("http:\\host/path")) 274 | assertEquals(parse("http://host/path"), base.resolve("http://host/path")) 275 | assertEquals(parse("http://host/path"), base.resolve("http:\\/host/path")) 276 | assertEquals(parse("http://host/path"), base.resolve("http:/\\host/path")) 277 | assertEquals(parse("http://host/path"), base.resolve("http:\\\\host/path")) 278 | assertEquals(parse("http://host/path"), base.resolve("http:///host/path")) 279 | assertEquals(parse("http://host/path"), base.resolve("http:\\//host/path")) 280 | assertEquals(parse("http://host/path"), base.resolve("http:/\\/host/path")) 281 | assertEquals(parse("http://host/path"), base.resolve("http://\\host/path")) 282 | assertEquals(parse("http://host/path"), base.resolve("http:\\\\/host/path")) 283 | assertEquals(parse("http://host/path"), base.resolve("http:/\\\\host/path")) 284 | assertEquals(parse("http://host/path"), base.resolve("http:\\\\\\host/path")) 285 | assertEquals(parse("http://host/path"), base.resolve("http:////host/path")) 286 | } 287 | 288 | @Test 289 | fun username() { 290 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http://@host/path")) 291 | assertEquals(parse("http://user@host/path"), DeepLinkUri.parse("http://user@host/path")) 292 | } 293 | 294 | /** Given multiple '@' characters, the last one is the delimiter. */ 295 | @Test 296 | fun authorityWithMultipleAtSigns() { 297 | val deepLinkUri = DeepLinkUri.parse("http://foo@bar@baz/path") 298 | assertEquals("foo@bar", deepLinkUri.username()) 299 | assertEquals("", deepLinkUri.password()) 300 | assertEquals(parse("http://foo%40bar@baz/path"), deepLinkUri) 301 | } 302 | 303 | /** Given multiple ':' characters, the first one is the delimiter. */ 304 | @Test 305 | fun authorityWithMultipleColons() { 306 | val deepLinkUri = DeepLinkUri.parse("http://foo:pass1@bar:pass2@baz/path") 307 | assertEquals("foo", deepLinkUri.username()) 308 | assertEquals("pass1@bar:pass2", deepLinkUri.password()) 309 | assertEquals(parse("http://foo:pass1%40bar%3Apass2@baz/path"), deepLinkUri) 310 | } 311 | 312 | @Test 313 | fun usernameAndPassword() { 314 | assertEquals( 315 | parse("http://username:password@host/path"), 316 | DeepLinkUri.parse("http://username:password@host/path") 317 | ) 318 | assertEquals( 319 | parse("http://username@host/path"), 320 | DeepLinkUri.parse("http://username:@host/path") 321 | ) 322 | } 323 | 324 | @Test 325 | fun passwordWithEmptyUsername() { 326 | // Chrome doesn't mind, but Firefox rejects URLs with empty usernames and non-empty passwords. 327 | assertEquals(parse("http://host/path"), DeepLinkUri.parse("http://:@host/path")) 328 | assertEquals("password%40", DeepLinkUri.parse("http://:password@@host/path").encodedPassword()) 329 | } 330 | 331 | @Test 332 | fun unprintableCharactersArePercentEncoded() { 333 | assertEquals("/%00", DeepLinkUri.parse("http://host/\u0000").encodedPath()) 334 | assertEquals("/%08", DeepLinkUri.parse("http://host/\u0008").encodedPath()) 335 | assertEquals("/%EF%BF%BD", DeepLinkUri.parse("http://host/\ufffd").encodedPath()) 336 | } 337 | 338 | @Test 339 | fun hostContainsIllegalCharacter() { 340 | assertInvalid("http://\n/", "Invalid URL host: \"\n\"") 341 | assertInvalid("http:// /", "Invalid URL host: \" \"") 342 | assertInvalid("http://%20/", "Invalid URL host: \"%20\"") 343 | } 344 | 345 | @Test 346 | fun hostnameLowercaseCharactersMappedDirectly() { 347 | assertEquals("abcd", DeepLinkUri.parse("http://abcd").host()) 348 | assertEquals("xn--4xa", DeepLinkUri.parse("http://σ").host()) 349 | } 350 | 351 | @Test 352 | fun hostnameUppercaseCharactersConvertedToLowercase() { 353 | assertEquals("abcd", DeepLinkUri.parse("http://ABCD").host()) 354 | assertEquals("xn--4xa", DeepLinkUri.parse("http://Σ").host()) 355 | } 356 | 357 | @Test 358 | fun hostnameIgnoredCharacters() { 359 | // The soft hyphen (­) should be ignored. 360 | assertEquals("abcd", DeepLinkUri.parse("http://AB\u00adCD").host()) 361 | } 362 | 363 | @Test 364 | fun hostnameMultipleCharacterMapping() { 365 | // Map the single character telephone symbol (℡) to the string "tel". 366 | assertEquals("tel", DeepLinkUri.parse("http://\u2121").host()) 367 | } 368 | 369 | @Test 370 | fun hostnameMappingLastMappedCodePoint() { 371 | assertEquals("xn--pu5l", DeepLinkUri.parse("http://\uD87E\uDE1D").host()) 372 | } 373 | 374 | @Ignore("The java.net.IDN implementation doesn't ignore characters that it should.") 375 | @Test 376 | fun hostnameMappingLastIgnoredCodePoint() { 377 | assertEquals("abcd", DeepLinkUri.parse("http://ab\uDB40\uDDEFcd").host()) 378 | } 379 | 380 | @Test 381 | fun hostnameMappingLastDisallowedCodePoint() { 382 | assertInvalid("http://\uDBFF\uDFFF", "Invalid URL host: \"\uDBFF\uDFFF\"") 383 | } 384 | 385 | @Test 386 | fun hostIpv6() { 387 | // Square braces are absent from host()... 388 | assertEquals("::1", DeepLinkUri.parse("http://[::1]/").host()) 389 | 390 | // ... but they're included in toString(). 391 | assertEquals("http://[::1]/", DeepLinkUri.parse("http://[::1]/").toString()) 392 | 393 | // IPv6 colons don't interfere with port numbers or passwords. 394 | assertEquals(8080, DeepLinkUri.parse("http://[::1]:8080/").port().toLong()) 395 | assertEquals("password", DeepLinkUri.parse("http://user:password@[::1]/").password()) 396 | assertEquals("::1", DeepLinkUri.parse("http://user:password@[::1]:8080/").host()) 397 | 398 | // Permit the contents of IPv6 addresses to be percent-encoded... 399 | assertEquals("::1", DeepLinkUri.parse("http://[%3A%3A%31]/").host()) 400 | 401 | // Including the Square braces themselves! (This is what Chrome does.) 402 | assertEquals("::1", DeepLinkUri.parse("http://%5B%3A%3A1%5D/").host()) 403 | } 404 | 405 | @Test 406 | fun hostIpv6AddressDifferentFormats() { 407 | // Multiple representations of the same address; see http://tools.ietf.org/html/rfc5952. 408 | val a3 = "2001:db8::1:0:0:1" 409 | assertEquals(a3, DeepLinkUri.parse("http://[2001:db8:0:0:1:0:0:1]").host()) 410 | assertEquals(a3, DeepLinkUri.parse("http://[2001:0db8:0:0:1:0:0:1]").host()) 411 | assertEquals(a3, DeepLinkUri.parse("http://[2001:db8::1:0:0:1]").host()) 412 | assertEquals(a3, DeepLinkUri.parse("http://[2001:db8::0:1:0:0:1]").host()) 413 | assertEquals(a3, DeepLinkUri.parse("http://[2001:0db8::1:0:0:1]").host()) 414 | assertEquals(a3, DeepLinkUri.parse("http://[2001:db8:0:0:1::1]").host()) 415 | assertEquals(a3, DeepLinkUri.parse("http://[2001:db8:0000:0:1::1]").host()) 416 | assertEquals(a3, DeepLinkUri.parse("http://[2001:DB8:0:0:1::1]").host()) 417 | } 418 | 419 | @Test 420 | fun hostIpv6AddressLeadingCompression() { 421 | assertEquals("::1", DeepLinkUri.parse("http://[::0001]").host()) 422 | assertEquals("::1", DeepLinkUri.parse("http://[0000::0001]").host()) 423 | assertEquals("::1", DeepLinkUri.parse("http://[0000:0000:0000:0000:0000:0000:0000:0001]").host()) 424 | assertEquals("::1", DeepLinkUri.parse("http://[0000:0000:0000:0000:0000:0000::0001]").host()) 425 | } 426 | 427 | @Test 428 | fun hostIpv6AddressTrailingCompression() { 429 | assertEquals("1::", DeepLinkUri.parse("http://[0001:0000::]").host()) 430 | assertEquals("1::", DeepLinkUri.parse("http://[0001::0000]").host()) 431 | assertEquals("1::", DeepLinkUri.parse("http://[0001::]").host()) 432 | assertEquals("1::", DeepLinkUri.parse("http://[1::]").host()) 433 | } 434 | 435 | @Test 436 | fun hostIpv6AddressTooManyDigitsInGroup() { 437 | assertInvalid( 438 | "http://[00000:0000:0000:0000:0000:0000:0000:0001]", 439 | "Invalid URL host: \"[00000:0000:0000:0000:0000:0000:0000:0001]\"" 440 | ) 441 | assertInvalid("http://[::00001]", "Invalid URL host: \"[::00001]\"") 442 | } 443 | 444 | @Test 445 | fun hostIpv6AddressMisplacedColons() { 446 | assertInvalid( 447 | "http://[:0000:0000:0000:0000:0000:0000:0000:0001]", 448 | "Invalid URL host: \"[:0000:0000:0000:0000:0000:0000:0000:0001]\"" 449 | ) 450 | assertInvalid( 451 | "http://[:::0000:0000:0000:0000:0000:0000:0000:0001]", 452 | "Invalid URL host: \"[:::0000:0000:0000:0000:0000:0000:0000:0001]\"" 453 | ) 454 | assertInvalid("http://[:1]", "Invalid URL host: \"[:1]\"") 455 | assertInvalid("http://[:::1]", "Invalid URL host: \"[:::1]\"") 456 | assertInvalid( 457 | "http://[0000:0000:0000:0000:0000:0000:0001:]", 458 | "Invalid URL host: \"[0000:0000:0000:0000:0000:0000:0001:]\"" 459 | ) 460 | assertInvalid( 461 | "http://[0000:0000:0000:0000:0000:0000:0000:0001:]", 462 | "Invalid URL host: \"[0000:0000:0000:0000:0000:0000:0000:0001:]\"" 463 | ) 464 | assertInvalid( 465 | "http://[0000:0000:0000:0000:0000:0000:0000:0001::]", 466 | "Invalid URL host: \"[0000:0000:0000:0000:0000:0000:0000:0001::]\"" 467 | ) 468 | assertInvalid( 469 | "http://[0000:0000:0000:0000:0000:0000:0000:0001:::]", 470 | "Invalid URL host: \"[0000:0000:0000:0000:0000:0000:0000:0001:::]\"" 471 | ) 472 | assertInvalid("http://[1:]", "Invalid URL host: \"[1:]\"") 473 | assertInvalid("http://[1:::]", "Invalid URL host: \"[1:::]\"") 474 | assertInvalid("http://[1:::1]", "Invalid URL host: \"[1:::1]\"") 475 | assertInvalid( 476 | "http://[0000:0000:0000:0000::0000:0000:0000:0001]", 477 | "Invalid URL host: \"[0000:0000:0000:0000::0000:0000:0000:0001]\"" 478 | ) 479 | } 480 | 481 | @Test 482 | fun hostIpv6AddressTooManyGroups() { 483 | assertInvalid( 484 | "http://[0000:0000:0000:0000:0000:0000:0000:0000:0001]", 485 | "Invalid URL host: \"[0000:0000:0000:0000:0000:0000:0000:0000:0001]\"" 486 | ) 487 | } 488 | 489 | @Test 490 | fun hostIpv6AddressTooMuchCompression() { 491 | assertInvalid( 492 | "http://[0000::0000:0000:0000:0000::0001]", 493 | "Invalid URL host: \"[0000::0000:0000:0000:0000::0001]\"" 494 | ) 495 | assertInvalid( 496 | "http://[::0000:0000:0000:0000::0001]", 497 | "Invalid URL host: \"[::0000:0000:0000:0000::0001]\"" 498 | ) 499 | } 500 | 501 | @Test 502 | fun hostIpv6ScopedAddress() { 503 | // java.net.InetAddress parses scoped addresses. These aren't valid in URLs. 504 | assertInvalid("http://[::1%2544]", "Invalid URL host: \"[::1%2544]\"") 505 | } 506 | 507 | @Test 508 | fun hostIpv6AddressTooManyLeadingZeros() { 509 | // Guava's been buggy on this case. https://github.com/google/guava/issues/3116 510 | assertInvalid( 511 | "http://[2001:db8:0:0:1:0:0:00001]", 512 | "Invalid URL host: \"[2001:db8:0:0:1:0:0:00001]\"" 513 | ) 514 | } 515 | 516 | @Test 517 | fun hostIpv6WithIpv4Suffix() { 518 | assertEquals("::1:ffff:ffff", DeepLinkUri.parse("http://[::1:255.255.255.255]/").host()) 519 | assertEquals("::1:0:0", DeepLinkUri.parse("http://[0:0:0:0:0:1:0.0.0.0]/").host()) 520 | } 521 | 522 | @Test 523 | fun hostIpv6WithIpv4SuffixWithOctalPrefix() { 524 | // Chrome interprets a leading '0' as octal; Firefox rejects them. (We reject them.) 525 | assertInvalid( 526 | "http://[0:0:0:0:0:1:0.0.0.000000]/", 527 | "Invalid URL host: \"[0:0:0:0:0:1:0.0.0.000000]\"" 528 | ) 529 | assertInvalid( 530 | "http://[0:0:0:0:0:1:0.010.0.010]/", 531 | "Invalid URL host: \"[0:0:0:0:0:1:0.010.0.010]\"" 532 | ) 533 | assertInvalid( 534 | "http://[0:0:0:0:0:1:0.0.0.000001]/", 535 | "Invalid URL host: \"[0:0:0:0:0:1:0.0.0.000001]\"" 536 | ) 537 | } 538 | 539 | @Test 540 | fun hostIpv6WithIpv4SuffixWithHexadecimalPrefix() { 541 | // Chrome interprets a leading '0x' as hexadecimal; Firefox rejects them. (We reject them.) 542 | assertInvalid( 543 | "http://[0:0:0:0:0:1:0.0x10.0.0x10]/", 544 | "Invalid URL host: \"[0:0:0:0:0:1:0.0x10.0.0x10]\"" 545 | ) 546 | } 547 | 548 | @Test 549 | fun hostIpv6WithMalformedIpv4Suffix() { 550 | assertInvalid("http://[0:0:0:0:0:1:0.0:0.0]/", "Invalid URL host: \"[0:0:0:0:0:1:0.0:0.0]\"") 551 | assertInvalid("http://[0:0:0:0:0:1:0.0-0.0]/", "Invalid URL host: \"[0:0:0:0:0:1:0.0-0.0]\"") 552 | assertInvalid( 553 | "http://[0:0:0:0:0:1:.255.255.255]/", 554 | "Invalid URL host: \"[0:0:0:0:0:1:.255.255.255]\"" 555 | ) 556 | assertInvalid( 557 | "http://[0:0:0:0:0:1:255..255.255]/", 558 | "Invalid URL host: \"[0:0:0:0:0:1:255..255.255]\"" 559 | ) 560 | assertInvalid( 561 | "http://[0:0:0:0:0:1:255.255..255]/", 562 | "Invalid URL host: \"[0:0:0:0:0:1:255.255..255]\"" 563 | ) 564 | assertInvalid( 565 | "http://[0:0:0:0:0:0:1:255.255]/", 566 | "Invalid URL host: \"[0:0:0:0:0:0:1:255.255]\"" 567 | ) 568 | assertInvalid( 569 | "http://[0:0:0:0:0:1:256.255.255.255]/", 570 | "Invalid URL host: \"[0:0:0:0:0:1:256.255.255.255]\"" 571 | ) 572 | assertInvalid( 573 | "http://[0:0:0:0:0:1:ff.255.255.255]/", 574 | "Invalid URL host: \"[0:0:0:0:0:1:ff.255.255.255]\"" 575 | ) 576 | assertInvalid( 577 | "http://[0:0:0:0:0:0:1:255.255.255.255]/", 578 | "Invalid URL host: \"[0:0:0:0:0:0:1:255.255.255.255]\"" 579 | ) 580 | assertInvalid( 581 | "http://[0:0:0:0:1:255.255.255.255]/", 582 | "Invalid URL host: \"[0:0:0:0:1:255.255.255.255]\"" 583 | ) 584 | assertInvalid("http://[0:0:0:0:1:0.0.0.0:1]/", "Invalid URL host: \"[0:0:0:0:1:0.0.0.0:1]\"") 585 | assertInvalid( 586 | "http://[0:0.0.0.0:1:0:0:0:0:1]/", 587 | "Invalid URL host: \"[0:0.0.0.0:1:0:0:0:0:1]\"" 588 | ) 589 | assertInvalid("http://[0.0.0.0:0:0:0:0:0:1]/", "Invalid URL host: \"[0.0.0.0:0:0:0:0:0:1]\"") 590 | } 591 | 592 | @Test 593 | fun hostIpv6WithIncompleteIpv4Suffix() { 594 | // To Chrome & Safari these are well-formed; Firefox disagrees. (We're consistent with Firefox). 595 | assertInvalid( 596 | "http://[0:0:0:0:0:1:255.255.255.]/", 597 | "Invalid URL host: \"[0:0:0:0:0:1:255.255.255.]\"" 598 | ) 599 | assertInvalid( 600 | "http://[0:0:0:0:0:1:255.255.255]/", 601 | "Invalid URL host: \"[0:0:0:0:0:1:255.255.255]\"" 602 | ) 603 | } 604 | 605 | @Test 606 | fun hostIpv6Malformed() { 607 | assertInvalid("http://[::g]/", "Invalid URL host: \"[::g]\"") 608 | } 609 | 610 | @Test 611 | fun hostIpv6CanonicalForm() { 612 | assertEquals( 613 | "abcd:ef01:2345:6789:abcd:ef01:2345:6789", 614 | DeepLinkUri.parse("http://[abcd:ef01:2345:6789:abcd:ef01:2345:6789]/").host() 615 | ) 616 | assertEquals("a::b:0:0:0", DeepLinkUri.parse("http://[a:0:0:0:b:0:0:0]/").host()) 617 | assertEquals("a:b:0:0:c::", DeepLinkUri.parse("http://[a:b:0:0:c:0:0:0]/").host()) 618 | assertEquals("a:b::c:0:0", DeepLinkUri.parse("http://[a:b:0:0:0:c:0:0]/").host()) 619 | assertEquals("a::b:0:0:0", DeepLinkUri.parse("http://[a:0:0:0:b:0:0:0]/").host()) 620 | assertEquals("::a:b:0:0:0", DeepLinkUri.parse("http://[0:0:0:a:b:0:0:0]/").host()) 621 | assertEquals("::a:0:0:0:b", DeepLinkUri.parse("http://[0:0:0:a:0:0:0:b]/").host()) 622 | assertEquals("0:a:b:c:d:e:f:1", DeepLinkUri.parse("http://[0:a:b:c:d:e:f:1]/").host()) 623 | assertEquals("a:b:c:d:e:f:1:0", DeepLinkUri.parse("http://[a:b:c:d:e:f:1:0]/").host()) 624 | assertEquals("ff01::101", DeepLinkUri.parse("http://[FF01:0:0:0:0:0:0:101]/").host()) 625 | assertEquals("2001:db8::1", DeepLinkUri.parse("http://[2001:db8::1]/").host()) 626 | assertEquals("2001:db8::2:1", DeepLinkUri.parse("http://[2001:db8:0:0:0:0:2:1]/").host()) 627 | assertEquals("2001:db8:0:1:1:1:1:1", DeepLinkUri.parse("http://[2001:db8:0:1:1:1:1:1]/").host()) 628 | assertEquals("2001:db8::1:0:0:1", DeepLinkUri.parse("http://[2001:db8:0:0:1:0:0:1]/").host()) 629 | assertEquals("2001:0:0:1::1", DeepLinkUri.parse("http://[2001:0:0:1:0:0:0:1]/").host()) 630 | assertEquals("1::", DeepLinkUri.parse("http://[1:0:0:0:0:0:0:0]/").host()) 631 | assertEquals("::1", DeepLinkUri.parse("http://[0:0:0:0:0:0:0:1]/").host()) 632 | assertEquals("::", DeepLinkUri.parse("http://[0:0:0:0:0:0:0:0]/").host()) 633 | assertEquals("192.168.1.254", DeepLinkUri.parse("http://[::ffff:c0a8:1fe]/").host()) 634 | } 635 | 636 | /** The builder permits square braces but does not require them. */ 637 | @Test 638 | fun hostIpv6Builder() { 639 | val base = DeepLinkUri.parse("http://example.com/") 640 | assertEquals("http://[::1]/", base.newBuilder().host("[::1]").build().toString()) 641 | assertEquals("http://[::1]/", base.newBuilder().host("[::0001]").build().toString()) 642 | assertEquals("http://[::1]/", base.newBuilder().host("::1").build().toString()) 643 | assertEquals("http://[::1]/", base.newBuilder().host("::0001").build().toString()) 644 | } 645 | 646 | @Test 647 | fun hostIpv4CanonicalForm() { 648 | assertEquals("255.255.255.255", DeepLinkUri.parse("http://255.255.255.255/").host()) 649 | assertEquals("1.2.3.4", DeepLinkUri.parse("http://1.2.3.4/").host()) 650 | assertEquals("0.0.0.0", DeepLinkUri.parse("http://0.0.0.0/").host()) 651 | } 652 | 653 | @Test 654 | fun hostWithTrailingDot() { 655 | assertEquals("host.", DeepLinkUri.parse("http://host./").host()) 656 | } 657 | 658 | @Test 659 | fun port() { 660 | assertEquals(parse("http://host/"), DeepLinkUri.parse("http://host:80/")) 661 | assertEquals(parse("http://host:99/"), DeepLinkUri.parse("http://host:99/")) 662 | assertEquals(parse("http://host/"), DeepLinkUri.parse("http://host:/")) 663 | assertEquals(65535, DeepLinkUri.parse("http://host:65535/").port().toLong()) 664 | assertInvalid("http://host:0/", "Invalid URL port: \"0\"") 665 | assertInvalid("http://host:65536/", "Invalid URL port: \"65536\"") 666 | assertInvalid("http://host:-1/", "Invalid URL port: \"-1\"") 667 | assertInvalid("http://host:a/", "Invalid URL port: \"a\"") 668 | assertInvalid("http://host:%39%39/", "Invalid URL port: \"%39%39\"") 669 | } 670 | 671 | @Test 672 | fun fragmentNonAscii() { 673 | val url = DeepLinkUri.parse("http://host/#Σ") 674 | assertEquals("http://host/#Σ", url.toString()) 675 | assertEquals("Σ", url.fragment()) 676 | assertEquals("Σ", url.encodedFragment()) 677 | assertEquals("http://host/#Σ", url.uri().toString()) 678 | } 679 | 680 | @Test 681 | fun fragmentNonAsciiThatOffendsJavaNetUri() { 682 | val url = DeepLinkUri.parse("http://host/#\u0080") 683 | assertEquals("http://host/#\u0080", url.toString()) 684 | assertEquals("\u0080", url.fragment()) 685 | assertEquals("\u0080", url.encodedFragment()) 686 | assertEquals(URI("http://host/#"), url.uri()) // Control characters may be stripped! 687 | } 688 | 689 | @Test 690 | fun fragmentPercentEncodedNonAscii() { 691 | val url = DeepLinkUri.parse("http://host/#%C2%80") 692 | assertEquals("http://host/#%C2%80", url.toString()) 693 | assertEquals("\u0080", url.fragment()) 694 | assertEquals("%C2%80", url.encodedFragment()) 695 | assertEquals("http://host/#%C2%80", url.uri().toString()) 696 | } 697 | 698 | @Test 699 | fun fragmentPercentEncodedPartialCodePoint() { 700 | val url = DeepLinkUri.parse("http://host/#%80") 701 | assertEquals("http://host/#%80", url.toString()) 702 | assertEquals("\ufffd", url.fragment()) // Unicode replacement character. 703 | assertEquals("%80", url.encodedFragment()) 704 | assertEquals("http://host/#%80", url.uri().toString()) 705 | } 706 | 707 | @Test 708 | fun relativePath() { 709 | val base = DeepLinkUri.parse("http://host/a/b/c") 710 | assertEquals(parse("http://host/a/b/d/e/f"), base.resolve("d/e/f")) 711 | assertEquals(parse("http://host/d/e/f"), base.resolve("../../d/e/f")) 712 | assertEquals(parse("http://host/a/"), base.resolve("..")) 713 | assertEquals(parse("http://host/"), base.resolve("../..")) 714 | assertEquals(parse("http://host/"), base.resolve("../../..")) 715 | assertEquals(parse("http://host/a/b/"), base.resolve(".")) 716 | assertEquals(parse("http://host/a/"), base.resolve("././..")) 717 | assertEquals(parse("http://host/a/b/c/"), base.resolve("c/d/../e/../")) 718 | assertEquals(parse("http://host/a/b/..e/"), base.resolve("..e/")) 719 | assertEquals(parse("http://host/a/b/e/f../"), base.resolve("e/f../")) 720 | assertEquals(parse("http://host/a/"), base.resolve("%2E.")) 721 | assertEquals(parse("http://host/a/"), base.resolve(".%2E")) 722 | assertEquals(parse("http://host/a/"), base.resolve("%2E%2E")) 723 | assertEquals(parse("http://host/a/"), base.resolve("%2e.")) 724 | assertEquals(parse("http://host/a/"), base.resolve(".%2e")) 725 | assertEquals(parse("http://host/a/"), base.resolve("%2e%2e")) 726 | assertEquals(parse("http://host/a/b/"), base.resolve("%2E")) 727 | assertEquals(parse("http://host/a/b/"), base.resolve("%2e")) 728 | } 729 | 730 | @Test 731 | fun relativePathWithTrailingSlash() { 732 | val base = DeepLinkUri.parse("http://host/a/b/c/") 733 | assertEquals(parse("http://host/a/b/"), base.resolve("..")) 734 | assertEquals(parse("http://host/a/b/"), base.resolve("../")) 735 | assertEquals(parse("http://host/a/"), base.resolve("../..")) 736 | assertEquals(parse("http://host/a/"), base.resolve("../../")) 737 | assertEquals(parse("http://host/"), base.resolve("../../..")) 738 | assertEquals(parse("http://host/"), base.resolve("../../../")) 739 | assertEquals(parse("http://host/"), base.resolve("../../../..")) 740 | assertEquals(parse("http://host/"), base.resolve("../../../../")) 741 | assertEquals(parse("http://host/a"), base.resolve("../../../../a")) 742 | assertEquals(parse("http://host/"), base.resolve("../../../../a/..")) 743 | assertEquals(parse("http://host/a/"), base.resolve("../../../../a/b/..")) 744 | } 745 | 746 | @Test 747 | fun pathWithBackslash() { 748 | val base = DeepLinkUri.parse("http://host/a/b/c") 749 | assertEquals(parse("http://host/a/b/d/e/f"), base.resolve("d\\e\\f")) 750 | assertEquals(parse("http://host/d/e/f"), base.resolve("../..\\d\\e\\f")) 751 | assertEquals(parse("http://host/"), base.resolve("..\\..")) 752 | } 753 | 754 | @Test 755 | fun relativePathWithSameScheme() { 756 | val base = DeepLinkUri.parse("http://host/a/b/c") 757 | assertEquals(parse("http://host/a/b/d/e/f"), base.resolve("http:d/e/f")) 758 | assertEquals(parse("http://host/d/e/f"), base.resolve("http:../../d/e/f")) 759 | } 760 | 761 | @Test 762 | fun decodeUsername() { 763 | assertEquals("user", DeepLinkUri.parse("http://user@host/").username()) 764 | assertEquals("\uD83C\uDF69", DeepLinkUri.parse("http://%F0%9F%8D%A9@host/").username()) 765 | } 766 | 767 | @Test 768 | fun decodePassword() { 769 | assertEquals("password", DeepLinkUri.parse("http://user:password@host/").password()) 770 | assertEquals("", DeepLinkUri.parse("http://user:@host/").password()) 771 | assertEquals("\uD83C\uDF69", DeepLinkUri.parse("http://user:%F0%9F%8D%A9@host/").password()) 772 | } 773 | 774 | @Test 775 | fun decodeSlashCharacterInDecodedPathSegment() { 776 | assertEquals( 777 | Arrays.asList("a/b/c"), 778 | DeepLinkUri.parse("http://host/a%2Fb%2Fc").pathSegments() 779 | ) 780 | } 781 | 782 | @Test 783 | fun decodeEmptyPathSegments() { 784 | assertEquals( 785 | Arrays.asList(""), 786 | DeepLinkUri.parse("http://host/").pathSegments() 787 | ) 788 | } 789 | 790 | @Test 791 | fun percentDecode() { 792 | assertEquals( 793 | Arrays.asList("\u0000"), 794 | DeepLinkUri.parse("http://host/%00").pathSegments() 795 | ) 796 | assertEquals( 797 | Arrays.asList("a", "\u2603", "c"), 798 | DeepLinkUri.parse("http://host/a/%E2%98%83/c").pathSegments() 799 | ) 800 | assertEquals( 801 | Arrays.asList("a", "\uD83C\uDF69", "c"), 802 | DeepLinkUri.parse("http://host/a/%F0%9F%8D%A9/c").pathSegments() 803 | ) 804 | assertEquals( 805 | Arrays.asList("a", "b", "c"), 806 | DeepLinkUri.parse("http://host/a/%62/c").pathSegments() 807 | ) 808 | assertEquals( 809 | Arrays.asList("a", "z", "c"), 810 | DeepLinkUri.parse("http://host/a/%7A/c").pathSegments() 811 | ) 812 | assertEquals( 813 | Arrays.asList("a", "z", "c"), 814 | DeepLinkUri.parse("http://host/a/%7a/c").pathSegments() 815 | ) 816 | } 817 | 818 | @Test 819 | fun malformedPercentEncoding() { 820 | assertEquals( 821 | Arrays.asList("a%f", "b"), 822 | DeepLinkUri.parse("http://host/a%f/b").pathSegments() 823 | ) 824 | assertEquals( 825 | Arrays.asList("%", "b"), 826 | DeepLinkUri.parse("http://host/%/b").pathSegments() 827 | ) 828 | assertEquals( 829 | Arrays.asList("%"), 830 | DeepLinkUri.parse("http://host/%").pathSegments() 831 | ) 832 | assertEquals( 833 | Arrays.asList("%00"), 834 | DeepLinkUri.parse("http://github.com/%%30%30").pathSegments() 835 | ) 836 | } 837 | 838 | @Test 839 | fun malformedUtf8Encoding() { 840 | // Replace a partial UTF-8 sequence with the Unicode replacement character. 841 | assertEquals( 842 | Arrays.asList("a", "\ufffdx", "c"), 843 | DeepLinkUri.parse("http://host/a/%E2%98x/c").pathSegments() 844 | ) 845 | } 846 | 847 | @Test 848 | fun incompleteUrlComposition() { 849 | try { 850 | DeepLinkUri.Builder().scheme("http").build() 851 | fail() 852 | } catch (expected: IllegalStateException) { 853 | assertEquals("host == null", expected.message) 854 | } 855 | 856 | try { 857 | DeepLinkUri.Builder().host("host").build() 858 | fail() 859 | } catch (expected: IllegalStateException) { 860 | assertEquals("scheme == null", expected.message) 861 | } 862 | 863 | } 864 | 865 | @Test 866 | fun builderToString() { 867 | assertEquals("https://host.com/path", DeepLinkUri.parse("https://host.com/path").newBuilder().toString()) 868 | } 869 | 870 | @Test 871 | fun incompleteBuilderToString() { 872 | assertEquals( 873 | "https:///path", 874 | DeepLinkUri.Builder().scheme("https").encodedPath("/path").toString() 875 | ) 876 | assertEquals( 877 | "//host.com/path", 878 | DeepLinkUri.Builder().host("host.com").encodedPath("/path").toString() 879 | ) 880 | assertEquals( 881 | "//host.com:8080/path", 882 | DeepLinkUri.Builder().host("host.com").encodedPath("/path").port(8080).toString() 883 | ) 884 | } 885 | 886 | @Test 887 | fun minimalUrlComposition() { 888 | val url = DeepLinkUri.Builder().scheme("http").host("host").build() 889 | assertEquals("http://host/", url.toString()) 890 | assertEquals("http", url.scheme()) 891 | assertEquals("", url.username()) 892 | assertEquals("", url.password()) 893 | assertEquals("host", url.host()) 894 | assertEquals(80, url.port().toLong()) 895 | assertEquals("/", url.encodedPath()) 896 | assertNull(url.query()) 897 | assertNull(url.fragment()) 898 | } 899 | 900 | @Test 901 | fun fullUrlComposition() { 902 | val url = DeepLinkUri.Builder() 903 | .scheme("http") 904 | .username("username") 905 | .password("password") 906 | .host("host") 907 | .port(8080) 908 | .addPathSegment("path") 909 | .query("query") 910 | .fragment("fragment") 911 | .build() 912 | assertEquals("http://username:password@host:8080/path?query#fragment", url.toString()) 913 | assertEquals("http", url.scheme()) 914 | assertEquals("username", url.username()) 915 | assertEquals("password", url.password()) 916 | assertEquals("host", url.host()) 917 | assertEquals(8080, url.port().toLong()) 918 | assertEquals("/path", url.encodedPath()) 919 | assertEquals("query", url.query()) 920 | assertEquals("fragment", url.fragment()) 921 | } 922 | 923 | @Test 924 | fun changingSchemeChangesDefaultPort() { 925 | assertEquals( 926 | 443, DeepLinkUri.parse("http://example.com") 927 | .newBuilder() 928 | .scheme("https") 929 | .build().port() 930 | ) 931 | 932 | assertEquals( 933 | 80, DeepLinkUri.parse("https://example.com") 934 | .newBuilder() 935 | .scheme("http") 936 | .build().port() 937 | ) 938 | 939 | assertEquals( 940 | 1234, DeepLinkUri.parse("https://example.com:1234") 941 | .newBuilder() 942 | .scheme("http") 943 | .build().port() 944 | ) 945 | } 946 | 947 | @Test 948 | fun composeEncodesWhitespace() { 949 | val url = DeepLinkUri.Builder() 950 | .scheme("http") 951 | .username("a\r\n\u000c\t b") 952 | .password("c\r\n\u000c\t d") 953 | .host("host") 954 | .addPathSegment("e\r\n\u000c\t f") 955 | .query("g\r\n\u000c\t h") 956 | .fragment("i\r\n\u000c\t j") 957 | .build() 958 | assertEquals( 959 | "http://a%0D%0A%0C%09%20b:c%0D%0A%0C%09%20d@host" + "/e%0D%0A%0C%09%20f?g%0D%0A%0C%09%20h#i%0D%0A%0C%09 j", 960 | url.toString() 961 | ) 962 | assertEquals("a\r\n\u000c\t b", url.username()) 963 | assertEquals("c\r\n\u000c\t d", url.password()) 964 | assertEquals("e\r\n\u000c\t f", url.pathSegments()[0]) 965 | assertEquals("g\r\n\u000c\t h", url.query()) 966 | assertEquals("i\r\n\u000c\t j", url.fragment()) 967 | } 968 | 969 | @Test 970 | fun composeFromUnencodedComponents() { 971 | val url = DeepLinkUri.Builder() 972 | .scheme("http") 973 | .username("a:\u0001@/\\?#%b") 974 | .password("c:\u0001@/\\?#%d") 975 | .host("ef") 976 | .port(8080) 977 | .addPathSegment("g:\u0001@/\\?#%h") 978 | .query("i:\u0001@/\\?#%j") 979 | .fragment("k:\u0001@/\\?#%l") 980 | .build() 981 | assertEquals( 982 | "http://a%3A%01%40%2F%5C%3F%23%25b:c%3A%01%40%2F%5C%3F%23%25d@ef:8080/" + "g:%01@%2F%5C%3F%23%25h?i:%01@/\\?%23%25j#k:%01@/\\?#%25l", 983 | url.toString() 984 | ) 985 | assertEquals("http", url.scheme()) 986 | assertEquals("a:\u0001@/\\?#%b", url.username()) 987 | assertEquals("c:\u0001@/\\?#%d", url.password()) 988 | assertEquals(Arrays.asList("g:\u0001@/\\?#%h"), url.pathSegments()) 989 | assertEquals("i:\u0001@/\\?#%j", url.query()) 990 | assertEquals("k:\u0001@/\\?#%l", url.fragment()) 991 | assertEquals("a%3A%01%40%2F%5C%3F%23%25b", url.encodedUsername()) 992 | assertEquals("c%3A%01%40%2F%5C%3F%23%25d", url.encodedPassword()) 993 | assertEquals("/g:%01@%2F%5C%3F%23%25h", url.encodedPath()) 994 | assertEquals("i:%01@/\\?%23%25j", url.encodedQuery()) 995 | assertEquals("k:%01@/\\?#%25l", url.encodedFragment()) 996 | } 997 | 998 | @Test 999 | fun composeFromEncodedComponents() { 1000 | val url = DeepLinkUri.Builder() 1001 | .scheme("http") 1002 | .encodedUsername("a:\u0001@/\\?#%25b") 1003 | .encodedPassword("c:\u0001@/\\?#%25d") 1004 | .host("ef") 1005 | .port(8080) 1006 | .addEncodedPathSegment("g:\u0001@/\\?#%25h") 1007 | .encodedQuery("i:\u0001@/\\?#%25j") 1008 | .encodedFragment("k:\u0001@/\\?#%25l") 1009 | .build() 1010 | assertEquals( 1011 | "http://a%3A%01%40%2F%5C%3F%23%25b:c%3A%01%40%2F%5C%3F%23%25d@ef:8080/" + "g:%01@%2F%5C%3F%23%25h?i:%01@/\\?%23%25j#k:%01@/\\?#%25l", 1012 | url.toString() 1013 | ) 1014 | assertEquals("http", url.scheme()) 1015 | assertEquals("a:\u0001@/\\?#%b", url.username()) 1016 | assertEquals("c:\u0001@/\\?#%d", url.password()) 1017 | assertEquals(Arrays.asList("g:\u0001@/\\?#%h"), url.pathSegments()) 1018 | assertEquals("i:\u0001@/\\?#%j", url.query()) 1019 | assertEquals("k:\u0001@/\\?#%l", url.fragment()) 1020 | assertEquals("a%3A%01%40%2F%5C%3F%23%25b", url.encodedUsername()) 1021 | assertEquals("c%3A%01%40%2F%5C%3F%23%25d", url.encodedPassword()) 1022 | assertEquals("/g:%01@%2F%5C%3F%23%25h", url.encodedPath()) 1023 | assertEquals("i:%01@/\\?%23%25j", url.encodedQuery()) 1024 | assertEquals("k:%01@/\\?#%25l", url.encodedFragment()) 1025 | } 1026 | 1027 | @Test 1028 | fun composeWithEncodedPath() { 1029 | val url = DeepLinkUri.Builder() 1030 | .scheme("http") 1031 | .host("host") 1032 | .encodedPath("/a%2Fb/c") 1033 | .build() 1034 | assertEquals("http://host/a%2Fb/c", url.toString()) 1035 | assertEquals("/a%2Fb/c", url.encodedPath()) 1036 | assertEquals(Arrays.asList("a/b", "c"), url.pathSegments()) 1037 | } 1038 | 1039 | @Test 1040 | fun composeMixingPathSegments() { 1041 | val url = DeepLinkUri.Builder() 1042 | .scheme("http") 1043 | .host("host") 1044 | .encodedPath("/a%2fb/c") 1045 | .addPathSegment("d%25e") 1046 | .addEncodedPathSegment("f%25g") 1047 | .build() 1048 | assertEquals("http://host/a%2fb/c/d%2525e/f%25g", url.toString()) 1049 | assertEquals("/a%2fb/c/d%2525e/f%25g", url.encodedPath()) 1050 | assertEquals(Arrays.asList("a%2fb", "c", "d%2525e", "f%25g"), url.encodedPathSegments()) 1051 | assertEquals(Arrays.asList("a/b", "c", "d%25e", "f%g"), url.pathSegments()) 1052 | } 1053 | 1054 | @Test 1055 | fun composeWithAddSegment() { 1056 | val base = DeepLinkUri.parse("http://host/a/b/c") 1057 | assertEquals("/a/b/c/", base.newBuilder().addPathSegment("").build().encodedPath()) 1058 | assertEquals( 1059 | "/a/b/c/d", 1060 | base.newBuilder().addPathSegment("").addPathSegment("d").build().encodedPath() 1061 | ) 1062 | assertEquals("/a/b/", base.newBuilder().addPathSegment("..").build().encodedPath()) 1063 | assertEquals( 1064 | "/a/b/", base.newBuilder().addPathSegment("").addPathSegment("..").build() 1065 | .encodedPath() 1066 | ) 1067 | assertEquals( 1068 | "/a/b/c/", base.newBuilder().addPathSegment("").addPathSegment("").build() 1069 | .encodedPath() 1070 | ) 1071 | } 1072 | 1073 | @Test 1074 | fun pathSize() { 1075 | assertEquals(1, DeepLinkUri.parse("http://host/").pathSize().toLong()) 1076 | assertEquals(3, DeepLinkUri.parse("http://host/a/b/c").pathSize().toLong()) 1077 | } 1078 | 1079 | @Test 1080 | fun addPathSegments() { 1081 | val base = DeepLinkUri.parse("http://host/a/b/c") 1082 | 1083 | // Add a string with zero slashes: resulting URL gains one slash. 1084 | assertEquals("/a/b/c/", base.newBuilder().addPathSegments("").build().encodedPath()) 1085 | assertEquals("/a/b/c/d", base.newBuilder().addPathSegments("d").build().encodedPath()) 1086 | 1087 | // Add a string with one slash: resulting URL gains two slashes. 1088 | assertEquals("/a/b/c//", base.newBuilder().addPathSegments("/").build().encodedPath()) 1089 | assertEquals("/a/b/c/d/", base.newBuilder().addPathSegments("d/").build().encodedPath()) 1090 | assertEquals("/a/b/c//d", base.newBuilder().addPathSegments("/d").build().encodedPath()) 1091 | 1092 | // Add a string with two slashes: resulting URL gains three slashes. 1093 | assertEquals("/a/b/c///", base.newBuilder().addPathSegments("//").build().encodedPath()) 1094 | assertEquals("/a/b/c//d/", base.newBuilder().addPathSegments("/d/").build().encodedPath()) 1095 | assertEquals("/a/b/c/d//", base.newBuilder().addPathSegments("d//").build().encodedPath()) 1096 | assertEquals("/a/b/c///d", base.newBuilder().addPathSegments("//d").build().encodedPath()) 1097 | assertEquals("/a/b/c/d/e/f", base.newBuilder().addPathSegments("d/e/f").build().encodedPath()) 1098 | } 1099 | 1100 | @Test 1101 | fun addPathSegmentsOntoTrailingSlash() { 1102 | val base = DeepLinkUri.parse("http://host/a/b/c/") 1103 | 1104 | // Add a string with zero slashes: resulting URL gains zero slashes. 1105 | assertEquals("/a/b/c/", base.newBuilder().addPathSegments("").build().encodedPath()) 1106 | assertEquals("/a/b/c/d", base.newBuilder().addPathSegments("d").build().encodedPath()) 1107 | 1108 | // Add a string with one slash: resulting URL gains one slash. 1109 | assertEquals("/a/b/c//", base.newBuilder().addPathSegments("/").build().encodedPath()) 1110 | assertEquals("/a/b/c/d/", base.newBuilder().addPathSegments("d/").build().encodedPath()) 1111 | assertEquals("/a/b/c//d", base.newBuilder().addPathSegments("/d").build().encodedPath()) 1112 | 1113 | // Add a string with two slashes: resulting URL gains two slashes. 1114 | assertEquals("/a/b/c///", base.newBuilder().addPathSegments("//").build().encodedPath()) 1115 | assertEquals("/a/b/c//d/", base.newBuilder().addPathSegments("/d/").build().encodedPath()) 1116 | assertEquals("/a/b/c/d//", base.newBuilder().addPathSegments("d//").build().encodedPath()) 1117 | assertEquals("/a/b/c///d", base.newBuilder().addPathSegments("//d").build().encodedPath()) 1118 | assertEquals("/a/b/c/d/e/f", base.newBuilder().addPathSegments("d/e/f").build().encodedPath()) 1119 | } 1120 | 1121 | @Test 1122 | fun addPathSegmentsWithBackslash() { 1123 | val base = DeepLinkUri.parse("http://host/") 1124 | assertEquals("/d/e", base.newBuilder().addPathSegments("d\\e").build().encodedPath()) 1125 | assertEquals("/d/e", base.newBuilder().addEncodedPathSegments("d\\e").build().encodedPath()) 1126 | } 1127 | 1128 | @Test 1129 | fun addPathSegmentsWithEmptyPaths() { 1130 | val base = DeepLinkUri.parse("http://host/a/b/c") 1131 | assertEquals( 1132 | "/a/b/c//d/e///f", 1133 | base.newBuilder().addPathSegments("/d/e///f").build().encodedPath() 1134 | ) 1135 | } 1136 | 1137 | @Test 1138 | fun addEncodedPathSegments() { 1139 | val base = DeepLinkUri.parse("http://host/a/b/c") 1140 | assertEquals( 1141 | "/a/b/c/d/e/%20/", 1142 | base.newBuilder().addEncodedPathSegments("d/e/%20/\n").build().encodedPath() 1143 | ) 1144 | } 1145 | 1146 | @Test 1147 | fun addPathSegmentDotDoesNothing() { 1148 | val base = DeepLinkUri.parse("http://host/a/b/c") 1149 | assertEquals("/a/b/c", base.newBuilder().addPathSegment(".").build().encodedPath()) 1150 | } 1151 | 1152 | @Test 1153 | fun addPathSegmentEncodes() { 1154 | val base = DeepLinkUri.parse("http://host/a/b/c") 1155 | assertEquals( 1156 | "/a/b/c/%252e", 1157 | base.newBuilder().addPathSegment("%2e").build().encodedPath() 1158 | ) 1159 | assertEquals( 1160 | "/a/b/c/%252e%252e", 1161 | base.newBuilder().addPathSegment("%2e%2e").build().encodedPath() 1162 | ) 1163 | } 1164 | 1165 | @Test 1166 | fun addPathSegmentDotDotPopsDirectory() { 1167 | val base = DeepLinkUri.parse("http://host/a/b/c") 1168 | assertEquals("/a/b/", base.newBuilder().addPathSegment("..").build().encodedPath()) 1169 | } 1170 | 1171 | @Test 1172 | fun addPathSegmentDotAndIgnoredCharacter() { 1173 | val base = DeepLinkUri.parse("http://host/a/b/c") 1174 | assertEquals("/a/b/c/.%0A", base.newBuilder().addPathSegment(".\n").build().encodedPath()) 1175 | } 1176 | 1177 | @Test 1178 | fun addEncodedPathSegmentDotAndIgnoredCharacter() { 1179 | val base = DeepLinkUri.parse("http://host/a/b/c") 1180 | assertEquals("/a/b/c", base.newBuilder().addEncodedPathSegment(".\n").build().encodedPath()) 1181 | } 1182 | 1183 | @Test 1184 | fun addEncodedPathSegmentDotDotAndIgnoredCharacter() { 1185 | val base = DeepLinkUri.parse("http://host/a/b/c") 1186 | assertEquals("/a/b/", base.newBuilder().addEncodedPathSegment("..\n").build().encodedPath()) 1187 | } 1188 | 1189 | @Test 1190 | fun setPathSegment() { 1191 | val base = DeepLinkUri.parse("http://host/a/b/c") 1192 | assertEquals("/d/b/c", base.newBuilder().setPathSegment(0, "d").build().encodedPath()) 1193 | assertEquals("/a/d/c", base.newBuilder().setPathSegment(1, "d").build().encodedPath()) 1194 | assertEquals("/a/b/d", base.newBuilder().setPathSegment(2, "d").build().encodedPath()) 1195 | } 1196 | 1197 | @Test 1198 | fun setPathSegmentEncodes() { 1199 | val base = DeepLinkUri.parse("http://host/a/b/c") 1200 | assertEquals("/%2525/b/c", base.newBuilder().setPathSegment(0, "%25").build().encodedPath()) 1201 | assertEquals("/.%0A/b/c", base.newBuilder().setPathSegment(0, ".\n").build().encodedPath()) 1202 | assertEquals("/%252e/b/c", base.newBuilder().setPathSegment(0, "%2e").build().encodedPath()) 1203 | } 1204 | 1205 | @Test 1206 | fun setPathSegmentAcceptsEmpty() { 1207 | val base = DeepLinkUri.parse("http://host/a/b/c") 1208 | assertEquals("//b/c", base.newBuilder().setPathSegment(0, "").build().encodedPath()) 1209 | assertEquals("/a/b/", base.newBuilder().setPathSegment(2, "").build().encodedPath()) 1210 | } 1211 | 1212 | @Test 1213 | fun setPathSegmentRejectsDot() { 1214 | val base = DeepLinkUri.parse("http://host/a/b/c") 1215 | 1216 | assertFailsWith { 1217 | base.newBuilder().setPathSegment(0, ".") 1218 | } 1219 | 1220 | } 1221 | 1222 | @Test 1223 | fun setPathSegmentRejectsDotDot() { 1224 | val base = DeepLinkUri.parse("http://host/a/b/c") 1225 | 1226 | assertFailsWith { 1227 | base.newBuilder().setPathSegment(0, "..") 1228 | } 1229 | 1230 | } 1231 | 1232 | @Test 1233 | fun setPathSegmentWithSlash() { 1234 | val base = DeepLinkUri.parse("http://host/a/b/c") 1235 | val url = base.newBuilder().setPathSegment(1, "/").build() 1236 | assertEquals("/a/%2F/c", url.encodedPath()) 1237 | } 1238 | 1239 | @Test 1240 | fun setPathSegmentOutOfBounds() { 1241 | assertFailsWith { 1242 | DeepLinkUri.Builder().setPathSegment(1, "a") 1243 | } 1244 | 1245 | } 1246 | 1247 | @Test 1248 | fun setEncodedPathSegmentEncodes() { 1249 | val base = DeepLinkUri.parse("http://host/a/b/c") 1250 | assertEquals( 1251 | "/%25/b/c", 1252 | base.newBuilder().setEncodedPathSegment(0, "%25").build().encodedPath() 1253 | ) 1254 | } 1255 | 1256 | @Test 1257 | fun setEncodedPathSegmentRejectsDot() { 1258 | val base = DeepLinkUri.parse("http://host/a/b/c") 1259 | 1260 | assertFailsWith { 1261 | base.newBuilder().setEncodedPathSegment(0, ".") 1262 | } 1263 | } 1264 | 1265 | @Test 1266 | fun setEncodedPathSegmentRejectsDotAndIgnoredCharacter() { 1267 | val base = DeepLinkUri.parse("http://host/a/b/c") 1268 | 1269 | assertFailsWith { 1270 | base.newBuilder().setEncodedPathSegment(0, ".\n") 1271 | } 1272 | 1273 | } 1274 | 1275 | @Test 1276 | fun setEncodedPathSegmentRejectsDotDot() { 1277 | val base = DeepLinkUri.parse("http://host/a/b/c") 1278 | 1279 | assertFailsWith { 1280 | base.newBuilder().setEncodedPathSegment(0, "..") 1281 | } 1282 | 1283 | } 1284 | 1285 | @Test 1286 | fun setEncodedPathSegmentRejectsDotDotAndIgnoredCharacter() { 1287 | val base = DeepLinkUri.parse("http://host/a/b/c") 1288 | 1289 | assertFailsWith { 1290 | base.newBuilder().setEncodedPathSegment(0, "..\n") 1291 | } 1292 | 1293 | } 1294 | 1295 | @Test 1296 | fun setEncodedPathSegmentWithSlash() { 1297 | val base = DeepLinkUri.parse("http://host/a/b/c") 1298 | val url = base.newBuilder().setEncodedPathSegment(1, "/").build() 1299 | assertEquals("/a/%2F/c", url.encodedPath()) 1300 | } 1301 | 1302 | @Test 1303 | fun setEncodedPathSegmentOutOfBounds() { 1304 | assertFailsWith { 1305 | DeepLinkUri.Builder().setEncodedPathSegment(1, "a") 1306 | fail() 1307 | } 1308 | 1309 | } 1310 | 1311 | @Test 1312 | fun removePathSegment() { 1313 | val base = DeepLinkUri.parse("http://host/a/b/c") 1314 | val url = base.newBuilder() 1315 | .removePathSegment(0) 1316 | .build() 1317 | assertEquals("/b/c", url.encodedPath()) 1318 | } 1319 | 1320 | @Test 1321 | fun removePathSegmentDoesntRemovePath() { 1322 | val base = DeepLinkUri.parse("http://host/a/b/c") 1323 | val url = base.newBuilder() 1324 | .removePathSegment(0) 1325 | .removePathSegment(0) 1326 | .removePathSegment(0) 1327 | .build() 1328 | assertEquals(Arrays.asList(""), url.pathSegments()) 1329 | assertEquals("/", url.encodedPath()) 1330 | } 1331 | 1332 | @Test 1333 | fun removePathSegmentOutOfBounds() { 1334 | assertFailsWith { 1335 | DeepLinkUri.Builder().removePathSegment(1) 1336 | fail() 1337 | } 1338 | 1339 | } 1340 | 1341 | @Test 1342 | fun toJavaNetUrl() { 1343 | val deepLinkUri = DeepLinkUri.parse("http://username:password@host/path?query#fragment") 1344 | val javaNetUrl = deepLinkUri.url() 1345 | assertEquals("http://username:password@host/path?query#fragment", javaNetUrl.toString()) 1346 | } 1347 | 1348 | @Test 1349 | fun toUri() { 1350 | val deepLinkUri = DeepLinkUri.parse("http://username:password@host/path?query#fragment") 1351 | val uri = deepLinkUri.uri() 1352 | assertEquals("http://username:password@host/path?query#fragment", uri.toString()) 1353 | } 1354 | 1355 | @Test 1356 | fun toUriSpecialQueryCharacters() { 1357 | val deepLinkUri = DeepLinkUri.parse("http://host/?d=abc!@[]^`{}|\\") 1358 | val uri = deepLinkUri.uri() 1359 | assertEquals("http://host/?d=abc!@[]%5E%60%7B%7D%7C%5C", uri.toString()) 1360 | } 1361 | 1362 | @Test 1363 | fun toUriWithUsernameNoPassword() { 1364 | val deepLinkUri = DeepLinkUri.Builder() 1365 | .scheme("http") 1366 | .username("user") 1367 | .host("host") 1368 | .build() 1369 | assertEquals("http://user@host/", deepLinkUri.toString()) 1370 | assertEquals("http://user@host/", deepLinkUri.uri().toString()) 1371 | } 1372 | 1373 | @Test 1374 | fun toUriUsernameSpecialCharacters() { 1375 | val url = DeepLinkUri.Builder() 1376 | .scheme("http") 1377 | .host("host") 1378 | .username("=[]:;\"~|?#@^/$%*") 1379 | .build() 1380 | assertEquals("http://%3D%5B%5D%3A%3B%22~%7C%3F%23%40%5E%2F$%25*@host/", url.toString()) 1381 | assertEquals("http://%3D%5B%5D%3A%3B%22~%7C%3F%23%40%5E%2F$%25*@host/", url.uri().toString()) 1382 | } 1383 | 1384 | @Test 1385 | fun toUriPasswordSpecialCharacters() { 1386 | val url = DeepLinkUri.Builder() 1387 | .scheme("http") 1388 | .host("host") 1389 | .username("user") 1390 | .password("=[]:;\"~|?#@^/$%*") 1391 | .build() 1392 | assertEquals("http://user:%3D%5B%5D%3A%3B%22~%7C%3F%23%40%5E%2F$%25*@host/", url.toString()) 1393 | assertEquals( 1394 | "http://user:%3D%5B%5D%3A%3B%22~%7C%3F%23%40%5E%2F$%25*@host/", 1395 | url.uri().toString() 1396 | ) 1397 | } 1398 | 1399 | @Test 1400 | fun toUriPathSpecialCharacters() { 1401 | val url = DeepLinkUri.Builder() 1402 | .scheme("http") 1403 | .host("host") 1404 | .addPathSegment("=[]:;\"~|?#@^/$%*") 1405 | .build() 1406 | assertEquals("http://host/=[]:;%22~%7C%3F%23@%5E%2F$%25*", url.toString()) 1407 | assertEquals("http://host/=%5B%5D:;%22~%7C%3F%23@%5E%2F$%25*", url.uri().toString()) 1408 | } 1409 | 1410 | @Test 1411 | fun toUriQueryParameterNameSpecialCharacters() { 1412 | val url = DeepLinkUri.Builder() 1413 | .scheme("http") 1414 | .host("host") 1415 | .addQueryParameter("=[]:;\"~|?#@^/$%*", "a") 1416 | .build() 1417 | assertEquals( 1418 | "http://host/?%3D%5B%5D%3A%3B%22%7E%7C%3F%23%40%5E%2F%24%25*=a", 1419 | url.toString() 1420 | ) 1421 | assertEquals( 1422 | "http://host/?%3D%5B%5D%3A%3B%22%7E%7C%3F%23%40%5E%2F%24%25*=a", 1423 | url.uri().toString() 1424 | ) 1425 | assertEquals("a", url.queryParameter("=[]:;\"~|?#@^/$%*")) 1426 | } 1427 | 1428 | @Test 1429 | fun toUriQueryParameterValueSpecialCharacters() { 1430 | val url = DeepLinkUri.Builder() 1431 | .scheme("http") 1432 | .host("host") 1433 | .addQueryParameter("a", "=[]:;\"~|?#@^/$%*") 1434 | .build() 1435 | assertEquals( 1436 | "http://host/?a=%3D%5B%5D%3A%3B%22%7E%7C%3F%23%40%5E%2F%24%25*", 1437 | url.toString() 1438 | ) 1439 | assertEquals( 1440 | "http://host/?a=%3D%5B%5D%3A%3B%22%7E%7C%3F%23%40%5E%2F%24%25*", 1441 | url.uri().toString() 1442 | ) 1443 | assertEquals("=[]:;\"~|?#@^/$%*", url.queryParameter("a")) 1444 | } 1445 | 1446 | @Test 1447 | fun toUriQueryValueSpecialCharacters() { 1448 | val url = DeepLinkUri.Builder() 1449 | .scheme("http") 1450 | .host("host") 1451 | .query("=[]:;\"~|?#@^/$%*") 1452 | .build() 1453 | assertEquals("http://host/?=[]:;%22~|?%23@^/$%25*", url.toString()) 1454 | assertEquals("http://host/?=[]:;%22~%7C?%23@%5E/$%25*", url.uri().toString()) 1455 | } 1456 | 1457 | @Test 1458 | fun queryCharactersEncodedWhenComposed() { 1459 | val url = DeepLinkUri.Builder() 1460 | .scheme("http") 1461 | .host("host") 1462 | .addQueryParameter("a", "!$(),/:;?@[]\\^`{|}~") 1463 | .build() 1464 | assertEquals( 1465 | "http://host/?a=%21%24%28%29%2C%2F%3A%3B%3F%40%5B%5D%5C%5E%60%7B%7C%7D%7E", 1466 | url.toString() 1467 | ) 1468 | assertEquals("!$(),/:;?@[]\\^`{|}~", url.queryParameter("a")) 1469 | } 1470 | 1471 | /** 1472 | * When callers use `addEncodedQueryParameter()` we only encode what's strictly required. 1473 | * We retain the encoded (or non-encoded) state of the input. 1474 | */ 1475 | @Test 1476 | fun queryCharactersNotReencodedWhenComposedWithAddEncoded() { 1477 | val url = DeepLinkUri.Builder() 1478 | .scheme("http") 1479 | .host("host") 1480 | .addEncodedQueryParameter("a", "!$(),/:;?@[]\\^`{|}~") 1481 | .build() 1482 | assertEquals( 1483 | "http://host/?a=!$(),/:;?@[]\\^`{|}~", 1484 | url.toString() 1485 | ) 1486 | assertEquals("!$(),/:;?@[]\\^`{|}~", url.queryParameter("a")) 1487 | } 1488 | 1489 | /** 1490 | * When callers parse a URL with query components that aren't encoded, we shouldn't convert them 1491 | * into a canonical form because doing so could be semantically different. 1492 | */ 1493 | @Test 1494 | fun queryCharactersNotReencodedWhenParsed() { 1495 | val url = DeepLinkUri.parse("http://host/?a=!$(),/:;?@[]\\^`{|}~") 1496 | assertEquals("http://host/?a=!$(),/:;?@[]\\^`{|}~", url.toString()) 1497 | assertEquals("!$(),/:;?@[]\\^`{|}~", url.queryParameter("a")) 1498 | } 1499 | 1500 | @Test 1501 | fun toUriFragmentSpecialCharacters() { 1502 | val url = DeepLinkUri.Builder() 1503 | .scheme("http") 1504 | .host("host") 1505 | .fragment("=[]:;\"~|?#@^/$%*") 1506 | .build() 1507 | assertEquals("http://host/#=[]:;\"~|?#@^/$%25*", url.toString()) 1508 | assertEquals("http://host/#=[]:;%22~%7C?%23@%5E/$%25*", url.uri().toString()) 1509 | } 1510 | 1511 | @Test 1512 | fun toUriWithControlCharacters() { 1513 | // Percent-encoded in the path. 1514 | assertEquals(URI("http://host/a%00b"), DeepLinkUri.parse("http://host/a\u0000b").uri()) 1515 | assertEquals(URI("http://host/a%C2%80b"), DeepLinkUri.parse("http://host/a\u0080b").uri()) 1516 | assertEquals(URI("http://host/a%C2%9Fb"), DeepLinkUri.parse("http://host/a\u009fb").uri()) 1517 | // Percent-encoded in the query. 1518 | assertEquals(URI("http://host/?a%00b"), DeepLinkUri.parse("http://host/?a\u0000b").uri()) 1519 | assertEquals(URI("http://host/?a%C2%80b"), DeepLinkUri.parse("http://host/?a\u0080b").uri()) 1520 | assertEquals(URI("http://host/?a%C2%9Fb"), DeepLinkUri.parse("http://host/?a\u009fb").uri()) 1521 | // Stripped from the fragment. 1522 | assertEquals(URI("http://host/#a%00b"), DeepLinkUri.parse("http://host/#a\u0000b").uri()) 1523 | assertEquals(URI("http://host/#ab"), DeepLinkUri.parse("http://host/#a\u0080b").uri()) 1524 | assertEquals(URI("http://host/#ab"), DeepLinkUri.parse("http://host/#a\u009fb").uri()) 1525 | } 1526 | 1527 | @Test 1528 | fun toUriWithSpaceCharacters() { 1529 | // Percent-encoded in the path. 1530 | assertEquals(URI("http://host/a%0Bb"), DeepLinkUri.parse("http://host/a\u000bb").uri()) 1531 | assertEquals(URI("http://host/a%20b"), DeepLinkUri.parse("http://host/a b").uri()) 1532 | assertEquals(URI("http://host/a%E2%80%89b"), DeepLinkUri.parse("http://host/a\u2009b").uri()) 1533 | assertEquals(URI("http://host/a%E3%80%80b"), DeepLinkUri.parse("http://host/a\u3000b").uri()) 1534 | // Percent-encoded in the query. 1535 | assertEquals(URI("http://host/?a%0Bb"), DeepLinkUri.parse("http://host/?a\u000bb").uri()) 1536 | assertEquals(URI("http://host/?a%20b"), DeepLinkUri.parse("http://host/?a b").uri()) 1537 | assertEquals(URI("http://host/?a%E2%80%89b"), DeepLinkUri.parse("http://host/?a\u2009b").uri()) 1538 | assertEquals(URI("http://host/?a%E3%80%80b"), DeepLinkUri.parse("http://host/?a\u3000b").uri()) 1539 | // Stripped from the fragment. 1540 | assertEquals(URI("http://host/#a%0Bb"), DeepLinkUri.parse("http://host/#a\u000bb").uri()) 1541 | assertEquals(URI("http://host/#a%20b"), DeepLinkUri.parse("http://host/#a b").uri()) 1542 | assertEquals(URI("http://host/#ab"), DeepLinkUri.parse("http://host/#a\u2009b").uri()) 1543 | assertEquals(URI("http://host/#ab"), DeepLinkUri.parse("http://host/#a\u3000b").uri()) 1544 | } 1545 | 1546 | @Test 1547 | fun toUriWithNonHexPercentEscape() { 1548 | assertEquals(URI("http://host/%25xx"), DeepLinkUri.parse("http://host/%xx").uri()) 1549 | } 1550 | 1551 | @Test 1552 | fun toUriWithTruncatedPercentEscape() { 1553 | assertEquals(URI("http://host/%25a"), DeepLinkUri.parse("http://host/%a").uri()) 1554 | assertEquals(URI("http://host/%25"), DeepLinkUri.parse("http://host/%").uri()) 1555 | } 1556 | 1557 | @Test 1558 | fun fromJavaNetUrl() { 1559 | val javaNetUrl = URL("http://username:password@host/path?query#fragment") 1560 | val deepLinkUri = DeepLinkUri.get(javaNetUrl) 1561 | assertEquals("http://username:password@host/path?query#fragment", deepLinkUri.toString()) 1562 | } 1563 | 1564 | @Test 1565 | fun fromJavaNetUrlCustomScheme() { 1566 | val javaNetUrl = URL("mailto:user@example.com") 1567 | assertEquals(parse("mailto:user@example.com"), DeepLinkUri.get(javaNetUrl)) 1568 | } 1569 | 1570 | @Test 1571 | fun fromUri() { 1572 | val uri = URI("http://username:password@host/path?query#fragment") 1573 | val deepLinkUri = DeepLinkUri.get(uri) 1574 | assertEquals("http://username:password@host/path?query#fragment", deepLinkUri.toString()) 1575 | } 1576 | 1577 | @Test 1578 | fun fromUriCustomScheme() { 1579 | val uri = URI("mailto:user@example.com") 1580 | assertEquals(parse("mailto:user@example.com"), DeepLinkUri.get(uri)) 1581 | } 1582 | 1583 | @Test 1584 | fun fromUriPartial() { 1585 | val uri = URI("/path") 1586 | assertFailsWith { (DeepLinkUri.get(uri)) } 1587 | } 1588 | 1589 | @Test 1590 | fun composeQueryWithComponents() { 1591 | val base = DeepLinkUri.parse("http://host/") 1592 | val url = base.newBuilder().addQueryParameter("a+=& b", "c+=& d").build() 1593 | assertEquals("http://host/?a%2B%3D%26%20b=c%2B%3D%26%20d", url.toString()) 1594 | assertEquals("c+=& d", url.queryParameterValue(0)) 1595 | assertEquals("a+=& b", url.queryParameterName(0)) 1596 | assertEquals("c+=& d", url.queryParameter("a+=& b")) 1597 | assertEquals(setOf("a+=& b"), url.queryParameterNames()) 1598 | assertEquals(listOf("c+=& d"), url.queryParameterValues("a+=& b")) 1599 | assertEquals(1, url.querySize().toLong()) 1600 | assertEquals("a+=& b=c+=& d", url.query()) // Ambiguous! (Though working as designed.) 1601 | assertEquals("a%2B%3D%26%20b=c%2B%3D%26%20d", url.encodedQuery()) 1602 | } 1603 | 1604 | @Test 1605 | fun composeQueryWithEncodedComponents() { 1606 | val base = DeepLinkUri.parse("http://host/") 1607 | val url = base.newBuilder().addEncodedQueryParameter("a+=& b", "c+=& d").build() 1608 | assertEquals("http://host/?a+%3D%26%20b=c+%3D%26%20d", url.toString()) 1609 | assertEquals("c =& d", url.queryParameter("a =& b")) 1610 | } 1611 | 1612 | @Test 1613 | fun composeQueryRemoveQueryParameter() { 1614 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1615 | .addQueryParameter("a+=& b", "c+=& d") 1616 | .removeAllQueryParameters("a+=& b") 1617 | .build() 1618 | assertEquals("http://host/", url.toString()) 1619 | assertNull(url.queryParameter("a+=& b")) 1620 | } 1621 | 1622 | @Test 1623 | fun composeQueryRemoveEncodedQueryParameter() { 1624 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1625 | .addEncodedQueryParameter("a+=& b", "c+=& d") 1626 | .removeAllEncodedQueryParameters("a+=& b") 1627 | .build() 1628 | assertEquals("http://host/", url.toString()) 1629 | assertNull(url.queryParameter("a =& b")) 1630 | } 1631 | 1632 | @Test 1633 | fun composeQuerySetQueryParameter() { 1634 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1635 | .addQueryParameter("a+=& b", "c+=& d") 1636 | .setQueryParameter("a+=& b", "ef") 1637 | .build() 1638 | assertEquals("http://host/?a%2B%3D%26%20b=ef", url.toString()) 1639 | assertEquals("ef", url.queryParameter("a+=& b")) 1640 | } 1641 | 1642 | @Test 1643 | fun composeQuerySetEncodedQueryParameter() { 1644 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1645 | .addEncodedQueryParameter("a+=& b", "c+=& d") 1646 | .setEncodedQueryParameter("a+=& b", "ef") 1647 | .build() 1648 | assertEquals("http://host/?a+%3D%26%20b=ef", url.toString()) 1649 | assertEquals("ef", url.queryParameter("a =& b")) 1650 | } 1651 | 1652 | @Test 1653 | fun composeQueryMultipleEncodedValuesForParameter() { 1654 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1655 | .addQueryParameter("a+=& b", "c+=& d") 1656 | .addQueryParameter("a+=& b", "e+=& f") 1657 | .build() 1658 | assertEquals( 1659 | "http://host/?a%2B%3D%26%20b=c%2B%3D%26%20d&a%2B%3D%26%20b=e%2B%3D%26%20f", 1660 | url.toString() 1661 | ) 1662 | assertEquals(2, url.querySize().toLong()) 1663 | assertEquals(setOf("a+=& b"), url.queryParameterNames()) 1664 | assertEquals(Arrays.asList("c+=& d", "e+=& f"), url.queryParameterValues("a+=& b")) 1665 | } 1666 | 1667 | @Test 1668 | fun absentQueryIsZeroNameValuePairs() { 1669 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1670 | .query(null) 1671 | .build() 1672 | assertEquals(0, url.querySize().toLong()) 1673 | } 1674 | 1675 | @Test 1676 | fun emptyQueryIsSingleNameValuePairWithEmptyKey() { 1677 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1678 | .query("") 1679 | .build() 1680 | assertEquals(1, url.querySize().toLong()) 1681 | assertEquals("", url.queryParameterName(0)) 1682 | assertNull(url.queryParameterValue(0)) 1683 | } 1684 | 1685 | @Test 1686 | fun ampersandQueryIsTwoNameValuePairsWithEmptyKeys() { 1687 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1688 | .query("&") 1689 | .build() 1690 | assertEquals(2, url.querySize().toLong()) 1691 | assertEquals("", url.queryParameterName(0)) 1692 | assertNull(url.queryParameterValue(0)) 1693 | assertEquals("", url.queryParameterName(1)) 1694 | assertNull(url.queryParameterValue(1)) 1695 | } 1696 | 1697 | @Test 1698 | fun removeAllDoesNotRemoveQueryIfNoParametersWereRemoved() { 1699 | val url = DeepLinkUri.parse("http://host/").newBuilder() 1700 | .query("") 1701 | .removeAllQueryParameters("a") 1702 | .build() 1703 | assertEquals("http://host/?", url.toString()) 1704 | } 1705 | 1706 | @Test 1707 | fun queryParametersWithoutValues() { 1708 | val url = DeepLinkUri.parse("http://host/?foo&bar&baz") 1709 | assertEquals(3, url.querySize().toLong()) 1710 | assertEquals( 1711 | LinkedHashSet(Arrays.asList("foo", "bar", "baz")), 1712 | url.queryParameterNames() 1713 | ) 1714 | assertNull(url.queryParameterValue(0)) 1715 | assertNull(url.queryParameterValue(1)) 1716 | assertNull(url.queryParameterValue(2)) 1717 | assertEquals(listOf(null), url.queryParameterValues("foo")) 1718 | assertEquals(listOf(null), url.queryParameterValues("bar")) 1719 | assertEquals(listOf(null), url.queryParameterValues("baz")) 1720 | } 1721 | 1722 | @Test 1723 | fun queryParametersWithEmptyValues() { 1724 | val url = DeepLinkUri.parse("http://host/?foo=&bar=&baz=") 1725 | assertEquals(3, url.querySize().toLong()) 1726 | assertEquals( 1727 | LinkedHashSet(Arrays.asList("foo", "bar", "baz")), 1728 | url.queryParameterNames() 1729 | ) 1730 | assertEquals("", url.queryParameterValue(0)) 1731 | assertEquals("", url.queryParameterValue(1)) 1732 | assertEquals("", url.queryParameterValue(2)) 1733 | assertEquals(listOf(""), url.queryParameterValues("foo")) 1734 | assertEquals(listOf(""), url.queryParameterValues("bar")) 1735 | assertEquals(listOf(""), url.queryParameterValues("baz")) 1736 | } 1737 | 1738 | @Test 1739 | fun queryParametersWithRepeatedName() { 1740 | val url = DeepLinkUri.parse("http://host/?foo[]=1&foo[]=2&foo[]=3") 1741 | assertEquals(3, url.querySize().toLong()) 1742 | assertEquals(setOf("foo[]"), url.queryParameterNames()) 1743 | assertEquals("1", url.queryParameterValue(0)) 1744 | assertEquals("2", url.queryParameterValue(1)) 1745 | assertEquals("3", url.queryParameterValue(2)) 1746 | assertEquals(Arrays.asList("1", "2", "3"), url.queryParameterValues("foo[]")) 1747 | } 1748 | 1749 | @Test 1750 | fun queryParameterLookupWithNonCanonicalEncoding() { 1751 | val url = DeepLinkUri.parse("http://host/?%6d=m&+=%20") 1752 | assertEquals("m", url.queryParameterName(0)) 1753 | assertEquals(" ", url.queryParameterName(1)) 1754 | assertEquals("m", url.queryParameter("m")) 1755 | assertEquals(" ", url.queryParameter(" ")) 1756 | } 1757 | 1758 | @Test 1759 | fun parsedQueryDoesntIncludeFragment() { 1760 | val url = DeepLinkUri.parse("http://host/?#fragment") 1761 | assertEquals("fragment", url.fragment()) 1762 | assertEquals("", url.query()) 1763 | assertEquals("", url.encodedQuery()) 1764 | } 1765 | 1766 | @Test 1767 | fun roundTripBuilder() { 1768 | val url = DeepLinkUri.Builder() 1769 | .scheme("http") 1770 | .username("%") 1771 | .password("%") 1772 | .host("host") 1773 | .addPathSegment("%") 1774 | .query("%") 1775 | .fragment("%") 1776 | .build() 1777 | assertEquals("http://%25:%25@host/%25?%25#%25", url.toString()) 1778 | assertEquals("http://%25:%25@host/%25?%25#%25", url.newBuilder().build().toString()) 1779 | assertEquals("http://%25:%25@host/%25?%25", url.resolve("")!!.toString()) 1780 | } 1781 | 1782 | /** 1783 | * Although DeepLinkUri prefers percent-encodings in uppercase, it should preserve the exact structure 1784 | * of the original encoding. 1785 | */ 1786 | @Test 1787 | fun rawEncodingRetained() { 1788 | val urlString = "http://%6d%6D:%6d%6D@host/%6d%6D?%6d%6D#%6d%6D" 1789 | val url = DeepLinkUri.parse(urlString) 1790 | assertEquals("%6d%6D", url.encodedUsername()) 1791 | assertEquals("%6d%6D", url.encodedPassword()) 1792 | assertEquals("/%6d%6D", url.encodedPath()) 1793 | assertEquals(Arrays.asList("%6d%6D"), url.encodedPathSegments()) 1794 | assertEquals("%6d%6D", url.encodedQuery()) 1795 | assertEquals("%6d%6D", url.encodedFragment()) 1796 | assertEquals(urlString, url.toString()) 1797 | assertEquals(urlString, url.newBuilder().build().toString()) 1798 | assertEquals("http://%6d%6D:%6d%6D@host/%6d%6D?%6d%6D", url.resolve("")!!.toString()) 1799 | } 1800 | 1801 | @Test 1802 | fun clearFragment() { 1803 | val url = DeepLinkUri.parse("http://host/#fragment") 1804 | .newBuilder() 1805 | .fragment(null) 1806 | .build() 1807 | assertEquals("http://host/", url.toString()) 1808 | assertNull(url.fragment()) 1809 | assertNull(url.encodedFragment()) 1810 | } 1811 | 1812 | @Test 1813 | fun clearEncodedFragment() { 1814 | val url = DeepLinkUri.parse("http://host/#fragment") 1815 | .newBuilder() 1816 | .encodedFragment(null) 1817 | .build() 1818 | assertEquals("http://host/", url.toString()) 1819 | assertNull(url.fragment()) 1820 | assertNull(url.encodedFragment()) 1821 | } 1822 | 1823 | private fun assertInvalid(string: String, exceptionMessage: String?) { 1824 | if (useGet) { 1825 | assertFailsWith(exceptionMessage) { 1826 | DeepLinkUri.parse(string) 1827 | } 1828 | 1829 | } else { 1830 | assertNull(DeepLinkUri.parseOrNull(string), string) 1831 | } 1832 | } 1833 | 1834 | companion object { 1835 | 1836 | @Suppress("unused", "BooleanLiteralArgument") 1837 | @Parameterized.Parameters(name = "Use get = {0}") 1838 | @JvmStatic 1839 | fun parameters(): Collection<*> { 1840 | return listOf(true, false) 1841 | } 1842 | } 1843 | } 1844 | -------------------------------------------------------------------------------- /deeplink/src/test/java/com/hellofresh/deeplink/DeeplinkParserTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | import org.junit.Test 20 | import kotlin.test.assertEquals 21 | import kotlin.test.assertFailsWith 22 | 23 | class DeepLinkParserTest { 24 | 25 | // DeepLinkParser is immutable, so the same instance can be shared across tests 26 | private val parser = DeepLinkParser.of(EmptyEnvironment) 27 | .addRoute(RecipeRoute) 28 | .addRoute(SubscriptionRoute) 29 | .addFallbackAction(FallbackAction) 30 | .build() 31 | 32 | @Test 33 | fun newParser_NoFallback_ThrowsException() { 34 | assertFailsWith { 35 | DeepLinkParser.of(EmptyEnvironment) 36 | .addRoute(RecipeRoute) 37 | .build() 38 | } 39 | } 40 | 41 | @Test 42 | fun parseSimple() { 43 | assertEquals("RecipeRoute", parser.parse(DeepLinkUri.parse("http://world.com/recipes"))) 44 | } 45 | 46 | @Test 47 | fun parseWithParam() { 48 | assertEquals("1234", parser.parse(DeepLinkUri.parse("http://world.com/recipe/1234"))) 49 | } 50 | 51 | @Test 52 | fun parseWithNextRouter() { 53 | assertEquals("SubscriptionRoute", parser.parse(DeepLinkUri.parse("hellofresh://host/subscription"))) 54 | } 55 | 56 | @Test 57 | fun parseWithConflictingRouter() { 58 | // RecipeRoute registered first 59 | var parserWithConflict = DeepLinkParser.of(EmptyEnvironment) 60 | .addRoute(RecipeRoute) 61 | .addRoute(ConflictingRecipeRoute) 62 | .addFallbackAction(FallbackAction) 63 | .build() 64 | assertEquals("RecipeRoute", parserWithConflict.parse(DeepLinkUri.parse("http://world.com/recipes"))) 65 | 66 | // ConflictingRoute registered first 67 | parserWithConflict = DeepLinkParser.of(EmptyEnvironment) 68 | .addRoute(ConflictingRecipeRoute) 69 | .addRoute(RecipeRoute) 70 | .addFallbackAction(FallbackAction) 71 | .build() 72 | assertEquals("Conflict", parserWithConflict.parse(DeepLinkUri.parse("http://world.com/recipes"))) 73 | } 74 | 75 | @Test 76 | fun parseFallback() { 77 | assertEquals("Fallback", parser.parse(DeepLinkUri.parse("http://world.com/unknown"))) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /deeplink/src/test/java/com/hellofresh/deeplink/EmptyEnvironment.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | import android.content.Context 20 | 21 | object EmptyEnvironment : Environment { 22 | 23 | override val context: Context 24 | get() = nullOverride() 25 | 26 | override val isAuthenticated: Boolean 27 | get() = true 28 | 29 | @Suppress("UNCHECKED_CAST") 30 | private fun nullOverride() = null as T 31 | } 32 | -------------------------------------------------------------------------------- /deeplink/src/test/java/com/hellofresh/deeplink/Routes.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019. The HelloFresh Android Team 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.hellofresh.deeplink 18 | 19 | 20 | object RecipeRoute : BaseRoute("recipes", "recipe/:id") { 21 | 22 | override fun run(uri: DeepLinkUri, params: Map, env: Environment): String { 23 | return params["id"] ?: javaClass.simpleName 24 | } 25 | } 26 | 27 | object SubscriptionRoute : BaseRoute("subscription") { 28 | 29 | override fun run(uri: DeepLinkUri, params: Map, env: Environment): String { 30 | return javaClass.simpleName 31 | } 32 | } 33 | 34 | object ConflictingRecipeRoute : BaseRoute("recipes") { 35 | 36 | override fun run(uri: DeepLinkUri, params: Map, env: Environment): String { 37 | return "Conflict" 38 | } 39 | } 40 | 41 | object FallbackAction : Action { 42 | 43 | override fun run(uri: DeepLinkUri, params: Map, env: Environment): String { 44 | return "Fallback" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019. The HelloFresh Android Team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | # Project-wide Gradle settings. 18 | # IDE (e.g. Android Studio) users: 19 | # Gradle settings configured through the IDE *will override* 20 | # any settings specified in this file. 21 | # For more details on how to configure your build environment visit 22 | # http://www.gradle.org/docs/current/userguide/build_environment.html 23 | # Specifies the JVM arguments used for the daemon process. 24 | # The setting is particularly useful for tweaking memory settings. 25 | org.gradle.jvmargs=-Xmx1536m 26 | # When configured, Gradle will run in incubating parallel mode. 27 | # This option should only be used with decoupled projects. More details, visit 28 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 29 | # org.gradle.parallel=true 30 | # Kotlin code style for this project: "official" or "obsolete": 31 | kotlin.code.style=official 32 | kotlin.parallel.tasks.in.project=true 33 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellofresh/android-deeplink/f48bdb2486020ba24a6a520625e0694b8df7b497/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.2-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /script/deploy-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ./gradlew clean build 4 | 5 | ./gradlew :deeplink:bintrayUpload -------------------------------------------------------------------------------- /script/deploy-snapshot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Deploy a jar, source jar, and javadoc jar to Sonatype's snapshot repo. 4 | # 5 | # Adapted from https://coderwall.com/p/9b_lfq and 6 | # http://benlimmer.com/2013/12/26/automatically-publish-javadoc-to-gh-pages-with-travis-ci/ 7 | 8 | SLUG="hellofresh/android-deeplink" 9 | BRANCH="master" 10 | 11 | set -e 12 | 13 | if [ "$TRAVIS_REPO_SLUG" != "$SLUG" ]; then 14 | echo "Skipping snapshot deployment: wrong repository. Expected '$SLUG' but was '$TRAVIS_REPO_SLUG'." 15 | elif [ "$TRAVIS_PULL_REQUEST" != "false" ]; then 16 | echo "Skipping snapshot deployment: was pull request." 17 | elif [ "$TRAVIS_BRANCH" != "$BRANCH" ]; then 18 | echo "Skipping snapshot deployment: wrong branch. Expected '$BRANCH' but was '$TRAVIS_BRANCH'." 19 | else 20 | echo "Deploying snapshot..." 21 | ./gradlew build 22 | ./gradlew artifactoryPublish 23 | echo "Snapshot deployed!" 24 | fi -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | include("deeplink") 2 | --------------------------------------------------------------------------------