react-native-zeroconf 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ Discover and publish network services using Zeroconf protocols (Bonjour, Avahi,
11
11
  - **Cross-Platform**: Works on iOS and Android
12
12
  - **Dual Android Implementation**: Choose between NSD (Android native) or DNSSD (embedded mDNSResponder)
13
13
  - **Android 15+ Compatible**: Includes 16KB page size alignment (Google Play requirement starting November 1, 2025)
14
+ - **TypeScript**: Type definitions included
14
15
 
15
16
  ## Installation
16
17
 
@@ -34,7 +35,7 @@ For manual installation, see the [wiki](https://github.com/balthazar/react-nativ
34
35
 
35
36
  ### Android Permissions
36
37
 
37
- Add the following permissions to your `AndroidManifest.xml`:
38
+ The library declares the following permissions in its own manifest, so they are merged into your app automatically:
38
39
 
39
40
  ```xml
40
41
  <uses-permission android:name="android.permission.INTERNET" />
@@ -82,6 +83,23 @@ setTimeout(() => {
82
83
  }, 10000)
83
84
  ```
84
85
 
86
+ ## TypeScript
87
+
88
+ Type definitions ship with the package, so `@types/react-native-zeroconf` is no longer needed (uninstall it to avoid conflicting types). Event listeners are typed by event name:
89
+
90
+ ```typescript
91
+ import Zeroconf, { ImplType, Service } from 'react-native-zeroconf'
92
+
93
+ const zeroconf = new Zeroconf()
94
+
95
+ zeroconf.on('resolved', (service: Service) => {
96
+ console.log(service.name, service.addresses, service.txt)
97
+ })
98
+ zeroconf.on('remove', name => console.log(`${name} left`))
99
+
100
+ zeroconf.scan('http', 'tcp', 'local.', ImplType.DNSSD)
101
+ ```
102
+
85
103
  ## API Reference
86
104
 
87
105
  ### Constructor
@@ -118,12 +136,10 @@ Stop the current scan.
118
136
 
119
137
  | Parameter | Type | Default | Description |
120
138
  | ---------- | ------ | ------- | ---------------------------------------------- |
121
- | `implType` | string | `'NSD'` | **Android only**: Which implementation to stop |
139
+ | `implType` | string | last scan's | **Android only**: Which implementation to stop. Defaults to the one used by the last `scan()` |
122
140
 
123
141
  ```javascript
124
142
  zeroconf.stop()
125
- // or on Android with DNSSD:
126
- zeroconf.stop('DNSSD')
127
143
  ```
128
144
 
129
145
  #### `getServices()`
@@ -146,7 +162,7 @@ Publish a service on the network.
146
162
  | `domain` | string | `'local.'` | Domain |
147
163
  | `name` | string | required | Service name (should be unique) |
148
164
  | `port` | number | required | Port number |
149
- | `txt` | object | `{}` | TXT record key-value pairs |
165
+ | `txt` | object \| array | `{}` | TXT record key-value pairs, or `[key, value]` pairs to control the order |
150
166
  | `implType` | string | `'NSD'` | **Android only**: `'NSD'` or `'DNSSD'` |
151
167
 
152
168
  ```javascript
@@ -154,8 +170,18 @@ zeroconf.publishService('http', 'tcp', 'local.', 'MyWebServer', 8080, {
154
170
  path: '/api',
155
171
  version: '1.0',
156
172
  })
173
+
174
+ // TXT records are published in the order given. Use pairs when order matters and keys look like numbers
175
+ zeroconf.publishService('http', 'tcp', 'local.', 'MyWebServer', 8080, [
176
+ ['txtvers', '1'],
177
+ ['path', '/api'],
178
+ ])
157
179
  ```
158
180
 
181
+ TXT order is preserved on iOS and with `DNSSD` on Android. With `NSD`, the order depends on the Android system.
182
+
183
+ Published services are unpublished when the React Native instance is torn down (e.g. on reload). If the app process is killed, it can't announce anything: other devices only drop the service once its mDNS records expire.
184
+
159
185
  #### `unpublishService(name, implType)`
160
186
 
161
187
  Remove a published service.
@@ -163,7 +189,7 @@ Remove a published service.
163
189
  | Parameter | Type | Default | Description |
164
190
  | ---------- | ------ | -------- | -------------------------------------- |
165
191
  | `name` | string | required | Name of the service to unpublish |
166
- | `implType` | string | `'NSD'` | **Android only**: Which implementation |
192
+ | `implType` | string | publish's | **Android only**: Which implementation. Defaults to the one the service was published with |
167
193
 
168
194
  ```javascript
169
195
  zeroconf.unpublishService('MyWebServer')
@@ -219,10 +245,12 @@ useEffect(() => {
219
245
  fullName: 'XeroxPrinter._http._tcp.local.', // Full service name
220
246
  host: 'XeroxPrinter.local.', // Hostname
221
247
  port: 8080, // Port number
222
- addresses: [ // IP addresses (IPv4 and/or IPv6)
248
+ addresses: [ // IP addresses, IPv4 first
223
249
  '192.168.1.23',
224
250
  'fe80::aebc:123:ffff:abcd'
225
251
  ],
252
+ ipv4: ['192.168.1.23'], // IPv4 addresses only
253
+ ipv6: ['fe80::aebc:123:ffff:abcd'], // IPv6 addresses only
226
254
  txt: { // TXT record attributes
227
255
  path: '/status',
228
256
  color: 'yes'
@@ -230,6 +258,8 @@ useEffect(() => {
230
258
  }
231
259
  ```
232
260
 
261
+ On Android with `NSD`, `host` is the mDNS hostname on Android 16+. Older versions don't expose it, so `host` is usually the IP address (use `DNSSD` if you need the hostname there). `NSD` returns all addresses on Android 14+, and a single one before.
262
+
233
263
  ## Android Implementation Types
234
264
 
235
265
  This library supports two implementations on Android:
@@ -18,11 +18,6 @@ android {
18
18
  abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
19
19
  }
20
20
 
21
- externalNativeBuild {
22
- ndkBuild {
23
- arguments "NDK_APPLICATION_MK:=src/main/jni/Application.mk"
24
- }
25
- }
26
21
  }
27
22
 
28
23
  externalNativeBuild {
@@ -50,6 +45,4 @@ dependencies {
50
45
  // RxJava2 for Rx2Dnssd (bundled from Discord's RxDNSSD fork)
51
46
  api 'io.reactivex.rxjava2:rxjava:2.2.21'
52
47
  api 'io.reactivex.rxjava2:rxandroid:2.1.1'
53
- // https://mvnrepository.com/artifact/org.apache.commons/commons-lang3
54
- api group: 'org.apache.commons', name: 'commons-lang3', version: '3.11'
55
48
  }
@@ -1,2 +1,6 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
4
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
5
+ <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
2
6
  </manifest>
@@ -5,9 +5,9 @@ import com.balthazargronon.RCTZeroconf.rx2dnssd.DnssdImpl;
5
5
  import com.facebook.react.bridge.ReactApplicationContext;
6
6
  import com.facebook.react.bridge.ReactContext;
7
7
 
8
- import org.apache.commons.lang3.StringUtils;
9
-
8
+ import java.util.ArrayList;
10
9
  import java.util.HashMap;
10
+ import java.util.List;
11
11
  import java.util.Map;
12
12
 
13
13
  public class ZeroConfImplFactory {
@@ -25,7 +25,7 @@ public class ZeroConfImplFactory {
25
25
  }
26
26
 
27
27
  public Zeroconf getZeroconf(String implType) {
28
- if (StringUtils.isBlank(implType)) implType = ZeroConfImplFactory.NSD_IMPL;
28
+ if (implType == null || implType.trim().isEmpty()) implType = ZeroConfImplFactory.NSD_IMPL;
29
29
  return getOrCreateImpl(implType);
30
30
  }
31
31
 
@@ -46,4 +46,11 @@ public class ZeroConfImplFactory {
46
46
  return zeroconfMap.get(implType);
47
47
  }
48
48
 
49
+ /**
50
+ * Implementations that have already been created, without instantiating new ones
51
+ */
52
+ public List<Zeroconf> getCreatedImpls() {
53
+ return new ArrayList<>(zeroconfMap.values());
54
+ }
55
+
49
56
  }
@@ -1,6 +1,6 @@
1
1
  package com.balthazargronon.RCTZeroconf;
2
2
 
3
- import com.facebook.react.bridge.ReadableMap;
3
+ import com.facebook.react.bridge.ReadableArray;
4
4
 
5
5
  public interface Zeroconf {
6
6
 
@@ -10,5 +10,13 @@ public interface Zeroconf {
10
10
 
11
11
  public void unregisterService(String serviceName);
12
12
 
13
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt);
13
+ /**
14
+ * @param txt TXT records as ordered [key, value] pairs
15
+ */
16
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt);
17
+
18
+ /**
19
+ * Unregister every service published through this implementation
20
+ */
21
+ void unregisterAllServices();
14
22
  }
@@ -7,7 +7,7 @@ import com.facebook.react.bridge.ReactApplicationContext;
7
7
  import com.facebook.react.bridge.ReactContext;
8
8
  import com.facebook.react.bridge.ReactContextBaseJavaModule;
9
9
  import com.facebook.react.bridge.ReactMethod;
10
- import com.facebook.react.bridge.ReadableMap;
10
+ import com.facebook.react.bridge.ReadableArray;
11
11
  import com.facebook.react.modules.core.DeviceEventManagerModule;
12
12
 
13
13
  import javax.annotation.Nullable;
@@ -73,7 +73,7 @@ public class ZeroconfModule extends ReactContextBaseJavaModule {
73
73
  }
74
74
 
75
75
  @ReactMethod
76
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt, String implType) {
76
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt, String implType) {
77
77
  try {
78
78
  getZeroconfImpl(implType).registerService(type, protocol, domain, name, port, txt);
79
79
  } catch (Throwable e) {
@@ -95,20 +95,36 @@ public class ZeroconfModule extends ReactContextBaseJavaModule {
95
95
  public void sendEvent(ReactContext reactContext,
96
96
  String eventName,
97
97
  @Nullable Object params) {
98
- reactContext
99
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
100
- .emit(eventName, params);
98
+ try {
99
+ reactContext
100
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
101
+ .emit(eventName, params);
102
+ } catch (Throwable e) {
103
+ // The JS side may already be gone, e.g. callbacks arriving during teardown
104
+ Log.w(getClass().getName(), "Could not send " + eventName + ": " + e.getMessage());
105
+ }
101
106
  }
102
107
 
103
- @Override
108
+ // Called on teardown by current React Native versions.
109
+ // No @Override so it still compiles against versions that don't declare it.
110
+ public void invalidate() {
111
+ teardown();
112
+ }
113
+
114
+ // Called on teardown by older React Native versions, deprecated in favor of invalidate().
115
+ // No @Override so it still compiles once it is removed.
104
116
  public void onCatalystInstanceDestroy() {
105
- super.onCatalystInstanceDestroy();
117
+ teardown();
118
+ }
119
+
120
+ private void teardown() {
106
121
  try {
107
- stop(ZeroConfImplFactory.NSD_IMPL);
108
- stop(ZeroConfImplFactory.DNSSD_IMPL);
122
+ for (Zeroconf impl : zeroConfFactory.getCreatedImpls()) {
123
+ impl.stop();
124
+ impl.unregisterAllServices();
125
+ }
109
126
  } catch (Throwable e) {
110
127
  Log.e(getClass().getName(), e.getMessage(), e);
111
- sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, "Exception During Catalyst Destroy: " + e.getMessage());
112
128
  }
113
129
  }
114
130
  }
@@ -6,25 +6,34 @@ import android.net.nsd.NsdManager;
6
6
  import android.net.nsd.NsdServiceInfo;
7
7
  import android.net.wifi.WifiManager;
8
8
  import android.os.Build;
9
+ import android.os.Handler;
10
+ import android.os.Looper;
11
+ import android.util.Log;
9
12
 
10
13
  import com.balthazargronon.RCTZeroconf.Zeroconf;
11
14
  import com.balthazargronon.RCTZeroconf.ZeroconfModule;
12
15
  import com.facebook.react.bridge.ReactApplicationContext;
13
16
 
14
- import com.facebook.react.bridge.ReadableMap;
15
- import com.facebook.react.bridge.ReadableMapKeySetIterator;
17
+ import com.facebook.react.bridge.ReadableArray;
16
18
  import com.facebook.react.bridge.WritableArray;
17
19
  import com.facebook.react.bridge.WritableMap;
18
20
  import com.facebook.react.bridge.WritableNativeArray;
19
21
  import com.facebook.react.bridge.WritableNativeMap;
20
22
 
21
- import java.io.UnsupportedEncodingException;
22
23
  import java.net.InetAddress;
23
- import java.util.HashMap;
24
+ import java.nio.charset.StandardCharsets;
25
+ import java.util.ArrayDeque;
26
+ import java.util.ArrayList;
27
+ import java.util.Collections;
28
+ import java.util.List;
24
29
  import java.util.Locale;
25
30
  import java.util.Map;
31
+ import java.util.concurrent.ConcurrentHashMap;
26
32
 
27
33
  public class NsdServiceImpl implements Zeroconf {
34
+ private static final String TAG = "NsdServiceImpl";
35
+ private static final long RESOLVE_RETRY_DELAY_MS = 100;
36
+
28
37
  private NsdManager mNsdManager;
29
38
  private NsdManager.DiscoveryListener mDiscoveryListener;
30
39
  private WifiManager.MulticastLock multicastLock;
@@ -32,10 +41,15 @@ public class NsdServiceImpl implements Zeroconf {
32
41
  private ZeroconfModule zeroconfModule;
33
42
  private ReactApplicationContext reactApplicationContext;
34
43
 
44
+ // NsdManager can only resolve one service at a time before API 34, so resolves are queued
45
+ private final ArrayDeque<NsdServiceInfo> mResolveQueue = new ArrayDeque<>();
46
+ private boolean mIsResolving = false;
47
+ private final Handler mHandler = new Handler(Looper.getMainLooper());
48
+
35
49
  public NsdServiceImpl(ZeroconfModule zeroconfModule, ReactApplicationContext reactApplicationContext) {
36
50
  this.zeroconfModule = zeroconfModule;
37
51
  this.reactApplicationContext = reactApplicationContext;
38
- mPublishedServices = new HashMap<String, NsdManager.RegistrationListener>();
52
+ mPublishedServices = new ConcurrentHashMap<String, NsdManager.RegistrationListener>();
39
53
  }
40
54
 
41
55
  @Override
@@ -68,29 +82,29 @@ public class NsdServiceImpl implements Zeroconf {
68
82
 
69
83
  @Override
70
84
  public void onDiscoveryStarted(String serviceType) {
71
- System.out.println("On Discovery Started");
85
+ Log.d(TAG, "Discovery started");
72
86
  zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_START, null);
73
87
  }
74
88
 
75
89
  @Override
76
90
  public void onDiscoveryStopped(String serviceType) {
77
- System.out.println("On Discovery Stopped");
91
+ Log.d(TAG, "Discovery stopped");
78
92
  zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_STOP, null);
79
93
  }
80
94
 
81
95
  @Override
82
96
  public void onServiceFound(NsdServiceInfo serviceInfo) {
83
- System.out.println("On Service Found");
97
+ Log.d(TAG, "Service found");
84
98
  WritableMap service = new WritableNativeMap();
85
99
  service.putString(ZeroconfModule.KEY_SERVICE_NAME, serviceInfo.getServiceName());
86
100
 
87
101
  zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_FOUND, service);
88
- mNsdManager.resolveService(serviceInfo, new NsdServiceImpl.ZeroResolveListener());
102
+ enqueueResolve(serviceInfo);
89
103
  }
90
104
 
91
105
  @Override
92
106
  public void onServiceLost(NsdServiceInfo serviceInfo) {
93
- System.out.println("On Service Lost");
107
+ Log.d(TAG, "Service lost");
94
108
  WritableMap service = new WritableNativeMap();
95
109
  service.putString(ZeroconfModule.KEY_SERVICE_NAME, serviceInfo.getServiceName());
96
110
  zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_REMOVE, service);
@@ -103,8 +117,19 @@ public class NsdServiceImpl implements Zeroconf {
103
117
 
104
118
  @Override
105
119
  public void stop() {
106
- if (mDiscoveryListener != null) {
107
- mNsdManager.stopServiceDiscovery(mDiscoveryListener);
120
+ if (mDiscoveryListener != null && mNsdManager != null) {
121
+ try {
122
+ mNsdManager.stopServiceDiscovery(mDiscoveryListener);
123
+ } catch (IllegalArgumentException e) {
124
+ // Listener was never registered or already unregistered (e.g. discovery failed to start)
125
+ Log.w(TAG, "stopServiceDiscovery failed", e);
126
+ }
127
+ }
128
+
129
+ mHandler.removeCallbacksAndMessages(null);
130
+ synchronized (mResolveQueue) {
131
+ mResolveQueue.clear();
132
+ mIsResolving = false;
108
133
  }
109
134
 
110
135
  if (multicastLock != null) {
@@ -116,7 +141,7 @@ public class NsdServiceImpl implements Zeroconf {
116
141
  }
117
142
 
118
143
  @Override
119
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt) {
144
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt) {
120
145
  String serviceType = String.format("_%s._%s.", type, protocol);
121
146
 
122
147
  final NsdManager nsdManager = this.getNsdManager();
@@ -125,10 +150,9 @@ public class NsdServiceImpl implements Zeroconf {
125
150
  serviceInfo.setServiceType(serviceType);
126
151
  serviceInfo.setPort(port);
127
152
 
128
- ReadableMapKeySetIterator iterator = txt.keySetIterator();
129
- while (iterator.hasNextKey()) {
130
- String key = iterator.nextKey();
131
- serviceInfo.setAttribute(key, txt.getString(key));
153
+ for (int i = 0; i < txt.size(); i++) {
154
+ ReadableArray pair = txt.getArray(i);
155
+ serviceInfo.setAttribute(pair.getString(0), pair.getString(1));
132
156
  }
133
157
 
134
158
  nsdManager.registerService(
@@ -148,6 +172,13 @@ public class NsdServiceImpl implements Zeroconf {
148
172
  }
149
173
  }
150
174
 
175
+ @Override
176
+ public void unregisterAllServices() {
177
+ for (String serviceName : new ArrayList<>(mPublishedServices.keySet())) {
178
+ unregisterService(serviceName);
179
+ }
180
+ }
181
+
151
182
  private NsdManager getNsdManager() {
152
183
  if (mNsdManager == null) {
153
184
  mNsdManager = (NsdManager) getReactApplicationContext().getSystemService(Context.NSD_SERVICE);
@@ -159,21 +190,60 @@ public class NsdServiceImpl implements Zeroconf {
159
190
  return reactApplicationContext;
160
191
  }
161
192
 
193
+ private void enqueueResolve(NsdServiceInfo serviceInfo) {
194
+ synchronized (mResolveQueue) {
195
+ mResolveQueue.add(serviceInfo);
196
+ }
197
+ resolveNext();
198
+ }
199
+
200
+ private void resolveNext() {
201
+ NsdServiceInfo next;
202
+ synchronized (mResolveQueue) {
203
+ if (mIsResolving || mResolveQueue.isEmpty()) {
204
+ return;
205
+ }
206
+ next = mResolveQueue.poll();
207
+ mIsResolving = true;
208
+ }
209
+ try {
210
+ getNsdManager().resolveService(next, new ZeroResolveListener());
211
+ } catch (Throwable e) {
212
+ Log.e(TAG, "resolveService failed", e);
213
+ zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, "Resolving service failed: " + e.getMessage());
214
+ onResolveDone();
215
+ }
216
+ }
217
+
218
+ private void onResolveDone() {
219
+ synchronized (mResolveQueue) {
220
+ mIsResolving = false;
221
+ }
222
+ resolveNext();
223
+ }
224
+
162
225
  private class ZeroResolveListener implements NsdManager.ResolveListener {
163
226
  @Override
164
227
  public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
165
228
  if (errorCode == NsdManager.FAILURE_ALREADY_ACTIVE) {
166
- mNsdManager.resolveService(serviceInfo, this);
167
- } else {
168
- String error = "Resolving service failed with code: " + errorCode;
169
- zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, error);
229
+ // Another resolve (possibly from another app or listener) is in flight: retry shortly
230
+ synchronized (mResolveQueue) {
231
+ mResolveQueue.addFirst(serviceInfo);
232
+ mIsResolving = false;
233
+ }
234
+ mHandler.postDelayed(NsdServiceImpl.this::resolveNext, RESOLVE_RETRY_DELAY_MS);
235
+ return;
170
236
  }
237
+ String error = "Resolving service failed with code: " + errorCode;
238
+ zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, error);
239
+ onResolveDone();
171
240
  }
172
241
 
173
242
  @Override
174
243
  public void onServiceResolved(NsdServiceInfo serviceInfo) {
175
244
  WritableMap service = serviceInfoToMap(serviceInfo);
176
245
  zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_RESOLVE, service);
246
+ onResolveDone();
177
247
  }
178
248
  }
179
249
 
@@ -194,7 +264,8 @@ public class NsdServiceImpl implements Zeroconf {
194
264
 
195
265
  @Override
196
266
  public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
197
- // Registration failed! Put debugging code here to determine why.
267
+ String error = "Registering service " + serviceInfo.getServiceName() + " failed with code: " + errorCode;
268
+ zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, error);
198
269
  }
199
270
 
200
271
  @Override
@@ -207,23 +278,60 @@ public class NsdServiceImpl implements Zeroconf {
207
278
 
208
279
  @Override
209
280
  public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
210
- // Unregistration failed. Put debugging code here to determine why.
281
+ String error = "Unregistering service " + serviceInfo.getServiceName() + " failed with code: " + errorCode;
282
+ zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, error);
211
283
  }
212
284
  }
213
285
 
286
+ /**
287
+ * All the addresses of the service on Android 14+, only one before
288
+ */
289
+ @SuppressWarnings("deprecation")
290
+ private static List<InetAddress> getHostAddresses(NsdServiceInfo serviceInfo) {
291
+ if (Build.VERSION.SDK_INT >= 34) {
292
+ return serviceInfo.getHostAddresses();
293
+ }
294
+ InetAddress host = serviceInfo.getHost();
295
+ return host == null ? Collections.<InetAddress>emptyList() : Collections.singletonList(host);
296
+ }
297
+
298
+ /**
299
+ * The mDNS hostname (e.g. "MyHost.local.") when available.
300
+ * NsdServiceInfo.getHostname() is API 36 (or backported through the Connectivity module),
301
+ * it is called through reflection so the library still compiles against older SDKs.
302
+ */
303
+ private static String getMdnsHostname(NsdServiceInfo serviceInfo) {
304
+ try {
305
+ Object hostname = NsdServiceInfo.class.getMethod("getHostname").invoke(serviceInfo);
306
+ if (hostname instanceof String && !((String) hostname).isEmpty()) {
307
+ return hostname + ".local.";
308
+ }
309
+ } catch (Exception e) {
310
+ // Not available on this device
311
+ }
312
+ return null;
313
+ }
314
+
214
315
  private WritableMap serviceInfoToMap(NsdServiceInfo serviceInfo) {
215
316
  WritableMap service = new WritableNativeMap();
216
317
  service.putString(ZeroconfModule.KEY_SERVICE_NAME, serviceInfo.getServiceName());
217
- final InetAddress host = serviceInfo.getHost();
318
+ final List<InetAddress> hostAddresses = getHostAddresses(serviceInfo);
218
319
  final String fullServiceName;
219
- if (host == null) {
320
+ if (hostAddresses.isEmpty()) {
220
321
  fullServiceName = serviceInfo.getServiceName();
221
322
  } else {
222
- fullServiceName = host.getHostName() + serviceInfo.getServiceType();
223
- service.putString(ZeroconfModule.KEY_SERVICE_HOST, host.getHostName());
323
+ String hostname = getMdnsHostname(serviceInfo);
324
+ if (hostname == null) {
325
+ // No hostname API before Android 16, falls back to a reverse lookup (often the IP)
326
+ hostname = hostAddresses.get(0).getHostName();
327
+ }
328
+ fullServiceName = hostname + serviceInfo.getServiceType();
329
+ service.putString(ZeroconfModule.KEY_SERVICE_HOST, hostname);
224
330
 
225
331
  WritableArray addresses = new WritableNativeArray();
226
- addresses.pushString(host.getHostAddress());
332
+ for (InetAddress address : hostAddresses) {
333
+ addresses.pushString(address.getHostAddress());
334
+ }
227
335
 
228
336
  service.putArray(ZeroconfModule.KEY_SERVICE_ADDRESSES, addresses);
229
337
  }
@@ -234,13 +342,10 @@ public class NsdServiceImpl implements Zeroconf {
234
342
 
235
343
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
236
344
  Map<String, byte[]> attributes = serviceInfo.getAttributes();
237
- for (String key : attributes.keySet()) {
238
- try {
345
+ if (attributes != null) {
346
+ for (String key : attributes.keySet()) {
239
347
  byte[] recordValue = attributes.get(key);
240
- txtRecords.putString(String.format(Locale.getDefault(), "%s", key), String.format(Locale.getDefault(), "%s", recordValue != null ? new String(recordValue, "UTF_8") : ""));
241
- } catch (UnsupportedEncodingException e) {
242
- String error = "Failed to encode txtRecord: " + e;
243
- zeroconfModule.sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, error);
348
+ txtRecords.putString(String.format(Locale.getDefault(), "%s", key), recordValue != null ? new String(recordValue, StandardCharsets.UTF_8) : "");
244
349
  }
245
350
  }
246
351
  }
@@ -8,8 +8,7 @@ import android.util.Log;
8
8
  import com.balthazargronon.RCTZeroconf.Zeroconf;
9
9
  import com.balthazargronon.RCTZeroconf.ZeroconfModule;
10
10
  import com.facebook.react.bridge.ReactApplicationContext;
11
- import com.facebook.react.bridge.ReadableMap;
12
- import com.facebook.react.bridge.ReadableMapKeySetIterator;
11
+ import com.facebook.react.bridge.ReadableArray;
13
12
  import com.facebook.react.bridge.WritableArray;
14
13
  import com.facebook.react.bridge.WritableMap;
15
14
  import com.facebook.react.bridge.WritableNativeArray;
@@ -19,18 +18,23 @@ import com.github.druk.rx2dnssd.Rx2Dnssd;
19
18
  import com.github.druk.rx2dnssd.Rx2DnssdEmbedded;
20
19
 
21
20
  import java.net.InetAddress;
21
+ import java.util.ArrayList;
22
22
  import java.util.HashMap;
23
+ import java.util.LinkedHashMap;
23
24
  import java.util.Locale;
24
25
  import java.util.Map;
25
26
  import java.util.List;
26
27
 
27
28
  import javax.annotation.Nullable;
28
29
 
30
+ import io.reactivex.Flowable;
29
31
  import io.reactivex.android.schedulers.AndroidSchedulers;
30
32
  import io.reactivex.disposables.Disposable;
31
33
  import io.reactivex.schedulers.Schedulers;
32
34
 
33
35
  public class DnssdImpl implements Zeroconf {
36
+ private static final String TAG = "DnssdImpl";
37
+
34
38
  private Rx2Dnssd rxDnssd;
35
39
 
36
40
  @Nullable
@@ -74,26 +78,49 @@ public class DnssdImpl implements Zeroconf {
74
78
  }
75
79
 
76
80
  String serviceType = getServiceType(type, protocol);
77
- Log.d("DnssdImpl", "Starting DNSSD scan for: " + serviceType);
81
+ Log.d(TAG, "Starting DNSSD scan for: " + serviceType);
78
82
 
79
83
  // Emit start event
80
84
  zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_START, null);
81
85
 
82
86
  browseDisposable = rxDnssd.browse(serviceType, "local.")
83
- .compose(rxDnssd.resolve())
84
- .compose(rxDnssd.queryRecords())
87
+ .doOnNext(bonjourService -> {
88
+ if (!bonjourService.isLost()) {
89
+ zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_FOUND, serviceNameToMap(bonjourService));
90
+ }
91
+ })
92
+ // Resolve each service independently so a single failure doesn't end the whole browse
93
+ .flatMap(bonjourService -> bonjourService.isLost()
94
+ ? Flowable.just(bonjourService)
95
+ : Flowable.just(bonjourService)
96
+ .compose(rxDnssd.resolve())
97
+ .compose(rxDnssd.queryRecords())
98
+ .onErrorResumeNext((Throwable throwable) -> {
99
+ Log.e(TAG, "Error resolving service: ", throwable);
100
+ zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_ERROR, "Resolving service failed: " + throwable.getMessage());
101
+ return Flowable.empty();
102
+ }))
85
103
  .subscribeOn(Schedulers.io())
86
104
  .observeOn(AndroidSchedulers.mainThread())
87
105
  .subscribe(bonjourService -> {
88
- WritableMap service = serviceInfoToMap(bonjourService);
89
- Log.d(getClass().getName(), service.toString());
90
- zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_RESOLVE, service);
106
+ if (bonjourService.isLost()) {
107
+ zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_REMOVE, serviceNameToMap(bonjourService));
108
+ return;
109
+ }
110
+ zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_RESOLVE, serviceInfoToMap(bonjourService));
91
111
  }, throwable -> {
92
- Log.e(getClass().getName(), "Error resolving service: ", throwable);
93
- zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_ERROR, throwable.getMessage());
112
+ Log.e(TAG, "Error browsing services: ", throwable);
113
+ zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_ERROR, "Browsing services failed: " + throwable.getMessage());
114
+ zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_STOP, null);
94
115
  });
95
116
  }
96
117
 
118
+ private WritableMap serviceNameToMap(BonjourService serviceInfo) {
119
+ WritableMap service = new WritableNativeMap();
120
+ service.putString(ZeroconfModule.KEY_SERVICE_NAME, serviceInfo.getServiceName());
121
+ return service;
122
+ }
123
+
97
124
  private String getServiceType(String type, String protocol) {
98
125
  return String.format("_%s._%s", type, protocol);
99
126
  }
@@ -102,10 +129,11 @@ public class DnssdImpl implements Zeroconf {
102
129
  WritableMap service = new WritableNativeMap();
103
130
  service.putString(ZeroconfModule.KEY_SERVICE_NAME, serviceInfo.getServiceName());
104
131
  final List<InetAddress> hostList = serviceInfo.getInetAddresses();
105
- final String fullServiceName;
106
- Log.d("TAG", serviceInfo.getServiceName());
107
- fullServiceName = serviceInfo.getServiceName();
108
- service.putString(ZeroconfModule.KEY_SERVICE_HOST, fullServiceName);
132
+ final String hostname = serviceInfo.getHostname();
133
+ final String fullServiceName = hostname != null
134
+ ? hostname + serviceInfo.getRegType()
135
+ : serviceInfo.getServiceName();
136
+ service.putString(ZeroconfModule.KEY_SERVICE_HOST, hostname != null ? hostname : serviceInfo.getServiceName());
109
137
 
110
138
  WritableArray addresses = new WritableNativeArray();
111
139
  for (InetAddress host : hostList) {
@@ -160,7 +188,7 @@ public class DnssdImpl implements Zeroconf {
160
188
  }
161
189
 
162
190
  @Override
163
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt) {
191
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt) {
164
192
  BonjourService bs = new BonjourService.Builder(0, 0, name, getServiceType(type, protocol), null)
165
193
  .port(port)
166
194
  .dnsRecords(getTxtRecordMap(txt))
@@ -170,23 +198,32 @@ public class DnssdImpl implements Zeroconf {
170
198
  .subscribeOn(Schedulers.io())
171
199
  .observeOn(AndroidSchedulers.mainThread())
172
200
  .subscribe(bonjourService -> {
173
- Log.i("TAG", "Register successfully " + bonjourService.toString());
201
+ Log.i(TAG, "Registered service " + bonjourService.toString());
174
202
 
175
203
  mPublishedServices.put(bs.getServiceName(), bs);
176
204
  zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_PUBLISHED, serviceInfoToMap(bonjourService));
177
205
  }, throwable -> {
178
- Log.e("TAG", "error", throwable);
206
+ Log.e(TAG, "Error registering service: ", throwable);
207
+ zeroconfModule.sendEvent(reactApplicationContext, ZeroconfModule.EVENT_ERROR, "Registering service failed: " + throwable.getMessage());
208
+ mRegisteredDisposables.remove(name);
179
209
  });
180
210
 
181
211
  mRegisteredDisposables.put(name, registerDisposable);
182
212
  }
183
213
 
184
- private Map<String, String> getTxtRecordMap(ReadableMap txt) {
185
- Map<String, String> txtMap = new HashMap<>();
186
- ReadableMapKeySetIterator iterator = txt.keySetIterator();
187
- while (iterator.hasNextKey()) {
188
- String key = iterator.nextKey();
189
- txtMap.put(key, txt.getString(key));
214
+ @Override
215
+ public void unregisterAllServices() {
216
+ for (String serviceName : new ArrayList<>(mRegisteredDisposables.keySet())) {
217
+ unregisterService(serviceName);
218
+ }
219
+ }
220
+
221
+ private Map<String, String> getTxtRecordMap(ReadableArray txt) {
222
+ // LinkedHashMap keeps the TXT record order
223
+ Map<String, String> txtMap = new LinkedHashMap<>();
224
+ for (int i = 0; i < txt.size(); i++) {
225
+ ReadableArray pair = txt.getArray(i);
226
+ txtMap.put(pair.getString(0), pair.getString(1));
190
227
  }
191
228
  return txtMap;
192
229
  }
package/dist/index.js CHANGED
@@ -11,11 +11,40 @@ const ImplType = exports.ImplType = {
11
11
  NSD: 'NSD',
12
12
  DNSSD: 'DNSSD'
13
13
  };
14
+ const isIPv6 = address => address.includes(':');
15
+
16
+ /**
17
+ * Sort addresses IPv4 first and expose them by family
18
+ */
19
+ const withAddressFamilies = service => {
20
+ const addresses = Array.isArray(service.addresses) ? service.addresses : [];
21
+ const ipv4 = addresses.filter(address => !isIPv6(address));
22
+ const ipv6 = addresses.filter(isIPv6);
23
+ return {
24
+ ...service,
25
+ addresses: [...ipv4, ...ipv6],
26
+ ipv4,
27
+ ipv6
28
+ };
29
+ };
30
+
31
+ /**
32
+ * TXT records as ordered [key, value] pairs, from an object or an array of pairs
33
+ */
34
+ const toTxtPairs = txt => {
35
+ const entries = Array.isArray(txt) ? txt : Object.entries(txt || {});
36
+ return entries.map(([key, value]) => [String(key), String(value)]);
37
+ };
14
38
  class Zeroconf extends _events.EventEmitter {
15
39
  constructor(props) {
16
40
  super(props);
41
+ if (!RNZeroconf) {
42
+ throw new Error('react-native-zeroconf: native module not found. Make sure the library is linked and the app rebuilt. Expo Go is not supported, use a development build instead.');
43
+ }
17
44
  this._services = {};
18
45
  this._publishedServices = {};
46
+ this._publishedImplTypes = {};
47
+ this._scanImplType = null;
19
48
  this._dListeners = {};
20
49
  this.addDeviceListeners();
21
50
  }
@@ -56,10 +85,11 @@ class Zeroconf extends _events.EventEmitter {
56
85
  this.emit('remove', name);
57
86
  this.emit('update');
58
87
  });
59
- this._dListeners.resolved = _reactNative.DeviceEventEmitter.addListener('RNZeroconfResolved', service => {
60
- if (!service || !service.name) {
88
+ this._dListeners.resolved = _reactNative.DeviceEventEmitter.addListener('RNZeroconfResolved', data => {
89
+ if (!data || !data.name) {
61
90
  return;
62
91
  }
92
+ const service = withAddressFamilies(data);
63
93
  this._services[service.name] = service;
64
94
  this.emit('resolved', service);
65
95
  this.emit('update');
@@ -103,6 +133,11 @@ class Zeroconf extends _events.EventEmitter {
103
133
  this._services = {};
104
134
  this.emit('update');
105
135
  if (_reactNative.Platform.OS === 'android') {
136
+ if (this._scanImplType && implType !== this._scanImplType) {
137
+ // Only one scan runs at a time, stop the one running on the other implementation
138
+ RNZeroconf.stop(this._scanImplType);
139
+ }
140
+ this._scanImplType = implType;
106
141
  RNZeroconf.scan(type, protocol, domain, implType);
107
142
  } else {
108
143
  RNZeroconf.scan(type, protocol, domain);
@@ -110,9 +145,10 @@ class Zeroconf extends _events.EventEmitter {
110
145
  }
111
146
 
112
147
  /**
113
- * Stop current scan if any
148
+ * Stop current scan if any,
149
+ * Defaults to the implementation used by the last scan
114
150
  */
115
- stop(implType = ImplType.NSD) {
151
+ stop(implType = this._scanImplType || ImplType.NSD) {
116
152
  if (_reactNative.Platform.OS === 'android') {
117
153
  RNZeroconf.stop(implType);
118
154
  } else {
@@ -124,21 +160,22 @@ class Zeroconf extends _events.EventEmitter {
124
160
  * Publish a service
125
161
  */
126
162
  publishService(type, protocol, domain = 'local.', name, port, txt = {}, implType = ImplType.NSD) {
127
- if (Object.keys(txt).length !== 0) {
128
- Object.entries(txt).map(([key, value]) => txt[key] = value.toString());
129
- }
163
+ const txtRecord = toTxtPairs(txt);
130
164
  if (_reactNative.Platform.OS === 'android') {
131
- RNZeroconf.registerService(type, protocol, domain, name, port, txt, implType);
165
+ this._publishedImplTypes[name] = implType;
166
+ RNZeroconf.registerService(type, protocol, domain, name, port, txtRecord, implType);
132
167
  } else {
133
- RNZeroconf.registerService(type, protocol, domain, name, port, txt);
168
+ RNZeroconf.registerService(type, protocol, domain, name, port, txtRecord);
134
169
  }
135
170
  }
136
171
 
137
172
  /**
138
- * Unpublish a service
173
+ * Unpublish a service,
174
+ * Defaults to the implementation the service was published with
139
175
  */
140
- unpublishService(name, implType = ImplType.NSD) {
176
+ unpublishService(name, implType = this._publishedImplTypes[name] || ImplType.NSD) {
141
177
  if (_reactNative.Platform.OS === 'android') {
178
+ delete this._publishedImplTypes[name];
142
179
  RNZeroconf.unregisterService(name, implType);
143
180
  } else {
144
181
  RNZeroconf.unregisterService(name);
package/index.d.ts ADDED
@@ -0,0 +1,101 @@
1
+ export declare const ImplType: {
2
+ readonly NSD: 'NSD'
3
+ readonly DNSSD: 'DNSSD'
4
+ }
5
+
6
+ /** Android only: which discovery implementation to use */
7
+ export type ImplType = (typeof ImplType)[keyof typeof ImplType]
8
+
9
+ export interface Service {
10
+ /** Human-readable service name */
11
+ name: string
12
+ /** Full service name, e.g. `XeroxPrinter.local._http._tcp.` */
13
+ fullName: string
14
+ /** Hostname, e.g. `XeroxPrinter.local.` */
15
+ host: string
16
+ port: number
17
+ /** IPv4 and IPv6 addresses, IPv4 first */
18
+ addresses: string[]
19
+ ipv4: string[]
20
+ ipv6: string[]
21
+ /** TXT record attributes */
22
+ txt: Record<string, string>
23
+ }
24
+
25
+ type TxtValue = string | number | boolean
26
+
27
+ /** TXT records as an object, or as [key, value] pairs to control the order */
28
+ export type TxtRecord = Record<string, TxtValue> | Array<[string, TxtValue]>
29
+
30
+ /** Published services don't carry the address family fields */
31
+ export type PublishedService = Omit<Service, 'ipv4' | 'ipv6'>
32
+
33
+ export interface ZeroconfEvents {
34
+ start: () => void
35
+ stop: () => void
36
+ /** Service found, before resolution */
37
+ found: (name: string) => void
38
+ /** Service fully resolved with network info */
39
+ resolved: (service: Service) => void
40
+ /** Service removed from the network */
41
+ remove: (name: string) => void
42
+ /** Services list changed */
43
+ update: () => void
44
+ error: (error: Error) => void
45
+ published: (service: PublishedService) => void
46
+ unpublished: (service: PublishedService) => void
47
+ }
48
+
49
+ /** Extends the `events` package EventEmitter */
50
+ export default class Zeroconf {
51
+ constructor()
52
+
53
+ on<E extends keyof ZeroconfEvents>(event: E, listener: ZeroconfEvents[E]): this
54
+ once<E extends keyof ZeroconfEvents>(event: E, listener: ZeroconfEvents[E]): this
55
+ addListener<E extends keyof ZeroconfEvents>(event: E, listener: ZeroconfEvents[E]): this
56
+ removeListener<E extends keyof ZeroconfEvents>(event: E, listener: ZeroconfEvents[E]): this
57
+ off<E extends keyof ZeroconfEvents>(event: E, listener: ZeroconfEvents[E]): this
58
+ removeAllListeners(event?: keyof ZeroconfEvents): this
59
+ listenerCount(event: keyof ZeroconfEvents): number
60
+
61
+ /** Add the native event listeners (called automatically in the constructor) */
62
+ addDeviceListeners(): void
63
+
64
+ /** Remove the native event listeners, call it when you are done with the instance */
65
+ removeDeviceListeners(): void
66
+
67
+ /** All the services found so far, keyed by name */
68
+ getServices(): Record<string, Service>
69
+
70
+ /**
71
+ * Scan for services, defaults to `_http._tcp.` on the `local.` domain
72
+ * @param implType Android only, defaults to `NSD`
73
+ */
74
+ scan(type?: string, protocol?: string, domain?: string, implType?: ImplType): void
75
+
76
+ /**
77
+ * Stop the current scan
78
+ * @param implType Android only, defaults to the implementation used by the last scan
79
+ */
80
+ stop(implType?: ImplType): void
81
+
82
+ /**
83
+ * Publish a service. Type and protocol are passed without underscores, e.g. `('http', 'tcp', ...)`
84
+ * @param implType Android only, defaults to `NSD`
85
+ */
86
+ publishService(
87
+ type: string,
88
+ protocol: string,
89
+ domain: string | undefined,
90
+ name: string,
91
+ port: number,
92
+ txt?: TxtRecord,
93
+ implType?: ImplType,
94
+ ): void
95
+
96
+ /**
97
+ * Unpublish a service
98
+ * @param implType Android only, defaults to the implementation the service was published with
99
+ */
100
+ unpublishService(name: string, implType?: ImplType): void
101
+ }
@@ -35,9 +35,13 @@ const NSString *kRNServiceTxtRecords = @"txt";
35
35
  NSMutableDictionary *dict = [[NSMutableDictionary alloc] init];
36
36
  for (NSString *key in txtRecordDict) {
37
37
  @try{
38
- dict[key] = [[NSString alloc]
39
- initWithData:txtRecordDict[key]
40
- encoding:NSASCIIStringEncoding];
38
+ NSData *value = txtRecordDict[key];
39
+ NSString *string = [[NSString alloc] initWithData:value encoding:NSUTF8StringEncoding];
40
+ if (string == nil) {
41
+ // Not valid UTF-8, fall back to a lossless 8-bit decoding
42
+ string = [[NSString alloc] initWithData:value encoding:NSISOLatin1StringEncoding];
43
+ }
44
+ dict[key] = string;
41
45
  }
42
46
  @catch(NSException *exception){
43
47
  NSLog(@"%@", exception);
@@ -10,8 +10,9 @@
10
10
  #import <React/RCTBridge.h>
11
11
  #import <React/RCTBridgeModule.h>
12
12
  #import <React/RCTEventDispatcher.h>
13
+ #import <React/RCTInvalidating.h>
13
14
 
14
- @interface RNZeroconf : NSObject <RCTBridgeModule, NSNetServiceBrowserDelegate, NSNetServiceDelegate>
15
+ @interface RNZeroconf : NSObject <RCTBridgeModule, RCTInvalidating, NSNetServiceBrowserDelegate, NSNetServiceDelegate>
15
16
 
16
17
  @property (nonatomic, strong) NSNetServiceBrowser *browser;
17
18
 
@@ -49,15 +49,14 @@ RCT_EXPORT_METHOD(registerService:(NSString *)type
49
49
  domain:(NSString *)domain
50
50
  name:(NSString *)name
51
51
  port:(int)port
52
- txt:(NSDictionary *)txt)
52
+ txt:(NSArray<NSArray<NSString *> *> *)txt)
53
53
  {
54
54
  const NSNetService *svc = [[NSNetService alloc] initWithDomain:domain type:[NSString stringWithFormat:@"_%@._%@.", type, protocol] name:name port:port];
55
55
  [svc setDelegate:self];
56
56
  [svc scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
57
57
 
58
- if (txt) {
59
- NSData *txtData = [NSNetService dataFromTXTRecordDictionary:txt];
60
- [svc setTXTRecordData:txtData];
58
+ if (txt.count > 0) {
59
+ [svc setTXTRecordData:[self TXTRecordDataFromPairs:txt]];
61
60
  }
62
61
 
63
62
  [svc publish];
@@ -106,6 +105,14 @@ RCT_EXPORT_METHOD(unregisterService:(NSString *) serviceName)
106
105
  return;
107
106
  }
108
107
 
108
+ // Stop any pending resolve for the removed service
109
+ NSNetService *resolving = self.resolvingServices[service.name];
110
+ if (resolving) {
111
+ resolving.delegate = nil;
112
+ [resolving stop];
113
+ [self.resolvingServices removeObjectForKey:service.name];
114
+ }
115
+
109
116
  NSDictionary *serviceInfo = [RNNetServiceSerializer serializeServiceToDictionary:service resolved:NO];
110
117
  [self.bridge.eventDispatcher sendDeviceEventWithName:@"RNZeroconfRemove" body:serviceInfo];
111
118
  }
@@ -205,6 +212,42 @@ RCT_EXPORT_METHOD(unregisterService:(NSString *) serviceName)
205
212
  return self;
206
213
  }
207
214
 
215
+ // Builds the TXT record in the given order, unlike dataFromTXTRecordDictionary
216
+ - (NSData *) TXTRecordDataFromPairs:(NSArray<NSArray<NSString *> *> *)pairs
217
+ {
218
+ NSMutableData *data = [NSMutableData data];
219
+ for (NSArray<NSString *> *pair in pairs) {
220
+ if (pair.count != 2) {
221
+ continue;
222
+ }
223
+ NSData *entry = [[NSString stringWithFormat:@"%@=%@", pair[0], pair[1]] dataUsingEncoding:NSUTF8StringEncoding];
224
+ if (entry.length > 255) {
225
+ [self.bridge.eventDispatcher sendDeviceEventWithName:@"RNZeroconfError" body:[NSString stringWithFormat:@"TXT record entry %@ is longer than 255 bytes", pair[0]]];
226
+ continue;
227
+ }
228
+ uint8_t length = (uint8_t)entry.length;
229
+ [data appendBytes:&length length:1];
230
+ [data appendData:entry];
231
+ }
232
+ return data;
233
+ }
234
+
235
+ // Called when the bridge is torn down (e.g. reload): stop scanning and unpublish services
236
+ - (void) invalidate
237
+ {
238
+ [self.browser stop];
239
+ for (NSNetService *service in self.resolvingServices.allValues) {
240
+ service.delegate = nil;
241
+ [service stop];
242
+ }
243
+ [self.resolvingServices removeAllObjects];
244
+ for (NSNetService *service in self.publishedServices.allValues) {
245
+ service.delegate = nil;
246
+ [service stop];
247
+ }
248
+ [self.publishedServices removeAllObjects];
249
+ }
250
+
208
251
  - (void) reportError:(NSDictionary *)errorDict
209
252
  {
210
253
  [self.bridge.eventDispatcher sendDeviceEventWithName:@"RNZeroconfError" body:[NSString stringWithFormat:@"%@",errorDict]];
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "react-native-zeroconf",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "A Zeroconf discovery utility for react-native",
5
5
  "main": "dist",
6
+ "types": "index.d.ts",
6
7
  "scripts": {
7
8
  "lint": "eslint src/*.js",
8
9
  "format": "prettier --write src/*.js",
@@ -15,6 +16,7 @@
15
16
  "files": [
16
17
  "src",
17
18
  "dist",
19
+ "index.d.ts",
18
20
  "android",
19
21
  "ios",
20
22
  "react-native-zeroconf.podspec",
package/src/index.js CHANGED
@@ -8,12 +8,40 @@ export const ImplType = {
8
8
  DNSSD: 'DNSSD',
9
9
  }
10
10
 
11
+ const isIPv6 = address => address.includes(':')
12
+
13
+ /**
14
+ * Sort addresses IPv4 first and expose them by family
15
+ */
16
+ const withAddressFamilies = service => {
17
+ const addresses = Array.isArray(service.addresses) ? service.addresses : []
18
+ const ipv4 = addresses.filter(address => !isIPv6(address))
19
+ const ipv6 = addresses.filter(isIPv6)
20
+ return { ...service, addresses: [...ipv4, ...ipv6], ipv4, ipv6 }
21
+ }
22
+
23
+ /**
24
+ * TXT records as ordered [key, value] pairs, from an object or an array of pairs
25
+ */
26
+ const toTxtPairs = txt => {
27
+ const entries = Array.isArray(txt) ? txt : Object.entries(txt || {})
28
+ return entries.map(([key, value]) => [String(key), String(value)])
29
+ }
30
+
11
31
  export default class Zeroconf extends EventEmitter {
12
32
  constructor(props) {
13
33
  super(props)
14
34
 
35
+ if (!RNZeroconf) {
36
+ throw new Error(
37
+ 'react-native-zeroconf: native module not found. Make sure the library is linked and the app rebuilt. Expo Go is not supported, use a development build instead.',
38
+ )
39
+ }
40
+
15
41
  this._services = {}
16
42
  this._publishedServices = {}
43
+ this._publishedImplTypes = {}
44
+ this._scanImplType = null
17
45
  this._dListeners = {}
18
46
 
19
47
  this.addDeviceListeners()
@@ -64,11 +92,12 @@ export default class Zeroconf extends EventEmitter {
64
92
  this.emit('update')
65
93
  })
66
94
 
67
- this._dListeners.resolved = DeviceEventEmitter.addListener('RNZeroconfResolved', service => {
68
- if (!service || !service.name) {
95
+ this._dListeners.resolved = DeviceEventEmitter.addListener('RNZeroconfResolved', data => {
96
+ if (!data || !data.name) {
69
97
  return
70
98
  }
71
99
 
100
+ const service = withAddressFamilies(data)
72
101
  this._services[service.name] = service
73
102
  this.emit('resolved', service)
74
103
  this.emit('update')
@@ -122,6 +151,11 @@ export default class Zeroconf extends EventEmitter {
122
151
  this._services = {}
123
152
  this.emit('update')
124
153
  if (Platform.OS === 'android') {
154
+ if (this._scanImplType && implType !== this._scanImplType) {
155
+ // Only one scan runs at a time, stop the one running on the other implementation
156
+ RNZeroconf.stop(this._scanImplType)
157
+ }
158
+ this._scanImplType = implType
125
159
  RNZeroconf.scan(type, protocol, domain, implType)
126
160
  } else {
127
161
  RNZeroconf.scan(type, protocol, domain)
@@ -129,9 +163,10 @@ export default class Zeroconf extends EventEmitter {
129
163
  }
130
164
 
131
165
  /**
132
- * Stop current scan if any
166
+ * Stop current scan if any,
167
+ * Defaults to the implementation used by the last scan
133
168
  */
134
- stop(implType = ImplType.NSD) {
169
+ stop(implType = this._scanImplType || ImplType.NSD) {
135
170
  if (Platform.OS === 'android') {
136
171
  RNZeroconf.stop(implType)
137
172
  } else {
@@ -143,21 +178,22 @@ export default class Zeroconf extends EventEmitter {
143
178
  * Publish a service
144
179
  */
145
180
  publishService(type, protocol, domain = 'local.', name, port, txt = {}, implType = ImplType.NSD) {
146
- if (Object.keys(txt).length !== 0) {
147
- Object.entries(txt).map(([key, value]) => (txt[key] = value.toString()))
148
- }
181
+ const txtRecord = toTxtPairs(txt)
149
182
  if (Platform.OS === 'android') {
150
- RNZeroconf.registerService(type, protocol, domain, name, port, txt, implType)
183
+ this._publishedImplTypes[name] = implType
184
+ RNZeroconf.registerService(type, protocol, domain, name, port, txtRecord, implType)
151
185
  } else {
152
- RNZeroconf.registerService(type, protocol, domain, name, port, txt)
186
+ RNZeroconf.registerService(type, protocol, domain, name, port, txtRecord)
153
187
  }
154
188
  }
155
189
 
156
190
  /**
157
- * Unpublish a service
191
+ * Unpublish a service,
192
+ * Defaults to the implementation the service was published with
158
193
  */
159
- unpublishService(name, implType = ImplType.NSD) {
194
+ unpublishService(name, implType = this._publishedImplTypes[name] || ImplType.NSD) {
160
195
  if (Platform.OS === 'android') {
196
+ delete this._publishedImplTypes[name]
161
197
  RNZeroconf.unregisterService(name, implType)
162
198
  } else {
163
199
  RNZeroconf.unregisterService(name)