├── .gitignore ├── .idea └── runConfigurations │ ├── Publish_Release_Manually.xml │ ├── Run_Android_Sample.xml │ ├── Run_Desktop_Sample.xml │ └── Run_Unit_Tests.xml ├── .travis.yml ├── LICENSE ├── README.md ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── rxbonjour-drivers ├── rxbonjour-driver-jmdns │ ├── build.gradle │ └── src │ │ └── main │ │ └── kotlin │ │ └── de │ │ └── mannodermaus │ │ └── rxbonjour │ │ └── drivers │ │ └── jmdns │ │ ├── JmDNSBroadcastEngine.kt │ │ ├── JmDNSDiscoveryEngine.kt │ │ └── JmDNSDriver.kt └── rxbonjour-driver-nsdmanager │ ├── build.gradle │ └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── de │ └── mannodermaus │ └── rxbonjour │ └── drivers │ └── nsdmanager │ ├── Exceptions.kt │ ├── Extensions.kt │ ├── NsdManagerBroadcastEngine.kt │ ├── NsdManagerDiscoveryEngine.kt │ └── NsdManagerDriver.kt ├── rxbonjour-platforms ├── rxbonjour-platform-android │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── de │ │ └── mannodermaus │ │ └── rxbonjour │ │ └── platforms │ │ └── android │ │ └── AndroidPlatform.kt └── rxbonjour-platform-desktop │ ├── build.gradle │ └── src │ └── main │ └── kotlin │ └── de │ └── mannodermaus │ └── rxbonjour │ └── platforms │ └── desktop │ ├── DesktopPlatform.kt │ └── RunActionDisposable.kt ├── rxbonjour ├── build.gradle └── src │ ├── main │ └── kotlin │ │ └── de │ │ └── mannodermaus │ │ └── rxbonjour │ │ ├── Driver.kt │ │ ├── Exceptions.kt │ │ ├── Models.kt │ │ ├── Platform.kt │ │ ├── RxBonjour.kt │ │ └── Schedulers.kt │ └── test │ ├── kotlin │ └── de │ │ └── mannodermaus │ │ └── rxbonjour │ │ ├── FakeModels.kt │ │ ├── ModelTests.kt │ │ └── RxBonjourTests.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── samples ├── sample-android │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ └── de │ │ │ └── mannodermaus │ │ │ └── rxbonjour │ │ │ └── samples │ │ │ └── android │ │ │ ├── Adapters.kt │ │ │ ├── MainActivity.kt │ │ │ ├── Models.kt │ │ │ └── RecyclerViews.kt │ │ └── res │ │ ├── layout │ │ ├── activity_main.xml │ │ └── item_bonjourservice.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml └── sample-desktop │ ├── build.gradle │ └── src │ └── main │ └── kotlin │ └── de │ └── mannodermaus │ └── rxbonjour │ └── samples │ └── desktop │ └── App.kt ├── scripts ├── deploy.gradle ├── deploy_release.sh └── deploy_snapshot.sh └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | local.properties 3 | .idea/* 4 | !.idea/runConfigurations 5 | .DS_Store 6 | build/ 7 | /captures 8 | *.iml -------------------------------------------------------------------------------- /.idea/runConfigurations/Publish_Release_Manually.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_Android_Sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 51 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_Desktop_Sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_Unit_Tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | 3 | env: 4 | global: 5 | - bintrayUser=aurae 6 | - secure: VoY/zWE7itJmukAZt1Zw43jFzOk2ssWbpWhydYLiaRvplYVJvXTCSve4ZDpBhxPAlta2Kpfr9H8bwuaYCEtS+ovhi/q8Fo97YXiGdkHwEXJPal2n4O/XEj5hFr1BxIDTEwFBtGD/iQWZL/MVRaWFrfuMWajMZhDQkGz+DwRog4XCaw6UcEofW9kzfPG1T94C/bAIETrad4bfHKSbORVd2SbyPCFHkFH+YB/eWVqK2N5PuiPaet+EzcX/sxAFnwD6kF/uj3KxTlii/Yw8bM6LrPEWyyuMDglHN3LTevZ1CwT1oy8AUZaFzx246SL+Osn6t9WHoL1p3ltTS9O7sNL6WSyCP+JFYXF8iaF9USaINy/vcZFPsKdcCpgLPjSF/4mgXcjPc5KRao3tZOoamyvXuiGg7lfwUBl82t73ItlnSGgInAyUSesVrQ2gJNsVHdRqvOmVPBGXZ3lWb4GHksfQzLO210M7Tiu6leacWEbsYWmAsRER87qdUu7lY2b3wYJB7NhXX5AMZYINSzfCDVrThTWLXUU554D/3TmTKz/ROab2QxR/Qxh5i4ZJ/0MNdcM+qp8ORq6WlwGTUhCmD83XtiuBF3s/JW1WybsqC4Ppo23YuItqgfmMZWk75xLqpi48078vNgtEE/1YE2tvxd7ddfeT3eNLt7qrfj7Hu+r3L5A= 7 | - sonatypeUser=aurae 8 | - secure: C6eYUpK0VYvnJ7FGvGP+2EhqHvUxLAslY6rgjewUihagWxh4Yn8pY2jeDEXvuQHoErkyhOSyEHvUqfzdVa+SuQdo1Ldn9Pzm90wvatWAyQCIkrg9bCyqYgz1Fx2GpwJJCyrqOQVpeR8aYFi0gUh+YbsHiiquwvIMLxpNmtVLYnauDsLcy36nEsixWWAJBPRBchwW3+hukv2ZjvS8VVktxWa1WhknAlVSxDaYhgMRBFbOXZcneP4I2VOYIz4zKAS1tqXF72EEQf74+1Zray/DSbm+LFvLy0eiq3JptxLVk2w5DV3BzeQKQLW0vr2tULzOO8XnTJcwiBHHzMGeSCjRgLDqj6D4wbXAh8YDWVfTHkjWYMMGfSXpS6TWkJhtg3nsmqa7LcH3uyZHxhTrJF4JTjmNlF4XaXrTOZVlAhKgfojMqlEexFW/C1RZxoMry/K5d1N/Pm4/ayFyNHHQ6wpMxXsO8IWEGIU5mkVigpq+9UIhN/HtH6tZ+1UQKsE7bcAfoQcDnrBoeDxAHNetkkueSRt3GpaVMlil2+EzgdBkl6c3ns5GV06m9e9Y4JRW05M+q38o9NV/pbUaljIJ8WPHtIfbGw8L8jwrka/HtI6+7z8ev61Ot/A8DNEmoZb/kUtk2KgD0E5aQYBaQzhxy9EImQiIM5UY6qrcNuqjnZBf3qk= 9 | 10 | android: 11 | components: 12 | - tools 13 | - platform-tools 14 | - build-tools-27.0.0 15 | - android-27 16 | 17 | jdk: 18 | - oraclejdk8 19 | 20 | before_install: 21 | - echo "sdk.dir=$ANDROID_HOME" > local.properties 22 | 23 | script: ./gradlew clean build --stacktrace 24 | 25 | after_success: 26 | - ./scripts/deploy_snapshot.sh 27 | 28 | deploy: 29 | provider: script 30 | script: ./scripts/deploy_release.sh 31 | on: 32 | tags: true 33 | repo: mannodermaus/RxBonjour 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RxBonjour 2 | 3 | [![Travis Build Status](https://travis-ci.org/mannodermaus/RxBonjour.svg?branch=2.x)][travisci] 4 | 5 | ⚠️ THIS PROJECT IS ARCHIVED. No further development is scheduled. 6 | 7 | A reactive wrapper around network service discovery functionalities for Kotlin and Java. 8 | 9 | ## Download 10 | 11 | **RxBonjour 2** is available on `jcenter()` and consists of three distinct components, all of which are detailed below. 12 | 13 | ```groovy 14 | // Always include this 15 | implementation "de.mannodermaus.rxjava2:rxbonjour:2.0.0-RC1" 16 | 17 | // Example: Usage on Android with JmDNS 18 | implementation "de.mannodermaus.rxjava2:rxbonjour-platform-android:2.0.0-RC1" 19 | implementation "de.mannodermaus.rxjava2:rxbonjour-driver-jmdns:2.0.0-RC1" 20 | ``` 21 | 22 | For the (less flexible & Android-only) RxJava 1 version, have a look at the [1.x][onex] branch. 23 | 24 | ## Components 25 | 26 | **RxBonjour 2** is composed of a core library, a `Platform` to run on, and a `Driver` to access the NSD stack. 27 | 28 | ### Core Library (rxbonjour) 29 | 30 | The main entry point to the API, `RxBonjour` is contained in this library. All other libraries depend on this common core. 31 | 32 | ### Platform Library (rxbonjour-platform-xxx) 33 | 34 | Provides access to the host device's IP and Network controls. 35 | During the creation of your `RxBonjour` instance, you attach exactly 1 implementation of the `Platform` interface. 36 | 37 | Below is a list of available `Platform` libraries supported by **RxBonjour 2**: 38 | 39 | |Group|Artifact|Description| 40 | |---|---|---| 41 | |`de.mannodermaus.rxjava2`|`rxbonjour-platform-android`|Android-aware Platform, utilizing `WifiManager` APIs| 42 | |`de.mannodermaus.rxjava2`|`rxbonjour-platform-desktop`|Default JVM Platform| 43 | 44 | #### About the AndroidPlatform 45 | 46 | When running on Android, the `rxbonjour-platform-android` has to be applied to the module. 47 | Doing so will add the following permissions to your `AndroidManifest.xml`: 48 | 49 | ```xml 50 | 51 | 52 | 53 | ``` 54 | 55 | ### Driver Library (rxbonjour-driver-xxx) 56 | 57 | Provides the connection to a Network Service Discovery stack. 58 | During the creation of your `RxBonjour` instance, you attach exactly 1 implementation of the `Driver` interface. 59 | 60 | Below is a list of available `Driver` libraries supported by **RxBonjour 2**: 61 | 62 | |Group|Artifact|Description| 63 | |---|---|---| 64 | |`de.mannodermaus.rxjava2`|`rxbonjour-driver-jmdns`|Service Discovery with [JmDNS][jmdns]| 65 | |`de.mannodermaus.rxjava2`|`rxbonjour-driver-nsdmanager`|Service Discovery with Android's [NsdManager][nsdmanager] APIs| 66 | 67 | ## Usage 68 | 69 | ### Creation 70 | 71 | Configure a `RxBonjour` service object using its `Builder`, 72 | attaching your desired `Platform` and `Driver` implementations. 73 | If you forget to provide either dependency, an Exception will be thrown: 74 | 75 | ```kotlin 76 | val rxBonjour = RxBonjour.Builder() 77 | .platform(AndroidPlatform.create(this)) 78 | .driver(JmDNSDriver.create()) 79 | .create() 80 | ``` 81 | 82 | Your `RxBonjour` is ready for use now! 83 | 84 | ### Discovery 85 | 86 | Create a network service discovery request using `RxBonjour#newDiscovery(String)`: 87 | 88 | ```kotlin 89 | val disposable = rxBonjour.newDiscovery("_http._tcp") 90 | .subscribeOn(Schedulers.io()) 91 | .observeOn(AndroidSchedulers.mainThread()) 92 | .subscribe( 93 | { event -> 94 | when(event) { 95 | is BonjourEvent.Added -> println("Resolved Service: ${event.service}") 96 | is BonjourEvent.Removed -> println("Lost Service: ${event.service}") 97 | } 98 | }, 99 | { error -> println("Error during Discovery: ${error.message}") } 100 | ) 101 | ``` 102 | 103 | Make sure to off-load this work onto a background thread, since the library won't enforce any threading. 104 | In this example, *RxAndroid* is utilized to return the events back to Android's main thread. 105 | 106 | ## Registration 107 | 108 | Configure your advertised service & start the broadcast using `RxBonjour#newBroadcast(BonjourBroadcastConfig)`. 109 | The only required property to set on a `BonjourBroadcastConfig` is its Bonjour type, the remaining parameters 110 | are filled with defaults as stated in the comments below: 111 | 112 | ```kotlin 113 | val broadcastConfig = BonjourBroadcastConfig( 114 | type = "_http._tcp", 115 | name = "My Bonjour Service", // default: "RxBonjour Service" 116 | address = null, // default: Fallback to WiFi address provided by Platform 117 | port = 13337, // default: 80 118 | txtRecords = mapOf( // default: Empty Map 119 | "my.record" to "my value", 120 | "other.record" to "0815")) 121 | 122 | val disposable = rxBonjour.newBroadcast(broadcastConfig) 123 | .subscribeOn(Schedulers.io()) 124 | .observeOn(AndroidSchedulers.mainThread()) 125 | .subscribe() 126 | ``` 127 | 128 | The broadcast is valid until the returned `Completable` is unsubscribed from. 129 | Again, make sure to off-load this work onto a background thread like above, since the library won't do it for you. 130 | 131 | ## License 132 | 133 | Copyright 2017-2018 Marcel Schnelle 134 | 135 | Licensed under the Apache License, Version 2.0 (the "License"); 136 | you may not use this file except in compliance with the License. 137 | You may obtain a copy of the License at 138 | 139 | http://www.apache.org/licenses/LICENSE-2.0 140 | 141 | Unless required by applicable law or agreed to in writing, software 142 | distributed under the License is distributed on an "AS IS" BASIS, 143 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 144 | See the License for the specific language governing permissions and 145 | limitations under the License. 146 | 147 | 148 | [jmdns]: https://github.com/openhab/jmdns 149 | [nsdmanager]: https://developer.android.com/reference/android/net/nsd/NsdManager 150 | [jit]: https://jitpack.io 151 | [onex]: https://github.com/mannodermaus/RxBonjour/tree/1.x 152 | [travisci]: https://travis-ci.org/mannodermaus/RxBonjour 153 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.github.ben-manes.versions" 2 | 3 | buildscript { 4 | repositories { 5 | jcenter() 6 | google() 7 | maven { url "https://jitpack.io" } 8 | maven { url "https://oss.sonatype.org/content/repositories/snapshots" } 9 | } 10 | 11 | dependencies { 12 | classpath "com.android.tools.build:gradle:$GRADLE_PLUGIN_VERSION" 13 | classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:$BINTRAY_PLUGIN_VERSION" 14 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION" 15 | classpath "com.github.dcendents:android-maven-gradle-plugin:$DCENDENTS_MAVEN_PLUGIN_VERSION" 16 | classpath "de.mannodermaus.gradle.plugins:android-junit5:$ANDROID_JUNIT5_PLUGIN_VERSION" 17 | classpath "org.junit.platform:junit-platform-gradle-plugin:$JAVA_JUNIT5_PLUGIN_VERSION" 18 | classpath "digital.wup:android-maven-publish:$ANDROID_MAVEN_PLUGIN_VERSION" 19 | classpath "com.github.ben-manes:gradle-versions-plugin:$VERSIONS_PLUGIN_VERSION" 20 | } 21 | } 22 | 23 | // Populate deployment credentials in an environment-aware fashion. 24 | // 25 | // * Local development: 26 | // Stored in local.properties file on the machine 27 | // * CI Server: 28 | // Stored in environment variables before launch 29 | Properties properties = new Properties() 30 | 31 | def credentialsFile = new File(project.rootDir, "local.properties") 32 | if (credentialsFile.exists()) { 33 | credentialsFile.withReader { properties.load(it) } 34 | } 35 | 36 | def bintrayUser = properties.getProperty("BINTRAY_USER", System.getenv("bintrayUser")) 37 | def bintrayKey = properties.getProperty("BINTRAY_KEY", System.getenv("bintrayKey")) 38 | def sonatypeUser = properties.getProperty("SONATYPE_USER", System.getenv("sonatypeUser")) 39 | def sonatypePass = properties.getProperty("SONATYPE_PASS", System.getenv("sonatypePass")) 40 | 41 | allprojects { 42 | repositories { 43 | google() 44 | jcenter() 45 | } 46 | 47 | // Store deployment credentials 48 | ext.bintrayUser = bintrayUser 49 | ext.bintrayKey = bintrayKey 50 | ext.sonatypeUser = sonatypeUser 51 | ext.sonatypePass = sonatypePass 52 | } 53 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # suppress inspection "UnusedProperty" for whole file 2 | 3 | # "AGP 3.x requires Studio 3.0 minimum" - workaround to use it inside IJ 4 | android.injected.build.model.only.versioned = 3 5 | org.gradle.jvmargs = -Xmx1536M 6 | 7 | # Library metadata 8 | GROUP_ID = de.mannodermaus.rxjava2 9 | ARTIFACT_ID = rxbonjour 10 | VERSION_NAME = 2.0.0-RC2-SNAPSHOT 11 | LIBRARY_NAME = RxBonjour2.x 12 | DESCRIPTION = Reactive spice added to Android's network service discovery API. (Compatible with RxJava 2) 13 | WEB_URL = https://github.com/mannodermaus/RxBonjour 14 | GIT_URL = https://github.com/mannodermaus/RxBonjour.git 15 | LICENSE_ID = Apache-2.0 16 | LICENSE_NAME = The Apache Software License, Version 2.0 17 | LICENSE_URL = http://www.apache.org/licenses/LICENSE-2.0.txt 18 | DEVELOPER_ID = aurae 19 | DEVELOPER_NAME = Marcel Schnelle 20 | DEVELOPER_EMAIL = schnellemarcel@gmail.com 21 | 22 | # Common Android Compile configuration 23 | # -------------------------------------------------------------------------------------------------- 24 | # IMPORTANT: Update Travis CI's config as well if any of these values change! 25 | # -------------------------------------------------------------------------------------------------- 26 | COMPILE_SDK_VERSION = android-27 27 | BUILD_TOOLS_VERSION = 27.0.0 28 | TARGET_SDK_VERSION = 27 29 | # -------------------------------------------------------------------------------------------------- 30 | 31 | KOTLIN_VERSION = 1.1.51 32 | 33 | # Dependency versions (plugins) 34 | GRADLE_PLUGIN_VERSION = 3.0.0 35 | BINTRAY_PLUGIN_VERSION = 1.7.3 36 | ANDROID_MAVEN_PLUGIN_VERSION = 3.3.0 37 | DCENDENTS_MAVEN_PLUGIN_VERSION = 2.0 38 | JAVA_JUNIT5_PLUGIN_VERSION = 1.0.1 39 | ANDROID_JUNIT5_PLUGIN_VERSION = 1.0.30 40 | VERSIONS_PLUGIN_VERSION = 0.15.0 41 | 42 | # Dependency versions (rxbonjour) 43 | RXJAVA_VERSION = 2.1.5 44 | 45 | # Dependency versions (rxbonjour-platform-android) 46 | SUPPORT_LIBRARY_VERSION = 27.0.0 47 | RXANDROID_VERSION = 2.0.1 48 | 49 | # Dependency versions (rxbonjour-driver-jmdns) 50 | JMDNS_JAR_VERSION = 3.5.3 51 | SLF4J_VERSION = 1.7.25 52 | 53 | # Dependency versions (test) 54 | JUNIT_PLATFORM_VERSION = 1.0.1 55 | JUNIT_JUPITER_VERSION = 5.0.1 56 | JUNIT_VINTAGE_VERSION = 4.12.1 57 | JUNIT5_EMBEDDED_RUNTIME_VERSION = 1.0.30 58 | MOCKITO_VERSION = 2.11.0 59 | 60 | # Dependency versions (example) 61 | BUTTERKNIFE_VERSION = 8.8.1 62 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mannodermaus/RxBonjour/3eb4194a8efc19d86c2a5bfc21720d77cc08f08f/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.5.1-bin.zip 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="" 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= 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 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-jmdns/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "java-library" 2 | apply plugin: "kotlin" 3 | apply plugin: "org.junit.platform.gradle.plugin" 4 | 5 | dependencies { 6 | implementation project(":rxbonjour") 7 | implementation("org.jmdns:jmdns:$JMDNS_JAR_VERSION") { 8 | exclude group: "org.slf4j" 9 | } 10 | implementation "org.slf4j:slf4j-nop:$SLF4J_VERSION" 11 | 12 | testImplementation "org.junit.jupiter:junit-jupiter-api:$JUNIT_JUPITER_VERSION" 13 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$JUNIT_JUPITER_VERSION" 14 | testCompileOnly "de.mannodermaus.gradle.plugins:android-junit5-embedded-runtime:$JUNIT5_EMBEDDED_RUNTIME_VERSION" 15 | } 16 | 17 | // Deployment Setup 18 | ext.artifact = "$ARTIFACT_ID-driver-jmdns" 19 | ext.targetPlatform = "java" 20 | 21 | apply from: "$rootDir/scripts/deploy.gradle" 22 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-jmdns/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/jmdns/JmDNSBroadcastEngine.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.jmdns 2 | 3 | import de.mannodermaus.rxbonjour.BonjourBroadcastConfig 4 | import de.mannodermaus.rxbonjour.BonjourSchedulers 5 | import de.mannodermaus.rxbonjour.BroadcastCallback 6 | import de.mannodermaus.rxbonjour.BroadcastEngine 7 | import io.reactivex.Completable 8 | import java.net.InetAddress 9 | import javax.jmdns.JmDNS 10 | import javax.jmdns.ServiceInfo 11 | 12 | private val LOCAL_DOMAIN_SUFFIX = ".local." 13 | 14 | internal class JmDNSBroadcastEngine : BroadcastEngine { 15 | 16 | private var jmdns: JmDNS? = null 17 | private var jmdnsService: ServiceInfo? = null 18 | 19 | override fun initialize() { 20 | } 21 | 22 | override fun start(address: InetAddress, config: BonjourBroadcastConfig, 23 | callback: BroadcastCallback) { 24 | val jmdns = JmDNS.create(address, address.toString()) 25 | this.jmdns = jmdns 26 | this.jmdnsService = config.toJmDNSModel() 27 | 28 | // This will start the broadcast immediately 29 | jmdns.registerService(jmdnsService) 30 | } 31 | 32 | override fun teardown() { 33 | jmdns?.let { jmdns -> 34 | jmdnsService?.let { jmdns.unregisterService(it) } 35 | 36 | // Closing JmDNS might take a while, so defer it to a background thread 37 | Completable.fromAction { jmdns.close() } 38 | .compose(BonjourSchedulers.completableAsync()) 39 | .onErrorComplete() 40 | .subscribe() 41 | } 42 | } 43 | } 44 | 45 | /* Extension Functions */ 46 | 47 | private fun String.ensureLocalDomain(): String = 48 | if (this.endsWith(LOCAL_DOMAIN_SUFFIX)) 49 | this 50 | else 51 | "$this$LOCAL_DOMAIN_SUFFIX" 52 | 53 | private fun BonjourBroadcastConfig.toJmDNSModel() = ServiceInfo.create( 54 | /* type */ this.type.ensureLocalDomain(), 55 | /* name */ this.name, 56 | /* port */ this.port, 57 | /* weight */ 0, 58 | /* priority */ 0, 59 | /* persistent */ true, 60 | /* props */ this.txtRecords) 61 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-jmdns/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/jmdns/JmDNSDiscoveryEngine.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.jmdns 2 | 3 | import de.mannodermaus.rxbonjour.BonjourSchedulers 4 | import de.mannodermaus.rxbonjour.BonjourService 5 | import de.mannodermaus.rxbonjour.DiscoveryCallback 6 | import de.mannodermaus.rxbonjour.DiscoveryEngine 7 | import io.reactivex.Completable 8 | import java.net.InetAddress 9 | import java.util.logging.Level 10 | import java.util.logging.Logger 11 | import javax.jmdns.JmDNS 12 | import javax.jmdns.ServiceEvent 13 | import javax.jmdns.ServiceInfo 14 | import javax.jmdns.ServiceListener 15 | import javax.jmdns.impl.DNSIncoming 16 | import javax.jmdns.impl.constants.DNSRecordClass 17 | import javax.jmdns.impl.constants.DNSRecordType 18 | 19 | private val BONJOUR_TYPE_LOCAL_SUFFIX = ".local." 20 | 21 | internal class JmDNSDiscoveryEngine 22 | constructor(type: String) : DiscoveryEngine { 23 | 24 | // Append type suffix in order to have JmDNS pick up on the resolved services 25 | private val serviceType = if (type.endsWith( 26 | BONJOUR_TYPE_LOCAL_SUFFIX)) type else type + BONJOUR_TYPE_LOCAL_SUFFIX 27 | 28 | private var jmdns: JmDNS? = null 29 | private var listener: JmDNSListener? = null 30 | 31 | override fun initialize() { 32 | // Disable logging for some JmDNS classes, since those severely clutter log output 33 | Logger.getLogger(DNSIncoming::class.java.name).level = Level.OFF 34 | Logger.getLogger(DNSRecordType::class.java.name).level = Level.OFF 35 | Logger.getLogger(DNSRecordClass::class.java.name).level = Level.OFF 36 | Logger.getLogger(DNSIncoming.MessageInputStream::class.java.name).level = Level.OFF 37 | } 38 | 39 | override fun discover(address: InetAddress, callback: DiscoveryCallback) { 40 | val jmdns = JmDNS.create(address, address.toString()) 41 | this.jmdns = jmdns 42 | this.listener = JmDNSListener(callback) 43 | 44 | // This will start the discovery immediately 45 | jmdns.addServiceListener(serviceType, listener) 46 | } 47 | 48 | override fun teardown() { 49 | // Remove service listener & shut down JmDNS 50 | jmdns?.let { jmdns -> 51 | listener?.let { jmdns.removeServiceListener(serviceType, it) } 52 | 53 | // Closing JmDNS might take a while, so defer it to a background thread 54 | Completable.fromAction { jmdns.close() } 55 | .compose(BonjourSchedulers.completableAsync()) 56 | .onErrorComplete() 57 | .subscribe() 58 | } 59 | } 60 | 61 | private class JmDNSListener(val callback: DiscoveryCallback) : ServiceListener { 62 | 63 | override fun serviceAdded(event: ServiceEvent) { 64 | // Resolve the service's info, don't call through with success yet 65 | event.dns.requestServiceInfo(event.type, event.name) 66 | } 67 | 68 | override fun serviceRemoved(event: ServiceEvent) { 69 | callback.serviceLost(event.info.toLibraryModel()) 70 | } 71 | 72 | override fun serviceResolved(event: ServiceEvent) { 73 | callback.serviceResolved(event.info.toLibraryModel()) 74 | } 75 | } 76 | } 77 | 78 | /* Extension Functions */ 79 | 80 | // Mapping between JmDNS namespace & RxBonjour model type 81 | private fun ServiceInfo.toLibraryModel() = BonjourService( 82 | name = this.name, 83 | type = this.type, 84 | v4Host = this.inet4Addresses.firstOrNull(), 85 | v6Host = this.inet6Addresses.firstOrNull(), 86 | port = this.port, 87 | txtRecords = this.propertyNames 88 | .toList() 89 | .associate { Pair(it, this.getPropertyString(it)) }) 90 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-jmdns/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/jmdns/JmDNSDriver.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.jmdns 2 | 3 | import de.mannodermaus.rxbonjour.BroadcastEngine 4 | import de.mannodermaus.rxbonjour.DiscoveryEngine 5 | import de.mannodermaus.rxbonjour.Driver 6 | 7 | /** 8 | * RxBonjour Driver implementation using JmDNS for Network Service Discovery. 9 | */ 10 | class JmDNSDriver private constructor() : Driver { 11 | override val name: String = "jmdns" 12 | override fun createDiscovery(type: String): DiscoveryEngine = JmDNSDiscoveryEngine(type) 13 | override fun createBroadcast(): BroadcastEngine = JmDNSBroadcastEngine() 14 | 15 | companion object { 16 | @JvmStatic 17 | fun create(): Driver = JmDNSDriver() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-nsdmanager/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.android.library" 2 | apply plugin: "kotlin-android" 3 | apply plugin: "de.mannodermaus.android-junit5" 4 | 5 | android { 6 | compileSdkVersion COMPILE_SDK_VERSION 7 | buildToolsVersion BUILD_TOOLS_VERSION 8 | 9 | defaultConfig { 10 | minSdkVersion 16 11 | targetSdkVersion TARGET_SDK_VERSION 12 | versionName VERSION_NAME 13 | } 14 | 15 | testOptions { 16 | unitTests.returnDefaultValues = true 17 | } 18 | 19 | sourceSets { 20 | main.java.srcDirs += "src/main/kotlin" 21 | } 22 | } 23 | 24 | dependencies { 25 | implementation project(":rxbonjour") 26 | 27 | testImplementation junit5.unitTests() 28 | testCompileOnly junit5.unitTestsRuntime() 29 | } 30 | 31 | // Deployment Setup 32 | ext.artifact = "$ARTIFACT_ID-driver-nsdmanager" 33 | ext.targetPlatform = "android" 34 | 35 | apply from: "$rootDir/scripts/deploy.gradle" 36 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-nsdmanager/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-nsdmanager/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/nsdmanager/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.nsdmanager 2 | 3 | class NsdDiscoveryException(code: Int) : RuntimeException("NsdManager Discovery error (code=$code)") 4 | class NsdBroadcastException(code: Int) : RuntimeException("NsdManager Broadcast error (code=$code)") 5 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-nsdmanager/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/nsdmanager/Extensions.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.nsdmanager 2 | 3 | import android.content.Context 4 | import android.net.nsd.NsdManager 5 | import android.net.nsd.NsdServiceInfo 6 | import android.os.Build 7 | import de.mannodermaus.rxbonjour.BonjourBroadcastConfig 8 | import de.mannodermaus.rxbonjour.BonjourService 9 | import de.mannodermaus.rxbonjour.TxtRecords 10 | import java.net.Inet4Address 11 | import java.net.Inet6Address 12 | import java.nio.charset.Charset 13 | 14 | internal fun Context.getNsdManager() = this.getSystemService(Context.NSD_SERVICE) as NsdManager 15 | 16 | internal fun NsdServiceInfo.getTxtRecords(): TxtRecords { 17 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 18 | // NSD Attributes only available on API 21+ 19 | this.attributes 20 | .map { (key, bytes) -> Pair(key, String(bytes, Charset.forName("UTF-8"))) } 21 | .associate { it } 22 | 23 | } else { 24 | emptyMap() 25 | } 26 | } 27 | 28 | internal fun NsdServiceInfo.setTxtRecords(records: TxtRecords?) { 29 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 30 | records?.forEach { (key, value) -> 31 | setAttribute(key, value) 32 | } 33 | } 34 | } 35 | 36 | internal fun NsdServiceInfo.toLibraryModel() = BonjourService( 37 | name = this.serviceName, 38 | type = this.serviceType, 39 | v4Host = if (this.host is Inet4Address) this.host as Inet4Address else null, 40 | v6Host = if (this.host is Inet6Address) this.host as Inet6Address else null, 41 | port = this.port, 42 | txtRecords = this.getTxtRecords()) 43 | 44 | internal fun BonjourBroadcastConfig.toNsdModel() = NsdServiceInfo().apply { 45 | val model = this@toNsdModel 46 | 47 | serviceType = model.type 48 | serviceName = model.name 49 | host = model.address 50 | port = model.port 51 | setTxtRecords(model.txtRecords) 52 | } 53 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-nsdmanager/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/nsdmanager/NsdManagerBroadcastEngine.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.nsdmanager 2 | 3 | import android.content.Context 4 | import android.net.nsd.NsdManager 5 | import android.net.nsd.NsdServiceInfo 6 | import de.mannodermaus.rxbonjour.BonjourBroadcastConfig 7 | import de.mannodermaus.rxbonjour.BroadcastCallback 8 | import de.mannodermaus.rxbonjour.BroadcastEngine 9 | import java.net.InetAddress 10 | 11 | internal class NsdManagerBroadcastEngine(private val context: Context) : BroadcastEngine { 12 | 13 | private var nsdManager: NsdManager? = null 14 | private var listener: NsdRegistrationListener? = null 15 | 16 | override fun initialize() { 17 | } 18 | 19 | override fun start(address: InetAddress, config: BonjourBroadcastConfig, 20 | callback: BroadcastCallback) { 21 | val nsdManager = context.getNsdManager() 22 | val listener = NsdRegistrationListener(callback) 23 | 24 | this.nsdManager = nsdManager 25 | this.listener = listener 26 | 27 | val nsdService = config.copy(address = config.address ?: address).toNsdModel() 28 | nsdManager.registerService(nsdService, NsdManager.PROTOCOL_DNS_SD, listener) 29 | } 30 | 31 | override fun teardown() { 32 | try { 33 | nsdManager?.unregisterService(listener) 34 | } catch (ignored: IllegalArgumentException) { 35 | } 36 | } 37 | 38 | private class NsdRegistrationListener( 39 | private val callback: BroadcastCallback) : NsdManager.RegistrationListener { 40 | 41 | override fun onRegistrationFailed(p0: NsdServiceInfo?, code: Int) { 42 | callback.broadcastFailed(NsdBroadcastException(code)) 43 | } 44 | 45 | override fun onUnregistrationFailed(p0: NsdServiceInfo?, code: Int) { 46 | callback.broadcastFailed(NsdBroadcastException(code)) 47 | } 48 | 49 | override fun onServiceUnregistered(service: NsdServiceInfo) { 50 | } 51 | 52 | override fun onServiceRegistered(p0: NsdServiceInfo) { 53 | } 54 | 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-nsdmanager/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/nsdmanager/NsdManagerDiscoveryEngine.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.nsdmanager 2 | 3 | import android.content.Context 4 | import android.net.nsd.NsdManager 5 | import android.net.nsd.NsdServiceInfo 6 | import de.mannodermaus.rxbonjour.BonjourSchedulers 7 | import de.mannodermaus.rxbonjour.DiscoveryCallback 8 | import de.mannodermaus.rxbonjour.DiscoveryEngine 9 | import io.reactivex.disposables.Disposable 10 | import io.reactivex.subjects.BehaviorSubject 11 | import java.net.InetAddress 12 | import java.util.concurrent.LinkedBlockingQueue 13 | import java.util.concurrent.atomic.AtomicBoolean 14 | 15 | private val BACKLOG_QUEUE_SIZE = 32 16 | 17 | internal class NsdManagerDiscoveryEngine( 18 | private val context: Context, 19 | private val type: String) : DiscoveryEngine { 20 | 21 | private var nsdManager: NsdManager? = null 22 | private var listener: NsdDiscoveryListener? = null 23 | private var resolveBacklog: NsdResolveBacklog? = null 24 | 25 | override fun initialize() { 26 | } 27 | 28 | override fun discover(address: InetAddress, callback: DiscoveryCallback) { 29 | val nsdManager = context.getNsdManager() 30 | val resolveBacklog = NsdResolveBacklog(nsdManager, callback) 31 | 32 | this.nsdManager = nsdManager 33 | this.resolveBacklog = resolveBacklog 34 | this.listener = NsdDiscoveryListener(callback, resolveBacklog) 35 | 36 | nsdManager.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener) 37 | } 38 | 39 | override fun teardown() { 40 | try { 41 | nsdManager?.stopServiceDiscovery(listener) 42 | } catch (ignored: Exception) { 43 | // "Service discovery not active on discoveryListener", 44 | // thrown if starting the service discovery was unsuccessful earlier 45 | } finally { 46 | resolveBacklog?.quit() 47 | } 48 | } 49 | 50 | private class NsdDiscoveryListener(val callback: DiscoveryCallback, 51 | val backlog: NsdResolveBacklog) : NsdManager.DiscoveryListener { 52 | override fun onServiceFound(service: NsdServiceInfo) { 53 | // Add the found service to the resolve backlog 54 | // (it'll be processed once the backlog gets to it). 55 | // The NsdServiceInfo passed to this method doesn't really have a lot of info, 56 | // so the callback isn't triggered from here directly. Instead, 57 | // the "serviceResolved" event happens inside the NsdResolveBacklog 58 | backlog.add(service) 59 | } 60 | 61 | override fun onServiceLost(service: NsdServiceInfo) { 62 | callback.serviceLost(service.toLibraryModel()) 63 | } 64 | 65 | override fun onStartDiscoveryFailed(type: String?, code: Int) { 66 | callback.discoveryFailed(NsdDiscoveryException(code)) 67 | } 68 | 69 | override fun onStopDiscoveryFailed(type: String?, code: Int) { 70 | callback.discoveryFailed(NsdDiscoveryException(code)) 71 | } 72 | 73 | override fun onDiscoveryStarted(p0: String?) { 74 | } 75 | 76 | override fun onDiscoveryStopped(p0: String?) { 77 | } 78 | } 79 | } 80 | 81 | // Linear Processor of found NsdServiceInfo objects. 82 | // Necessary because of NsdManager's "one resolve at a time" limitation 83 | private class NsdResolveBacklog( 84 | private val nsdManager: NsdManager, 85 | private val callback: DiscoveryCallback) { 86 | 87 | /* Marker objects for processing the queue */ 88 | 89 | object NEXT 90 | object STOP 91 | 92 | private val queue = EvictingQueue() 93 | private val subject = BehaviorSubject.create() 94 | private val disposable: Disposable 95 | private val idle = AtomicBoolean(true) 96 | 97 | init { 98 | disposable = subject 99 | .compose(BonjourSchedulers.observableAsync()) 100 | .subscribe { 101 | try { 102 | // Take the next item pushed to the queue & abort if STOP marker. 103 | // This call blocks 104 | val next = queue.take() 105 | when (next) { 106 | is NsdServiceInfo -> { 107 | // Resolve the service 108 | idle.set(false) 109 | nsdManager.resolveService(next, object : NsdManager.ResolveListener { 110 | override fun onResolveFailed(p0: NsdServiceInfo?, p1: Int) { 111 | } 112 | 113 | override fun onServiceResolved(service: NsdServiceInfo) { 114 | callback.serviceResolved(service.toLibraryModel()) 115 | proceed() 116 | } 117 | 118 | }) 119 | } 120 | STOP -> throw InterruptedException() 121 | } 122 | 123 | } catch (ignored: InterruptedException) { 124 | } 125 | } 126 | } 127 | 128 | /** Terminates the work of this backlog instance */ 129 | fun quit() { 130 | // Send the STOP signal 131 | queue.clear() 132 | queue.add(STOP) 133 | subject.onComplete() 134 | disposable.dispose() 135 | } 136 | 137 | /** Adds the provided item to the backlog's queue for processing */ 138 | fun add(service: NsdServiceInfo) { 139 | queue.add(service) 140 | if (idle.get()) { 141 | proceed() 142 | } 143 | } 144 | 145 | /** Signalizes that the backlog can proceed with the next item */ 146 | fun proceed() { 147 | idle.set(true) 148 | subject.onNext(NEXT) 149 | } 150 | } 151 | 152 | // Queue Implementation that automatically evicts 153 | // the oldest element when trying to push beyond its capacity. 154 | private class EvictingQueue(size: Int = BACKLOG_QUEUE_SIZE) : LinkedBlockingQueue(size) { 155 | override fun add(element: T): Boolean { 156 | if (remainingCapacity() == 0) remove() 157 | return super.add(element) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /rxbonjour-drivers/rxbonjour-driver-nsdmanager/src/main/kotlin/de/mannodermaus/rxbonjour/drivers/nsdmanager/NsdManagerDriver.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.drivers.nsdmanager 2 | 3 | import android.content.Context 4 | import de.mannodermaus.rxbonjour.BroadcastEngine 5 | import de.mannodermaus.rxbonjour.DiscoveryEngine 6 | import de.mannodermaus.rxbonjour.Driver 7 | 8 | /** 9 | * RxBonjour Driver implementation using Android's NsdManager API. 10 | */ 11 | class NsdManagerDriver private constructor(val context: Context) : Driver { 12 | override val name: String = "nsdmanager" 13 | override fun createDiscovery(type: String): DiscoveryEngine = NsdManagerDiscoveryEngine(context, 14 | type) 15 | 16 | override fun createBroadcast(): BroadcastEngine = NsdManagerBroadcastEngine(context) 17 | 18 | companion object { 19 | @JvmStatic 20 | fun create(context: Context): Driver = NsdManagerDriver(context) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /rxbonjour-platforms/rxbonjour-platform-android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.android.library" 2 | apply plugin: "kotlin-android" 3 | apply plugin: "de.mannodermaus.android-junit5" 4 | 5 | android { 6 | compileSdkVersion COMPILE_SDK_VERSION 7 | buildToolsVersion BUILD_TOOLS_VERSION 8 | 9 | defaultConfig { 10 | minSdkVersion 14 11 | targetSdkVersion TARGET_SDK_VERSION 12 | versionName VERSION_NAME 13 | } 14 | 15 | testOptions { 16 | unitTests.returnDefaultValues = true 17 | } 18 | 19 | sourceSets { 20 | main.java.srcDirs += "src/main/kotlin" 21 | } 22 | } 23 | 24 | dependencies { 25 | implementation project(":rxbonjour") 26 | implementation "io.reactivex.rxjava2:rxandroid:$RXANDROID_VERSION" 27 | 28 | testImplementation junit5.unitTests() 29 | testCompileOnly junit5.unitTestsRuntime() 30 | } 31 | 32 | // Deployment Setup 33 | ext.artifact = "$ARTIFACT_ID-platform-android" 34 | ext.targetPlatform = "android" 35 | 36 | apply from: "$rootDir/scripts/deploy.gradle" 37 | -------------------------------------------------------------------------------- /rxbonjour-platforms/rxbonjour-platform-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /rxbonjour-platforms/rxbonjour-platform-android/src/main/kotlin/de/mannodermaus/rxbonjour/platforms/android/AndroidPlatform.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.platforms.android 2 | 3 | import android.content.Context 4 | import android.net.wifi.WifiManager 5 | import de.mannodermaus.rxbonjour.Platform 6 | import de.mannodermaus.rxbonjour.PlatformConnection 7 | import io.reactivex.android.MainThreadDisposable 8 | import io.reactivex.disposables.Disposable 9 | import java.net.InetAddress 10 | 11 | private val WIFI_MULTICAST_LOCK_TAG = "RxBonjour Android Multicast Lock" 12 | 13 | class AndroidPlatform 14 | private constructor(private val context: Context) : Platform { 15 | 16 | override fun createConnection(): PlatformConnection = AndroidConnection(context) 17 | 18 | override fun getWifiAddress(): InetAddress { 19 | val intAddress = context.getWifiManager().connectionInfo.ipAddress 20 | val byteAddress = byteArrayOf( 21 | (intAddress and 0xff).toByte(), 22 | (intAddress shr 8 and 0xff).toByte(), 23 | (intAddress shr 16 and 0xff).toByte(), 24 | (intAddress shr 24 and 0xff).toByte()) 25 | return InetAddress.getByAddress(byteAddress) 26 | } 27 | 28 | override fun runOnTeardown(action: () -> Unit): Disposable? = object : MainThreadDisposable() { 29 | override fun onDispose() { 30 | action.invoke() 31 | } 32 | } 33 | 34 | companion object { 35 | @JvmStatic 36 | fun create(context: Context): Platform = AndroidPlatform(context) 37 | } 38 | } 39 | 40 | private class AndroidConnection(val context: Context) : PlatformConnection { 41 | 42 | private lateinit var multicastLock: WifiManager.MulticastLock 43 | 44 | override fun initialize() { 45 | multicastLock = context.getWifiManager().createMulticastLock(WIFI_MULTICAST_LOCK_TAG) 46 | multicastLock.setReferenceCounted(true) 47 | multicastLock.acquire() 48 | } 49 | 50 | override fun teardown() { 51 | multicastLock.release() 52 | } 53 | } 54 | 55 | /* Extension Functions */ 56 | 57 | private fun Context.getWifiManager(): WifiManager = 58 | this.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager 59 | -------------------------------------------------------------------------------- /rxbonjour-platforms/rxbonjour-platform-desktop/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "java-library" 2 | apply plugin: "kotlin" 3 | apply plugin: "org.junit.platform.gradle.plugin" 4 | 5 | dependencies { 6 | implementation project(":rxbonjour") 7 | 8 | testImplementation "org.junit.jupiter:junit-jupiter-api:$JUNIT_JUPITER_VERSION" 9 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$JUNIT_JUPITER_VERSION" 10 | testCompileOnly "de.mannodermaus.gradle.plugins:android-junit5-embedded-runtime:$JUNIT5_EMBEDDED_RUNTIME_VERSION" 11 | } 12 | 13 | // Deployment Setup 14 | ext.artifact = "$ARTIFACT_ID-platform-desktop" 15 | ext.targetPlatform = "java" 16 | 17 | apply from: "$rootDir/scripts/deploy.gradle" 18 | -------------------------------------------------------------------------------- /rxbonjour-platforms/rxbonjour-platform-desktop/src/main/kotlin/de/mannodermaus/rxbonjour/platforms/desktop/DesktopPlatform.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.platforms.desktop 2 | 3 | import de.mannodermaus.rxbonjour.Platform 4 | import de.mannodermaus.rxbonjour.PlatformConnection 5 | import io.reactivex.disposables.Disposable 6 | import java.net.InetAddress 7 | 8 | class DesktopPlatform private constructor() : Platform { 9 | override fun createConnection(): PlatformConnection = DesktopConnection() 10 | 11 | override fun getWifiAddress(): InetAddress { 12 | // TODO How to go about this? 13 | return InetAddress.getLocalHost() 14 | } 15 | 16 | override fun runOnTeardown(action: () -> Unit): Disposable = RunActionDisposable(action) 17 | 18 | companion object { 19 | @JvmStatic 20 | fun create(): Platform = DesktopPlatform() 21 | } 22 | } 23 | 24 | private class DesktopConnection : PlatformConnection { 25 | 26 | override fun initialize() { 27 | } 28 | 29 | override fun teardown() { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /rxbonjour-platforms/rxbonjour-platform-desktop/src/main/kotlin/de/mannodermaus/rxbonjour/platforms/desktop/RunActionDisposable.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.platforms.desktop 2 | 3 | import io.reactivex.disposables.Disposable 4 | import java.util.concurrent.atomic.AtomicBoolean 5 | 6 | internal class RunActionDisposable(private val action: () -> Unit) : Disposable { 7 | 8 | private val disposed: AtomicBoolean = AtomicBoolean(false) 9 | 10 | override fun dispose() { 11 | if (disposed.compareAndSet(false, true)) { 12 | action.invoke() 13 | } 14 | } 15 | 16 | override fun isDisposed(): Boolean = disposed.get() 17 | } 18 | -------------------------------------------------------------------------------- /rxbonjour/build.gradle: -------------------------------------------------------------------------------- 1 | import org.junit.platform.console.options.Details 2 | 3 | apply plugin: "java-library" 4 | apply plugin: "kotlin" 5 | apply plugin: "org.junit.platform.gradle.plugin" 6 | 7 | dependencies { 8 | api "org.jetbrains.kotlin:kotlin-stdlib:$KOTLIN_VERSION" 9 | api "io.reactivex.rxjava2:rxjava:$RXJAVA_VERSION" 10 | 11 | testImplementation "org.mockito:mockito-core:$MOCKITO_VERSION" 12 | testImplementation "org.junit.jupiter:junit-jupiter-api:$JUNIT_JUPITER_VERSION" 13 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$JUNIT_JUPITER_VERSION" 14 | } 15 | 16 | // Deployment Setup 17 | ext.artifact = ARTIFACT_ID 18 | ext.targetPlatform = "java" 19 | 20 | apply from: "$rootDir/scripts/deploy.gradle" 21 | 22 | junitPlatform { 23 | details Details.VERBOSE 24 | } 25 | -------------------------------------------------------------------------------- /rxbonjour/src/main/kotlin/de/mannodermaus/rxbonjour/Driver.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | import java.net.InetAddress 4 | 5 | interface Driver { 6 | val name: String 7 | fun createDiscovery(type: String): DiscoveryEngine 8 | fun createBroadcast(): BroadcastEngine 9 | } 10 | 11 | interface Engine { 12 | fun initialize() 13 | fun teardown() 14 | } 15 | 16 | interface DiscoveryEngine : Engine { 17 | fun discover(address: InetAddress, callback: DiscoveryCallback) 18 | } 19 | 20 | interface DiscoveryCallback { 21 | fun discoveryFailed(cause: Exception?) 22 | fun serviceResolved(service: BonjourService) 23 | fun serviceLost(service: BonjourService) 24 | } 25 | 26 | interface BroadcastEngine : Engine { 27 | fun start(address: InetAddress, config: BonjourBroadcastConfig, callback: BroadcastCallback) 28 | } 29 | 30 | interface BroadcastCallback { 31 | fun broadcastFailed(cause: Exception?) 32 | } 33 | -------------------------------------------------------------------------------- /rxbonjour/src/main/kotlin/de/mannodermaus/rxbonjour/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | class IllegalBonjourTypeException(type: String) 4 | : RuntimeException("The following is not a valid Bonjour type: $type") 5 | 6 | class DiscoveryFailedException(driverName: String, cause: Exception?) 7 | : RuntimeException("Service Discovery Driver '$driverName' failed with an unrecoverable error" + 8 | if (cause != null) ": ${cause.message}" else "", cause) 9 | 10 | class BroadcastFailedException(driverName: String, cause: Exception?) 11 | : RuntimeException("Service Broadcast Driver '$driverName' failed with an unrecoverable error" + 12 | if (cause != null) ": ${cause.message}" else "", cause) 13 | -------------------------------------------------------------------------------- /rxbonjour/src/main/kotlin/de/mannodermaus/rxbonjour/Models.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | import java.net.Inet4Address 4 | import java.net.Inet6Address 5 | import java.net.InetAddress 6 | 7 | typealias TxtRecords = Map 8 | 9 | private val DEFAULT_NAME = "RxBonjour Service" 10 | private val DEFAULT_PORT = 80 11 | 12 | data class BonjourBroadcastConfig @JvmOverloads constructor( 13 | val type: String, 14 | val name: String = DEFAULT_NAME, 15 | val address: InetAddress? = null, 16 | val port: Int = DEFAULT_PORT, 17 | val txtRecords: TxtRecords? = emptyMap()) 18 | 19 | data class BonjourService( 20 | val type: String, 21 | val name: String, 22 | val v4Host: Inet4Address?, 23 | val v6Host: Inet6Address?, 24 | val port: Int, 25 | val txtRecords: TxtRecords = emptyMap()) { 26 | 27 | val host: InetAddress? = v4Host ?: v6Host 28 | } 29 | 30 | sealed class BonjourEvent(val service: BonjourService) { 31 | class Added(service: BonjourService) : BonjourEvent(service) 32 | class Removed(service: BonjourService) : BonjourEvent(service) 33 | 34 | override fun toString(): String = "BonjourEvent{${javaClass.simpleName}: $service}" 35 | } 36 | -------------------------------------------------------------------------------- /rxbonjour/src/main/kotlin/de/mannodermaus/rxbonjour/Platform.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | import io.reactivex.disposables.Disposable 4 | import java.net.InetAddress 5 | 6 | interface Platform { 7 | fun getWifiAddress(): InetAddress 8 | fun runOnTeardown(action: () -> Unit): Disposable? 9 | fun createConnection(): PlatformConnection 10 | } 11 | 12 | interface PlatformConnection { 13 | fun initialize() 14 | fun teardown() 15 | } 16 | -------------------------------------------------------------------------------- /rxbonjour/src/main/kotlin/de/mannodermaus/rxbonjour/RxBonjour.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("RxBonjourUtils") 2 | 3 | package de.mannodermaus.rxbonjour 4 | 5 | import io.reactivex.Completable 6 | import io.reactivex.Observable 7 | 8 | /* Extensions & Constants */ 9 | 10 | private val TYPE_PATTERN = Regex("_[a-zA-Z0-9\\-_]+\\.(_tcp|_udp)(\\.[a-zA-Z0-9\\-]+\\.)?") 11 | 12 | /* Classes */ 13 | 14 | /** 15 | * RxBonjour: Reactive access to Network Service Discovery APIs using RxJava 2. 16 | * Obtain an instance through its Builder, configure it by injecting a Driver & a Platform 17 | * and create it! After creation, use the instance to start network service discovery 18 | * or broadcast operations. 19 | */ 20 | class RxBonjour private constructor( 21 | private val platform: Platform, 22 | private val driver: Driver) { 23 | 24 | /** 25 | * Starts a Bonjour service discovery for the provided service type with the given {@link Driver}. 26 | *

27 | * The stream will immediately end with an {@link IllegalBonjourTypeException} 28 | * if the input type does not obey Bonjour type specifications. 29 | * If you intend to use this method with arbitrary types that can be provided by user input, 30 | * it is highly encouraged to verify this input 31 | * using {@link #isBonjourType(String)} before calling this method! 32 | * 33 | * @param type Type of service to discover 34 | * @return An {@link Observable} of {@link BonjourEvent}s for the specific type 35 | */ 36 | fun newDiscovery(type: String): Observable = 37 | if (type.isBonjourType()) { 38 | // New Discovery request for the Driver 39 | val discovery = driver.createDiscovery(type) 40 | val connection = platform.createConnection() 41 | 42 | Observable.defer { 43 | Observable.create { emitter -> 44 | // Initialization 45 | discovery.initialize() 46 | connection.initialize() 47 | 48 | // Destruction 49 | val disposable = platform.runOnTeardown { 50 | discovery.teardown() 51 | connection.teardown() 52 | } 53 | emitter.setDisposable(disposable) 54 | 55 | // Lifetime 56 | val callback = object : DiscoveryCallback { 57 | override fun discoveryFailed(cause: Exception?) { 58 | // Abort stream 59 | emitter.onError(DiscoveryFailedException(driver.name, cause)) 60 | } 61 | 62 | override fun serviceResolved(service: BonjourService) { 63 | // Convert to event 64 | emitter.onNext(BonjourEvent.Added(service)) 65 | } 66 | 67 | override fun serviceLost(service: BonjourService) { 68 | // Convert to event 69 | emitter.onNext(BonjourEvent.Removed(service)) 70 | } 71 | } 72 | 73 | try { 74 | val address = platform.getWifiAddress() 75 | discovery.discover(address, callback) 76 | } catch (ex: Exception) { 77 | callback.discoveryFailed(ex) 78 | } 79 | } 80 | } 81 | 82 | } else { 83 | // Not a Bonjour type 84 | Observable.error(IllegalBonjourTypeException(type)) 85 | } 86 | 87 | /** 88 | * Starts a Bonjour service broadcast with the given configuration. 89 | *

90 | * The stream will immediately end with an {@link IllegalBonjourTypeException} 91 | * if the input type does not obey Bonjour type specifications. 92 | * If you intend to use this method with arbitrary types that can be provided by user input, 93 | * it is highly encouraged to verify this input 94 | * using {@link #isBonjourType(String)} before calling this method! 95 | *

96 | * When the returned {@link Completable} is unsubscribed from, the broadcast ends, 97 | * and this is the only instance (aside from error events) where it terminates. 98 | * It never emits the onCompleted() event because of that, 99 | * so avoid chaining something after this Completable using andThen(). 100 | * 101 | * @param config Configuration of the service to advertise 102 | * @return A {@link Completable} holding the state of the broadcast, valid until unsubscription 103 | */ 104 | fun newBroadcast(config: BonjourBroadcastConfig): Completable = 105 | if (config.type.isBonjourType()) { 106 | // New Broadcast request for the Driver 107 | val broadcast = driver.createBroadcast() 108 | val connection = platform.createConnection() 109 | 110 | Completable.defer { 111 | Completable.create { emitter -> 112 | // Initialization 113 | broadcast.initialize() 114 | connection.initialize() 115 | 116 | // Destruction 117 | val disposable = platform.runOnTeardown { 118 | broadcast.teardown() 119 | connection.teardown() 120 | } 121 | emitter.setDisposable(disposable) 122 | 123 | // Lifetime 124 | val callback = object : BroadcastCallback { 125 | override fun broadcastFailed(cause: Exception?) { 126 | emitter.onError(BroadcastFailedException(driver.name, cause)) 127 | } 128 | } 129 | 130 | try { 131 | val address = config.address ?: platform.getWifiAddress() 132 | broadcast.start(address, config, callback) 133 | } catch (ex: Exception) { 134 | callback.broadcastFailed(ex) 135 | } 136 | } 137 | } 138 | 139 | } else { 140 | // Not a Bonjour type 141 | Completable.error(IllegalBonjourTypeException(config.type)) 142 | } 143 | 144 | /** 145 | * Configuration and Creation of RxBonjour instances. 146 | * Supply a Platform & a Driver to the Builder (provided by separate artifacts) 147 | * before creating the RxBonjour instance itself. 148 | */ 149 | class Builder { 150 | private var platform: Platform? = null 151 | private var driver: Driver? = null 152 | 153 | fun platform(platform: Platform) = also { this.platform = platform } 154 | fun driver(driver: Driver) = also { this.driver = driver } 155 | 156 | fun create(): RxBonjour { 157 | require(platform != null, { "You need to provide a platform() to RxBonjour's builder" }) 158 | require(driver != null, { "You need to provide a driver() to RxBonjour's builder" }) 159 | return RxBonjour(platform!!, driver!!) 160 | } 161 | } 162 | } 163 | 164 | /* Extension Functions */ 165 | 166 | fun String.isBonjourType() = this.matches(TYPE_PATTERN) 167 | -------------------------------------------------------------------------------- /rxbonjour/src/main/kotlin/de/mannodermaus/rxbonjour/Schedulers.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | import io.reactivex.CompletableTransformer 4 | import io.reactivex.ObservableTransformer 5 | import io.reactivex.schedulers.Schedulers 6 | 7 | class BonjourSchedulers private constructor() { 8 | companion object { 9 | @JvmStatic 10 | fun completableAsync(): CompletableTransformer = 11 | CompletableTransformer { 12 | it.subscribeOn(Schedulers.computation()) 13 | .observeOn(Schedulers.computation()) 14 | } 15 | 16 | @JvmStatic 17 | fun observableAsync(): ObservableTransformer = 18 | ObservableTransformer { 19 | it.subscribeOn(Schedulers.computation()) 20 | .observeOn(Schedulers.computation()) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /rxbonjour/src/test/kotlin/de/mannodermaus/rxbonjour/FakeModels.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | import io.reactivex.disposables.Disposable 4 | import org.mockito.Mockito.mock 5 | import java.net.InetAddress 6 | import java.util.concurrent.atomic.AtomicBoolean 7 | 8 | /* 9 | * Fake Implementations of the Driver interfaces, 10 | * useful for assertions during unit testing. 11 | */ 12 | 13 | enum class DiscoveryState { 14 | New, 15 | Initialized, 16 | Discovering, 17 | TornDown 18 | } 19 | 20 | enum class BroadcastState { 21 | New, 22 | Initialized, 23 | Broadcasting, 24 | TornDown 25 | } 26 | 27 | class FakeDriver : Driver { 28 | val discoveryEngine: FakeDiscoveryEngine = FakeDiscoveryEngine() 29 | val broadcastEngine: FakeBroadcastEngine = FakeBroadcastEngine() 30 | 31 | override val name: String = "fake" 32 | override fun createDiscovery(type: String) = discoveryEngine 33 | override fun createBroadcast(): BroadcastEngine = broadcastEngine 34 | } 35 | 36 | class FakeDiscoveryEngine : DiscoveryEngine { 37 | private var state: DiscoveryState = DiscoveryState.New 38 | private var callback: DiscoveryCallback? = null 39 | 40 | fun state() = state 41 | 42 | override fun initialize() { 43 | state = DiscoveryState.Initialized 44 | } 45 | 46 | override fun discover(address: InetAddress, callback: DiscoveryCallback) { 47 | this.state = DiscoveryState.Discovering 48 | this.callback = callback 49 | } 50 | 51 | override fun teardown() { 52 | state = DiscoveryState.TornDown 53 | } 54 | 55 | fun emitFailure(error: Exception) { 56 | require(state == DiscoveryState.Discovering) 57 | callback?.discoveryFailed(error) 58 | } 59 | 60 | fun emitResolved(service: BonjourService) { 61 | require(state == DiscoveryState.Discovering) 62 | callback?.serviceResolved(service) 63 | } 64 | 65 | fun emitLost(service: BonjourService) { 66 | require(state == DiscoveryState.Discovering) 67 | callback?.serviceLost(service) 68 | } 69 | } 70 | 71 | class FakeBroadcastEngine : BroadcastEngine { 72 | private var state: BroadcastState = BroadcastState.New 73 | private var callback: BroadcastCallback? = null 74 | 75 | fun state() = state 76 | 77 | override fun initialize() { 78 | state = BroadcastState.Initialized 79 | } 80 | 81 | override fun start(address: InetAddress, config: BonjourBroadcastConfig, 82 | callback: BroadcastCallback) { 83 | this.state = BroadcastState.Broadcasting 84 | this.callback = callback 85 | } 86 | 87 | override fun teardown() { 88 | state = BroadcastState.TornDown 89 | } 90 | 91 | fun emitFailure(error: Exception) { 92 | require(state == BroadcastState.Broadcasting) 93 | callback?.broadcastFailed(error) 94 | } 95 | } 96 | 97 | /* 98 | * Fake Implementations of the Platform interfaces, 99 | * useful for assertions during unit testing. 100 | */ 101 | 102 | enum class ConnectionState { 103 | New, 104 | Initialized, 105 | TornDown 106 | } 107 | 108 | class FakePlatform( 109 | private val address: InetAddress = mock(InetAddress::class.java)) : Platform { 110 | val connection: FakePlatformConnection = FakePlatformConnection() 111 | 112 | override fun createConnection() = connection 113 | override fun getWifiAddress() = address 114 | 115 | override fun runOnTeardown(action: () -> Unit): Disposable? { 116 | return object : Disposable { 117 | private val disposed: AtomicBoolean = AtomicBoolean(false) 118 | 119 | override fun dispose() { 120 | if (disposed.compareAndSet(false, true)) { 121 | action.invoke() 122 | } 123 | } 124 | 125 | override fun isDisposed(): Boolean = disposed.get() 126 | } 127 | } 128 | } 129 | 130 | class FakePlatformConnection : PlatformConnection { 131 | 132 | private var state: ConnectionState = ConnectionState.New 133 | 134 | fun state() = state 135 | 136 | override fun initialize() { 137 | state = ConnectionState.Initialized 138 | } 139 | 140 | override fun teardown() { 141 | state = ConnectionState.TornDown 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /rxbonjour/src/test/kotlin/de/mannodermaus/rxbonjour/ModelTests.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | import org.junit.jupiter.api.Assertions.assertEquals 4 | import org.junit.jupiter.api.DisplayName 5 | import org.junit.jupiter.api.Nested 6 | import org.junit.jupiter.api.Test 7 | import org.mockito.Mockito 8 | import java.net.Inet4Address 9 | import java.net.Inet6Address 10 | 11 | private val SERVICE = BonjourService( 12 | type = "_http._tcp", 13 | name = "Test Bonjour Service", 14 | v4Host = null, 15 | v6Host = null, 16 | port = 80, 17 | txtRecords = emptyMap()) 18 | 19 | @DisplayName("BonjourService") 20 | class BonjourServiceTests { 21 | 22 | @Nested 23 | @DisplayName("BonjourService#host()") 24 | class HostTests { 25 | 26 | @Test 27 | @DisplayName("safe access if no address is present whatsoever") 28 | fun safeAccessIfNoAddressIsPresent() { 29 | val service = SERVICE.copy( 30 | v4Host = null, 31 | v6Host = null) 32 | 33 | assertEquals(null, service.host) 34 | } 35 | 36 | @Test 37 | @DisplayName("prefer IPv4 Address if both are present") 38 | fun preferV4AddressIfBothPresent() { 39 | val addressV4 = Mockito.mock(Inet4Address::class.java) 40 | val addressV6 = Mockito.mock(Inet6Address::class.java) 41 | val service = SERVICE.copy( 42 | v4Host = addressV4, 43 | v6Host = addressV6) 44 | 45 | assertEquals(addressV4, service.host) 46 | } 47 | 48 | @Test 49 | @DisplayName("use IPv6 Address if IPv4 isn't present") 50 | fun useV6AddressIfV4NotPresent() { 51 | val addressV6 = Mockito.mock(Inet6Address::class.java) 52 | val service = SERVICE.copy( 53 | v4Host = null, 54 | v6Host = addressV6) 55 | 56 | assertEquals(addressV6, service.host) 57 | } 58 | 59 | @Test 60 | @DisplayName("use IPv4 Address if IPv6 isn't present") 61 | fun useV4AddressIfV6NotPresent() { 62 | val addressV4 = Mockito.mock(Inet4Address::class.java) 63 | val service = SERVICE.copy( 64 | v4Host = addressV4, 65 | v6Host = null) 66 | 67 | assertEquals(addressV4, service.host) 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /rxbonjour/src/test/kotlin/de/mannodermaus/rxbonjour/RxBonjourTests.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour 2 | 3 | import org.junit.jupiter.api.Assertions.assertEquals 4 | import org.junit.jupiter.api.Assertions.assertFalse 5 | import org.junit.jupiter.api.Assertions.assertThrows 6 | import org.junit.jupiter.api.Assertions.assertTrue 7 | import org.junit.jupiter.api.DisplayName 8 | import org.junit.jupiter.api.Nested 9 | import org.junit.jupiter.api.Test 10 | import org.mockito.Mockito.mock 11 | 12 | class RxBonjourTests { 13 | 14 | @Nested 15 | @DisplayName("Building RxBonjour Instances") 16 | class BuilderTests { 17 | 18 | @Test 19 | @DisplayName("Throws if Platform isn't provided to Builder") 20 | fun throwsIfPlatformMissing() { 21 | val driver = mock(Driver::class.java) 22 | val builder = RxBonjour.Builder().driver(driver) 23 | 24 | assertThrows(IllegalArgumentException::class.java, { builder.create() }) 25 | } 26 | 27 | @Test 28 | @DisplayName("Throws if Driver isn't provided to Builder") 29 | fun throwsIfDriverMissing() { 30 | val platform = mock(Platform::class.java) 31 | val builder = RxBonjour.Builder().platform(platform) 32 | 33 | assertThrows(IllegalArgumentException::class.java, { builder.create() }) 34 | } 35 | 36 | @Test 37 | @DisplayName("Successfully creates an RxBonjour instances if everything is provided") 38 | fun successfulIfPlatformAndDriverProvided() { 39 | val driver = mock(Driver::class.java) 40 | val platform = mock(Platform::class.java) 41 | val builder = RxBonjour.Builder() 42 | .driver(driver) 43 | .platform(platform) 44 | 45 | builder.create() 46 | } 47 | } 48 | 49 | @Nested 50 | @DisplayName("RxBonjour#newDiscovery()") 51 | class DiscoveryTests { 52 | 53 | private val VALID_BONJOUR_TYPE = "_http._tcp" 54 | 55 | @Test 56 | @DisplayName("Emit Error if not a Bonjour Type") 57 | fun emitErrorIfNotBonjourType() { 58 | val driver = mock(Driver::class.java) 59 | val platform = mock(Platform::class.java) 60 | val rxb = RxBonjour.Builder().driver(driver).platform(platform).create() 61 | 62 | rxb.newDiscovery("Totally Not Valid").test() 63 | .assertError({ 64 | it is IllegalBonjourTypeException 65 | && it.message!!.contains("Totally Not Valid") 66 | }) 67 | } 68 | 69 | @Test 70 | @DisplayName("Emit Error if Discovery fails after starting") 71 | fun emitErrorIfDiscoveryFailsAfterStarting() { 72 | val driver = FakeDriver() 73 | val platform = FakePlatform() 74 | val rxb = RxBonjour.Builder().driver(driver).platform(platform).create() 75 | 76 | val observer = rxb.newDiscovery(VALID_BONJOUR_TYPE).test() 77 | val expected = RuntimeException("driver crashed") 78 | driver.discoveryEngine.emitFailure(expected) 79 | 80 | observer.assertError({ it is DiscoveryFailedException && it.cause == expected }) 81 | } 82 | 83 | @Test 84 | @DisplayName("Successful Round Trip") 85 | fun successfulRoundTrip() { 86 | val driver = FakeDriver() 87 | val platform = FakePlatform() 88 | 89 | // 1. Before subscribing 90 | val rxb = RxBonjour.Builder().driver(driver).platform(platform).create() 91 | assertEquals(DiscoveryState.New, driver.discoveryEngine.state()) 92 | assertEquals(ConnectionState.New, platform.connection.state()) 93 | 94 | // 2. Start discovering 95 | val observer = rxb.newDiscovery(VALID_BONJOUR_TYPE).test() 96 | .assertSubscribed() 97 | .assertEmpty() 98 | .assertNotComplete() 99 | 100 | assertEquals(DiscoveryState.Discovering, driver.discoveryEngine.state()) 101 | assertEquals(ConnectionState.Initialized, platform.connection.state()) 102 | 103 | // 3. Discover service 104 | val service = mock(BonjourService::class.java) 105 | driver.discoveryEngine.emitResolved(service) 106 | 107 | observer.assertValueCount(1) 108 | observer.assertValueAt(0, { it is BonjourEvent.Added && it.service == service }) 109 | 110 | // 4. Lose service 111 | driver.discoveryEngine.emitLost(service) 112 | 113 | observer.assertValueCount(2) 114 | observer.assertValueAt(1, { it is BonjourEvent.Removed && it.service == service }) 115 | 116 | // 5. Tear down discovery 117 | observer.dispose() 118 | 119 | observer.assertNotComplete().assertNoErrors() 120 | assertEquals(DiscoveryState.TornDown, driver.discoveryEngine.state()) 121 | assertEquals(ConnectionState.TornDown, platform.connection.state()) 122 | } 123 | } 124 | 125 | @Nested 126 | @DisplayName("RxBonjour#newBroadcast()") 127 | class BroadcastTests { 128 | 129 | private val VALID_BROADCAST = BonjourBroadcastConfig("_http._tcp") 130 | 131 | @Test 132 | @DisplayName("Emit Error if not a Bonjour Type") 133 | fun emitErrorIfNotBonjourType() { 134 | val driver = mock(Driver::class.java) 135 | val platform = mock(Platform::class.java) 136 | val rxb = RxBonjour.Builder().driver(driver).platform(platform).create() 137 | 138 | val config = VALID_BROADCAST.copy(type = "Totally Not Valid") 139 | rxb.newBroadcast(config).test() 140 | .assertError({ 141 | it is IllegalBonjourTypeException 142 | && it.message!!.contains("Totally Not Valid") 143 | }) 144 | } 145 | 146 | @Test 147 | @DisplayName("Emit Error if Broadcast fails after starting") 148 | fun emitErrorIfBroadcastFailsAfterStarting() { 149 | val driver = FakeDriver() 150 | val platform = FakePlatform() 151 | val rxb = RxBonjour.Builder().driver(driver).platform(platform).create() 152 | 153 | val observer = rxb.newBroadcast(VALID_BROADCAST).test() 154 | val expected = RuntimeException("driver crashed") 155 | driver.broadcastEngine.emitFailure(expected) 156 | 157 | observer.assertError({ it is BroadcastFailedException && it.cause == expected }) 158 | } 159 | 160 | @Test 161 | @DisplayName("Successful Round Trip") 162 | fun successfulRoundTrip() { 163 | val driver = FakeDriver() 164 | val platform = FakePlatform() 165 | 166 | // 1. Before subscribing 167 | val rxb = RxBonjour.Builder().driver(driver).platform(platform).create() 168 | assertEquals(BroadcastState.New, driver.broadcastEngine.state()) 169 | assertEquals(ConnectionState.New, platform.connection.state()) 170 | 171 | // 2. Start broadcasting 172 | val observer = rxb.newBroadcast(VALID_BROADCAST).test() 173 | .assertSubscribed() 174 | .assertNotComplete() 175 | 176 | assertEquals(BroadcastState.Broadcasting, driver.broadcastEngine.state()) 177 | assertEquals(ConnectionState.Initialized, platform.connection.state()) 178 | 179 | // 5. Tear down broadcast 180 | observer.dispose() 181 | 182 | observer.assertNotComplete().assertNoErrors() 183 | assertEquals(BroadcastState.TornDown, driver.broadcastEngine.state()) 184 | assertEquals(ConnectionState.TornDown, platform.connection.state()) 185 | } 186 | } 187 | } 188 | 189 | @DisplayName("String.isBonjourType()") 190 | class IsBonjourTypeTests { 191 | 192 | @Test 193 | fun valid() { 194 | assertTrue("_http._tcp".isBonjourType()) 195 | assertTrue("_http._udp".isBonjourType()) 196 | assertTrue("_ssh._tcp".isBonjourType()) 197 | assertTrue("_ssh._udp".isBonjourType()) 198 | assertTrue("_xmpp-server._tcp".isBonjourType()) 199 | assertTrue("_printer._tcp".isBonjourType()) 200 | assertTrue("_somelocalservice._tcp.local.".isBonjourType()) 201 | } 202 | 203 | @Test 204 | fun invalid() { 205 | assertFalse("_invalid§/(chars._tcp".isBonjourType()) 206 | assertFalse("_http._invalidprotocol".isBonjourType()) 207 | assertFalse("wrong._format".isBonjourType()) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /rxbonjour/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | -------------------------------------------------------------------------------- /samples/sample-android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.android.application" 2 | apply plugin: "kotlin-android" 3 | apply plugin: "kotlin-kapt" 4 | 5 | android { 6 | compileSdkVersion COMPILE_SDK_VERSION 7 | buildToolsVersion BUILD_TOOLS_VERSION 8 | 9 | compileOptions { 10 | sourceCompatibility JavaVersion.VERSION_1_8 11 | targetCompatibility JavaVersion.VERSION_1_8 12 | } 13 | 14 | defaultConfig { 15 | applicationId "de.mannodermaus.rxbonjour.samples.android" 16 | minSdkVersion 16 17 | targetSdkVersion TARGET_SDK_VERSION 18 | versionCode 1 19 | versionName VERSION_NAME 20 | } 21 | 22 | buildTypes { 23 | release { 24 | minifyEnabled false 25 | proguardFiles getDefaultProguardFile("proguard-android.txt") 26 | } 27 | } 28 | 29 | sourceSets { 30 | main.java.srcDirs += "src/main/kotlin" 31 | } 32 | } 33 | 34 | dependencies { 35 | implementation project(":rxbonjour") 36 | implementation project(":rxbonjour-driver-jmdns") 37 | implementation project(":rxbonjour-driver-nsdmanager") 38 | implementation project(":rxbonjour-platform-android") 39 | 40 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$KOTLIN_VERSION" 41 | implementation "io.reactivex.rxjava2:rxandroid:$RXANDROID_VERSION" 42 | implementation "com.android.support:appcompat-v7:$SUPPORT_LIBRARY_VERSION" 43 | implementation "com.android.support:recyclerview-v7:$SUPPORT_LIBRARY_VERSION" 44 | 45 | implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION" 46 | kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION" 47 | } 48 | -------------------------------------------------------------------------------- /samples/sample-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /samples/sample-android/src/main/kotlin/de/mannodermaus/rxbonjour/samples/android/Adapters.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.samples.android 2 | 3 | import android.content.Context 4 | import android.view.LayoutInflater 5 | import android.view.ViewGroup 6 | import android.widget.ArrayAdapter 7 | import android.widget.TextView 8 | import butterknife.BindView 9 | import butterknife.ButterKnife 10 | import de.mannodermaus.rxbonjour.BonjourService 11 | 12 | class DriverImplAdapter(context: Context) 13 | : ArrayAdapter( 14 | context, 15 | R.layout.support_simple_spinner_dropdown_item, 16 | DriverImpl.values()) 17 | 18 | class ServiceRecyclerAdapter : RecyclerBaseAdapter() { 19 | override fun createViewHolder(inflater: LayoutInflater, parent: ViewGroup, 20 | viewType: Int) = Holder(inflater, parent) 21 | 22 | class Holder( 23 | inflater: LayoutInflater, 24 | parent: ViewGroup) 25 | : RecyclerBaseHolder(inflater, parent, R.layout.item_bonjourservice) { 26 | 27 | @BindView(R.id.tv_name) 28 | lateinit var tvName: TextView 29 | @BindView(R.id.tv_type) 30 | lateinit var tvType: TextView 31 | @BindView(R.id.tv_host_port_v4) 32 | lateinit var tvHostPortV4: TextView 33 | @BindView(R.id.tv_host_port_v6) 34 | lateinit var tvHostPortV6: TextView 35 | @BindView(R.id.tv_txtrecords) 36 | lateinit var tvTxtRecords: TextView 37 | 38 | init { 39 | ButterKnife.bind(this, itemView) 40 | } 41 | 42 | override fun onBindItem(item: BonjourService) { 43 | val context = tvName.context 44 | tvName.text = item.name 45 | tvType.text = item.type 46 | 47 | // Display host address information 48 | tvHostPortV4.text = item.v4Host?.let { 49 | context.getString(R.string.format_host_address_v4, it, item.port) 50 | } ?: "" 51 | tvHostPortV6.text = item.v6Host?.let { 52 | context.getString(R.string.format_host_address_v6, it, item.port) 53 | } ?: "" 54 | 55 | // Display TXT records, if any could be resolved 56 | val txtRecords = item.txtRecords 57 | val txtRecordCount = txtRecords.size 58 | if (txtRecordCount > 0) { 59 | val txtRecordsText = StringBuilder() 60 | val keyIterator = txtRecords.keys.iterator() 61 | for (i in 0 until txtRecordCount) { 62 | // Append key-value information for the TXT record 63 | val key = keyIterator.next() 64 | txtRecordsText 65 | .append(key) 66 | .append(" -> ") 67 | .append(txtRecords[key]) 68 | 69 | // Add line break if more is coming 70 | if (i < txtRecordCount - 1) txtRecordsText.append('\n') 71 | } 72 | tvTxtRecords.text = txtRecordsText.toString() 73 | 74 | } else { 75 | tvTxtRecords.text = tvTxtRecords.resources.getString(R.string.tv_notxtrecords) 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /samples/sample-android/src/main/kotlin/de/mannodermaus/rxbonjour/samples/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.samples.android 2 | 3 | import android.os.Bundle 4 | import android.support.v7.app.AppCompatActivity 5 | import android.support.v7.widget.LinearLayoutManager 6 | import android.util.Log 7 | import android.view.View 8 | import android.widget.AdapterView 9 | import android.widget.EditText 10 | import android.widget.ProgressBar 11 | import android.widget.Spinner 12 | import android.widget.Toast 13 | import butterknife.BindView 14 | import butterknife.ButterKnife 15 | import butterknife.OnClick 16 | import butterknife.Unbinder 17 | import de.mannodermaus.rxbonjour.BonjourBroadcastConfig 18 | import de.mannodermaus.rxbonjour.BonjourEvent 19 | import de.mannodermaus.rxbonjour.RxBonjour 20 | import de.mannodermaus.rxbonjour.isBonjourType 21 | import de.mannodermaus.rxbonjour.platforms.android.AndroidPlatform 22 | import io.reactivex.android.schedulers.AndroidSchedulers 23 | import io.reactivex.disposables.Disposables 24 | import io.reactivex.schedulers.Schedulers 25 | 26 | private val LOG_TAG = "RxBonjour Sample" 27 | 28 | /** 29 | * @author marcel 30 | */ 31 | class MainActivity : AppCompatActivity() { 32 | 33 | @BindView(R.id.et_type) 34 | lateinit var etInput: EditText 35 | @BindView(R.id.progress_bar) 36 | lateinit var progressBar: ProgressBar 37 | @BindView(R.id.spinner) 38 | lateinit var spDrivers: Spinner 39 | @BindView(R.id.rv) 40 | lateinit var rvItems: CustomRecyclerView 41 | 42 | lateinit var spinnerAdapter: DriverImplAdapter 43 | private val listAdapter = ServiceRecyclerAdapter() 44 | 45 | lateinit var unbinder: Unbinder 46 | private var nsdDisposable = Disposables.empty() 47 | 48 | override fun onCreate(savedInstanceState: Bundle?) { 49 | super.onCreate(savedInstanceState) 50 | setContentView(R.layout.activity_main) 51 | unbinder = ButterKnife.bind(this) 52 | 53 | // Setup RecyclerView 54 | rvItems.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) 55 | rvItems.setEmptyView(findViewById(R.id.tv_empty)) 56 | rvItems.adapter = listAdapter 57 | 58 | // Setup Spinner 59 | spinnerAdapter = DriverImplAdapter(this) 60 | spDrivers.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { 61 | override fun onItemSelected(adapterView: AdapterView<*>, view: View, i: Int, l: Long) { 62 | // This will fire immediately when the first item is set 63 | restartDiscovery() 64 | } 65 | 66 | override fun onNothingSelected(adapterView: AdapterView<*>) {} 67 | } 68 | spDrivers.adapter = spinnerAdapter 69 | } 70 | 71 | override fun onStop() { 72 | super.onStop() 73 | 74 | // Unsubscribe from the network service discovery Observable 75 | unsubscribe() 76 | } 77 | 78 | override fun onDestroy() { 79 | super.onDestroy() 80 | unbinder.unbind() 81 | } 82 | 83 | @OnClick(R.id.button_apply) 84 | internal fun onApplyClicked() { 85 | val input = etInput.text 86 | if (input != null && input.isNotEmpty()) { 87 | // For non-empty input, restart the discovery with the new input 88 | restartDiscovery() 89 | } 90 | } 91 | 92 | /* Begin private */ 93 | 94 | private fun unsubscribe() { 95 | nsdDisposable.dispose() 96 | } 97 | 98 | private fun restartDiscovery() { 99 | // Check the current input, only proceed if valid 100 | val type = etInput.text.toString() 101 | if (!type.isBonjourType()) { 102 | Toast.makeText(this, getString(R.string.toast_invalidtype, type), Toast.LENGTH_SHORT).show() 103 | return 104 | } 105 | 106 | // Cancel any previous subscription 107 | unsubscribe() 108 | 109 | // Clear the adapter's items, then start a new discovery 110 | listAdapter.clearItems() 111 | 112 | // Construct a new RxBonjour instance with the currently selected Driver. 113 | // Usually, you'd simply add the Driver inside the Builder 114 | // and provide the entry point to RxBonjour globally, 115 | // e.g. through Dependency Injection, or as an instance field. 116 | // 117 | // RxBonjour rxBonjour = new RxBonjour.Builder() 118 | // .driver(JmDNSDriver.create()) 119 | // .platform(AndroidPlatform.create(this)) 120 | // .create(); 121 | // 122 | // Since in this sample Driver implementations can be switched, 123 | // we're using the SpinnerAdapter for this. 124 | val driverLibrary = spinnerAdapter.getItem(spDrivers.selectedItemPosition) 125 | 126 | val rxBonjour = RxBonjour.Builder() 127 | .driver(driverLibrary.factory.invoke(this)) 128 | .platform(AndroidPlatform.create(this)) 129 | .create() 130 | 131 | val broadcastConfig = BonjourBroadcastConfig( 132 | type = "_http._tcp", 133 | name = "My Bonjour Service", 134 | address = null, 135 | port = 13337, 136 | txtRecords = mapOf( 137 | "my.record" to "my value", 138 | "other.record" to "0815")) 139 | val disposable = rxBonjour.newBroadcast(broadcastConfig) 140 | .subscribeOn(Schedulers.io()) 141 | .observeOn(AndroidSchedulers.mainThread()) 142 | .subscribe() 143 | 144 | nsdDisposable = rxBonjour.newDiscovery(type) 145 | .subscribeOn(Schedulers.io()) 146 | .observeOn(AndroidSchedulers.mainThread()) 147 | .doOnSubscribe { progressBar.visibility = View.VISIBLE } 148 | .doOnComplete { progressBar.visibility = View.INVISIBLE } 149 | .doOnError { progressBar.visibility = View.INVISIBLE } 150 | .subscribe( 151 | { event -> 152 | // Depending on the type of event and the availability of the item, adjust the adapter 153 | val item = event.service 154 | Log.i(LOG_TAG, "Event: " + item) 155 | when (event) { 156 | is BonjourEvent.Added -> if (!listAdapter.containsItem(item)) listAdapter.addItem( 157 | item) 158 | is BonjourEvent.Removed -> if (listAdapter.containsItem( 159 | item)) listAdapter.removeItem(item) 160 | } 161 | }, 162 | { error -> 163 | error.printStackTrace() 164 | Toast.makeText(this@MainActivity, error.message, Toast.LENGTH_SHORT).show() 165 | }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /samples/sample-android/src/main/kotlin/de/mannodermaus/rxbonjour/samples/android/Models.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.samples.android 2 | 3 | import android.content.Context 4 | import de.mannodermaus.rxbonjour.Driver 5 | import de.mannodermaus.rxbonjour.drivers.jmdns.JmDNSDriver 6 | import de.mannodermaus.rxbonjour.drivers.nsdmanager.NsdManagerDriver 7 | 8 | enum class DriverImpl( 9 | private val library: String, 10 | private val artifact: String, 11 | val factory: (Context) -> Driver) { 12 | 13 | JMDNS( 14 | "JmDNS", 15 | "rxbonjour-driver-jmdns", 16 | { JmDNSDriver.create() }), 17 | 18 | NSDMANAGER( 19 | "NsdManager", 20 | "rxbonjour-driver-nsdmanager", 21 | { NsdManagerDriver.create(it) }); 22 | 23 | override fun toString(): String = "$library ($artifact)" 24 | } 25 | -------------------------------------------------------------------------------- /samples/sample-android/src/main/kotlin/de/mannodermaus/rxbonjour/samples/android/RecyclerViews.kt: -------------------------------------------------------------------------------- 1 | package de.mannodermaus.rxbonjour.samples.android 2 | 3 | import android.content.Context 4 | import android.support.annotation.LayoutRes 5 | import android.support.v7.widget.RecyclerView 6 | import android.util.AttributeSet 7 | import android.view.ContextMenu 8 | import android.view.LayoutInflater 9 | import android.view.MotionEvent 10 | import android.view.View 11 | import android.view.ViewGroup 12 | import java.util.Arrays 13 | 14 | /* Some additional utilities unrelated to RxBonjour itself */ 15 | 16 | class CustomRecyclerView 17 | @JvmOverloads 18 | constructor( 19 | context: Context, 20 | attrs: AttributeSet? = null, 21 | defStyle: Int = 0) 22 | : RecyclerView(context, attrs, defStyle) { 23 | 24 | /** Reference to the empty view, if any */ 25 | private var emptyView: View? = null 26 | 27 | /** Context menu info connected to this View */ 28 | private var contextMenuInfo: ContextMenu.ContextMenuInfo? = null 29 | 30 | /** Data set observer for empty view coordination */ 31 | private val observer = object : RecyclerView.AdapterDataObserver() { 32 | override fun onChanged() { 33 | checkIfEmpty() 34 | } 35 | 36 | override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { 37 | checkIfEmpty() 38 | } 39 | 40 | override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { 41 | checkIfEmpty() 42 | } 43 | 44 | override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { 45 | checkIfEmpty() 46 | } 47 | } 48 | 49 | /* Begin overrides */ 50 | 51 | override fun scrollTo(x: Int, y: Int) { 52 | // Prevent "UnsupportedOperationException", because android:animateLayoutChanges depends on it. Works fine this way! 53 | } 54 | 55 | override fun setAdapter(adapter: RecyclerView.Adapter<*>?) { 56 | // First, unregister any previous adapter 57 | val oldAdapter = getAdapter() 58 | oldAdapter?.unregisterAdapterDataObserver(observer) 59 | 60 | // Call through to set the adapter 61 | super.setAdapter(adapter) 62 | 63 | // Register the new adapter 64 | adapter?.registerAdapterDataObserver(observer) 65 | 66 | // Check if empty right away 67 | checkIfEmpty() 68 | } 69 | 70 | override fun showContextMenuForChild(originalView: View): Boolean { 71 | // Initialize the context menu info for this item 72 | return try { 73 | if (getChildAdapterPosition(originalView) != RecyclerView.NO_POSITION) { 74 | // Obtain the ID of the child as well and create a context menu info for it 75 | val holder = this.getChildViewHolder(originalView) 76 | contextMenuInfo = RvContextMenuInfo(holder) 77 | super.showContextMenuForChild(originalView) 78 | 79 | } else { 80 | false 81 | } 82 | 83 | } catch (ex: ClassCastException) { 84 | // If the RecyclerView isn't set up for context menus 85 | false 86 | } 87 | } 88 | 89 | override fun getContextMenuInfo(): ContextMenu.ContextMenuInfo? = contextMenuInfo 90 | 91 | /* Begin public */ 92 | 93 | /** 94 | * Sets a reference to an empty View, which is displayed when the adapter doesn't have any items to display. 95 | * 96 | * @param emptyView Empty View to link to this RecyclerView 97 | */ 98 | fun setEmptyView(emptyView: View) { 99 | this.emptyView = emptyView 100 | 101 | // Check if empty right away 102 | checkIfEmpty() 103 | } 104 | 105 | /* Begin private */ 106 | 107 | private fun checkIfEmpty() { 108 | // Only proceed if an empty view exists 109 | val adapter = adapter 110 | if (emptyView != null && adapter != null) { 111 | // Display the empty View whenever this RecyclerView doesn't show any items 112 | val empty = adapter.itemCount == 0 113 | emptyView!!.visibility = if (empty) View.VISIBLE else View.GONE 114 | visibility = if (empty) View.GONE else View.VISIBLE 115 | } 116 | } 117 | 118 | /* Begin inner classes */ 119 | 120 | /** 121 | * ContextMenuInfo implementation for a RecyclerView. Instances of this class are contained within 122 | * the "onCreateContextMenu()" and "onContextItemSelected()" callbacks. 123 | */ 124 | class RvContextMenuInfo 125 | constructor(holder: RecyclerView.ViewHolder) : ContextMenu.ContextMenuInfo { 126 | val position = holder.adapterPosition 127 | val id = holder.itemId 128 | } 129 | } 130 | 131 | /** 132 | * RecyclerView adapter implementation with convenience methods for the insertion, deletion 133 | * and other modifications of adapter items. 134 | * 135 | * @param Item type to be held by the adapter 136 | */ 137 | abstract class RecyclerBaseAdapter() : RecyclerView.Adapter>() { 138 | 139 | /** List of unfiltered items */ 140 | protected var mutableItems: MutableList = ArrayList() 141 | 142 | /** Optional item click listener notified of events */ 143 | private var itemClickListener: OnItemClickListener? = null 144 | 145 | /** 146 | * Returns the list of items held by this adapter 147 | * 148 | * @return The list of items held by the adapter 149 | */ 150 | val items: List 151 | get() = mutableItems 152 | 153 | /** 154 | * Constructor with initial items 155 | * 156 | * @param initialItems Initial items to load the adapter with 157 | */ 158 | constructor(initialItems: List?) : this() { 159 | if (initialItems != null) this.mutableItems.addAll(initialItems) 160 | } 161 | 162 | /* Begin public */ 163 | 164 | fun setOnItemClickListener(listener: OnItemClickListener) { 165 | this.itemClickListener = listener 166 | } 167 | 168 | /** 169 | * Clears the list of items held by this adapter 170 | */ 171 | fun clearItems() { 172 | mutableItems.clear() 173 | notifyDataSetChanged() 174 | } 175 | 176 | /** 177 | * Gets the item at the given position from the item list, or returns null if there is no such position. 178 | * 179 | * @param position Position at which to get the item 180 | * @return The item at that position, or null for out-of-bounds values 181 | */ 182 | fun getItemAt(position: Int): E? = if (position in 0..(itemCount - 1)) { 183 | mutableItems[position] 184 | } else { 185 | null 186 | } 187 | 188 | /** 189 | * Obtains the index of the provided item within this adapter. 190 | * 191 | * @param item Item to obtain the index of within the adapter 192 | * @return The position within the adapter's items that holds the provided item (comparisons made using equals()), or NO_POSITION if the item doesn't exist in the adapter. 193 | */ 194 | fun indexOf( 195 | item: E): Int = mutableItems.indices.firstOrNull { mutableItems[it] == item } ?: NO_POSITION 196 | 197 | /** 198 | * Checks if the adapter contains the provided item. Ensure that custom objects implement 199 | * equals() and hashCode() in order for this to work reliably! 200 | * 201 | * @param item Item to check for in the list 202 | * @return True if the item is contained in the item list, false otherwise 203 | */ 204 | fun containsItem(item: E): Boolean = mutableItems.contains(item) 205 | 206 | /** 207 | * Re-sets the item list to the given values 208 | * 209 | * @param items Values to replace the adapter's current contents with 210 | */ 211 | fun setItems(items: Array) { 212 | // Convert to a List and replace 213 | this.setItems(Arrays.asList(*items)) 214 | } 215 | 216 | /** 217 | * Re-sets the item list to the given values 218 | * 219 | * @param items Values to replace the adapter's current contents with 220 | */ 221 | fun setItems(items: Set) { 222 | // Convert to a List and replace 223 | val list = ArrayList(items.size) 224 | list.addAll(items) 225 | this.setItems(list) 226 | } 227 | 228 | /** 229 | * Re-sets the item list to the given values 230 | * 231 | * @param items Values to replace the adapter's current contents with 232 | */ 233 | fun setItems(items: MutableList) { 234 | // Replace the item list and notify 235 | val oldSize = mutableItems.size 236 | mutableItems = items 237 | notifyItemRangeChanged(0, oldSize) 238 | } 239 | 240 | /** 241 | * Appends the provided item to the end of the item list 242 | * 243 | * @param item Item to append to the list 244 | */ 245 | fun addItem(item: E) { 246 | // Simply append to the end of the list 247 | this.insertItem(itemCount, item) 248 | } 249 | 250 | /** 251 | * Appends the provided items to the end of the item list 252 | * 253 | * @param items Items to append to the list 254 | */ 255 | fun addItems(items: Collection) { 256 | // Append to the end of the list 257 | this.insertItems(itemCount, items) 258 | } 259 | 260 | /** 261 | * Updates the item at the given position within the item list. 262 | * 263 | * @param position Position at which to insert the item 264 | * @param item Item to insert into the list 265 | * @throws IndexOutOfBoundsException for invalid indices 266 | */ 267 | fun updateItem(position: Int, item: E) { 268 | mutableItems[position] = item 269 | this.notifyItemChanged(position) 270 | } 271 | 272 | /** 273 | * Replaces the given item with the new one. This method appends the new item to the end of the list if the old one isn't contained in the adapter. 274 | * 275 | * @param oldItem Item to replace 276 | * @param newItem Item to replace the old one with 277 | */ 278 | fun replaceItem(oldItem: E, newItem: E) { 279 | // If the old item exists in the adapter, replace it. Otherwise, append the new item at the end 280 | val index = this.indexOf(oldItem) 281 | if (index != NO_POSITION) 282 | this.updateItem(index, newItem) 283 | else 284 | this.addItem(newItem) 285 | } 286 | 287 | /** 288 | * Inserts the provided item at the given position within the item list. This method 289 | * takes care of bounds-checking, so that indices outside the item list's bounds are 290 | * automatically corrected (i.e., trying to insert at position 5 with only 1 item in the list 291 | * resulting in the item being appended to the end of the list). 292 | * 293 | * @param position Position at which to insert the item 294 | * @param item Item to insert into the list 295 | */ 296 | fun insertItem(position: Int, item: E) { 297 | // Cap the position index at 0 and the total item size, then add it 298 | val actualPosition = Math.min(Math.max(position, 0), itemCount) 299 | mutableItems.add(actualPosition, item) 300 | this.notifyItemInserted(actualPosition) 301 | } 302 | 303 | /** 304 | * Inserts the provided item collection at the given position within the item list. 305 | * This method takes care of bounds-checking, so that indices outside the item list's bounds 306 | * are automatically corrected. 307 | * 308 | * @param position Position at which to insert the items 309 | * @param items Items to insert into the list 310 | */ 311 | fun insertItems(position: Int, items: Collection) { 312 | // Cap the position index at 0 and the total item size, then add them 313 | val actualPosition = Math.min(Math.max(position, 0), itemCount) 314 | mutableItems.addAll(actualPosition, items) 315 | this.notifyItemRangeInserted(actualPosition, items.size) 316 | } 317 | 318 | /** 319 | * Removes the provided item from the item list. 320 | * If the item doesn't exist in the list, this method does nothing. 321 | * 322 | * @param item Item to remove from the list 323 | * @return True if the item could be successfully removed, false if it doesn't exist in the list 324 | */ 325 | fun removeItem(item: E): Boolean { 326 | // Find the position of the item in the list and delegate 327 | val position = mutableItems.indexOf(item) 328 | return position > -1 && this.removeItem(position) 329 | } 330 | 331 | /** 332 | * Removes the item at the given position from the item list. 333 | * If the position is out of the item list's bounds, this method does nothing. 334 | * 335 | * @param position Position of the item to remove 336 | * @return True if the item could be successfully removed, false if an out-of-bounds value was passed in 337 | */ 338 | fun removeItem(position: Int): Boolean { 339 | if (position in 0..(itemCount - 1)) { 340 | mutableItems.removeAt(position) 341 | this.notifyItemRemoved(position) 342 | return true 343 | } 344 | return false 345 | } 346 | 347 | /* Begin overrides */ 348 | 349 | override fun getItemCount(): Int = mutableItems.size 350 | 351 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerBaseHolder { 352 | // Obtain the layout inflater and delegate to the abstract creation method 353 | val inflater = LayoutInflater.from(parent.context) 354 | val holder = this.createViewHolder(inflater, parent, viewType) 355 | 356 | // Attach item click listener, if any, then return 357 | itemClickListener?.let { holder.setItemClickListener(it) } 358 | return holder 359 | } 360 | 361 | override fun onBindViewHolder(holder: RecyclerBaseHolder, position: Int) { 362 | // Obtain the item to bind to the provided holder and delegate the bind process to it 363 | val item = mutableItems[position] 364 | holder.performBind(item) 365 | } 366 | 367 | /* Begin abstract */ 368 | 369 | /** 370 | * Callback method invoked upon each demand of the adapter for a new ViewHolder. 371 | * Usually, the parameters can be passed through straight to the implementing 372 | * [RvBaseHolder] class for View inflation. 373 | *nflate the ViewHolder's layout 374 | * @param parent Parent of the View to inflate 375 | * @param viewType Type of view to inflate 376 | * @return A new instance of the ViewHolder implementation to be used by the adapter 377 | */ 378 | protected abstract fun createViewHolder(inflater: LayoutInflater, parent: ViewGroup, 379 | viewType: Int): RecyclerBaseHolder 380 | 381 | /* Begin inner classes */ 382 | 383 | interface OnItemClickListener { 384 | fun onItemClick(holder: RecyclerBaseHolder, item: E) 385 | } 386 | 387 | companion object { 388 | /** Key which can be used for adapter items used in onSave/onRestoreInstanceState */ 389 | protected val INSTANCE_STATE_ITEMS = "isitems" 390 | 391 | /** Int constant to represent "no position", used when invoking indexOf() with an item not present in the adapter */ 392 | protected val NO_POSITION = -1 393 | } 394 | } 395 | 396 | /** 397 | * RecyclerView view holder base implementation with convenience methods for click events. 398 | * 399 | * @param Item type to be held by the view holder 400 | */ 401 | abstract class RecyclerBaseHolder 402 | protected constructor( 403 | inflater: LayoutInflater, 404 | parent: ViewGroup, 405 | @LayoutRes layoutRes: Int) 406 | : RecyclerView.ViewHolder( 407 | inflater.inflate(layoutRes, parent, false)), View.OnClickListener, View.OnLongClickListener { 408 | 409 | /* Begin public */ 410 | 411 | private var item: E? = null 412 | private var itemClickListener: RecyclerBaseAdapter.OnItemClickListener? = null 413 | 414 | init { 415 | // Attach an OnClickListener to the item view 416 | itemView.setOnClickListener(this) 417 | itemView.setOnLongClickListener(this) 418 | itemView.setOnHoverListener { v, event -> 419 | item != null && this@RecyclerBaseHolder.onHover(v, event, item) 420 | } 421 | } 422 | 423 | /* Begin abstract */ 424 | 425 | /** 426 | * Invoked upon binding the view holder to an item. This callback is usually used to setup the holder's UI 427 | * with information obtained from the provided item. 428 | * 429 | * @param item Item with which to setup the view holder's UI components 430 | */ 431 | protected abstract fun onBindItem(item: E) 432 | 433 | /* Begin package */ 434 | 435 | /** 436 | * Internal bind method called from a [RvBaseAdapter]. 437 | * 438 | * @param item Item to bind to the view 439 | */ 440 | internal fun performBind(item: E) { 441 | // Save the item reference and call the abstract bind implementation 442 | this.item = item 443 | this.onBindItem(item) 444 | } 445 | 446 | internal fun setItemClickListener(listener: RecyclerBaseAdapter.OnItemClickListener) { 447 | this.itemClickListener = listener 448 | } 449 | 450 | /* Begin protected */ 451 | 452 | /** 453 | * Invoked when the holder's item view is clicked. Does nothing by default 454 | * 455 | * @param v Item view of the view holder 456 | * @param item The item it is currently bound to 457 | */ 458 | protected fun onClick(v: View, item: E) { 459 | // If an item click listener is set, call it 460 | itemClickListener?.onItemClick(this, item) 461 | } 462 | 463 | /** 464 | * Invoked when the holder's item view is long-clicked. Returns false by default 465 | * 466 | * @param v Item view of the view holder 467 | * @param item The item it is currently bound to 468 | * @return True if the callback consumed the event, false otherwise 469 | */ 470 | protected fun onLongClick(v: View, item: E): Boolean = false 471 | 472 | /** 473 | * Invoked when the holder's item view triggered a hover event. Returns false by default 474 | * 475 | * @param v Item view of the view holder 476 | * @param event Motion event containing the hover 477 | * @param item The item it is currently bound to 478 | * @return True if the callback consumed the event, false otherwise 479 | */ 480 | protected fun onHover(v: View, event: MotionEvent, item: E?): Boolean = false 481 | 482 | /* Begin overrides */ 483 | 484 | override fun onClick(v: View) { 485 | item?.let { this.onClick(v, it) } 486 | } 487 | 488 | override fun onLongClick(v: View): Boolean = item?.let { this.onLongClick(v, it) } == true 489 | } 490 | -------------------------------------------------------------------------------- /samples/sample-android/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | 17 | 25 | 26 | 35 | 36 |