1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package android.net.thread.utils; 17 18 import static android.net.DnsResolver.TYPE_A; 19 import static android.net.DnsResolver.TYPE_AAAA; 20 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT; 21 import static android.net.thread.utils.IntegrationTestUtils.waitFor; 22 23 import static com.google.common.io.BaseEncoding.base16; 24 25 import static java.util.concurrent.TimeUnit.SECONDS; 26 27 import android.net.InetAddresses; 28 import android.net.IpPrefix; 29 import android.net.nsd.NsdServiceInfo; 30 import android.net.thread.ActiveOperationalDataset; 31 import android.os.Handler; 32 import android.os.HandlerThread; 33 34 import com.google.errorprone.annotations.FormatMethod; 35 36 import java.io.BufferedReader; 37 import java.io.BufferedWriter; 38 import java.io.IOException; 39 import java.io.InputStreamReader; 40 import java.io.OutputStreamWriter; 41 import java.net.Inet6Address; 42 import java.net.InetAddress; 43 import java.nio.charset.StandardCharsets; 44 import java.time.Duration; 45 import java.util.ArrayList; 46 import java.util.List; 47 import java.util.Map; 48 import java.util.concurrent.CompletableFuture; 49 import java.util.concurrent.ExecutionException; 50 import java.util.concurrent.TimeoutException; 51 import java.util.regex.Matcher; 52 import java.util.regex.Pattern; 53 54 /** 55 * A class that launches and controls a simulation Full Thread Device (FTD). 56 * 57 * <p>This class launches an `ot-cli-ftd` process and communicates with it via command line input 58 * and output. See <a 59 * href="https://github.com/openthread/openthread/blob/main/src/cli/README.md">this page</a> for 60 * available commands. 61 */ 62 public final class FullThreadDevice { 63 private static final int HOP_LIMIT = 64; 64 private static final int PING_INTERVAL = 1; 65 private static final int PING_SIZE = 100; 66 // There may not be a response for the ping command, using a short timeout to keep the tests 67 // short. 68 private static final float PING_TIMEOUT_0_1_SECOND = 0.1f; 69 // 1 second timeout should be used when response is expected. 70 private static final float PING_TIMEOUT_1_SECOND = 1f; 71 private static final int READ_LINE_TIMEOUT_SECONDS = 5; 72 73 private final Process mProcess; 74 private final BufferedReader mReader; 75 private final BufferedWriter mWriter; 76 private final HandlerThread mReaderHandlerThread; 77 private final Handler mReaderHandler; 78 79 private ActiveOperationalDataset mActiveOperationalDataset; 80 81 /** 82 * Constructs a {@link FullThreadDevice} for the given node ID. 83 * 84 * <p>It launches an `ot-cli-ftd` process using the given node ID. The node ID is an integer in 85 * range [1, OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE]. `OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE` 86 * is defined in `external/openthread/examples/platforms/simulation/platform-config.h`. 87 * 88 * @param nodeId the node ID for the simulation Full Thread Device. 89 * @throws IllegalStateException the node ID is already occupied by another simulation Thread 90 * device. 91 */ FullThreadDevice(int nodeId)92 public FullThreadDevice(int nodeId) { 93 try { 94 mProcess = Runtime.getRuntime().exec("/system/bin/ot-cli-ftd -Leth1 " + nodeId); 95 } catch (IOException e) { 96 throw new IllegalStateException( 97 "Failed to start ot-cli-ftd -Leth1 (id=" + nodeId + ")", e); 98 } 99 mReader = new BufferedReader(new InputStreamReader(mProcess.getInputStream())); 100 mWriter = new BufferedWriter(new OutputStreamWriter(mProcess.getOutputStream())); 101 mReaderHandlerThread = new HandlerThread("FullThreadDeviceReader"); 102 mReaderHandlerThread.start(); 103 mReaderHandler = new Handler(mReaderHandlerThread.getLooper()); 104 mActiveOperationalDataset = null; 105 } 106 destroy()107 public void destroy() { 108 mProcess.destroy(); 109 mReaderHandlerThread.quit(); 110 } 111 112 /** 113 * Returns an OMR (Off-Mesh-Routable) address on this device if any. 114 * 115 * <p>This methods goes through all unicast addresses on the device and returns the first 116 * address which is neither link-local nor mesh-local. 117 */ getOmrAddress()118 public Inet6Address getOmrAddress() { 119 List<String> addresses = executeCommand("ipaddr"); 120 IpPrefix meshLocalPrefix = mActiveOperationalDataset.getMeshLocalPrefix(); 121 for (String address : addresses) { 122 if (address.startsWith("fe80:")) { 123 continue; 124 } 125 Inet6Address addr = (Inet6Address) InetAddresses.parseNumericAddress(address); 126 if (!meshLocalPrefix.contains(addr)) { 127 return addr; 128 } 129 } 130 return null; 131 } 132 133 /** Returns the Mesh-local EID address on this device if any. */ getMlEid()134 public Inet6Address getMlEid() { 135 List<String> addresses = executeCommand("ipaddr mleid"); 136 return (Inet6Address) InetAddresses.parseNumericAddress(addresses.get(0)); 137 } 138 139 /** 140 * Returns the link-local address of the device. 141 * 142 * <p>This methods goes through all unicast addresses on the device and returns the address that 143 * begins with fe80. 144 */ getLinkLocalAddress()145 public Inet6Address getLinkLocalAddress() { 146 List<String> output = executeCommand("ipaddr linklocal"); 147 if (!output.isEmpty() && output.get(0).startsWith("fe80:")) { 148 return (Inet6Address) InetAddresses.parseNumericAddress(output.get(0)); 149 } 150 return null; 151 } 152 153 /** 154 * Returns the mesh-local addresses of the device. 155 * 156 * <p>This methods goes through all unicast addresses on the device and returns the address that 157 * begins with mesh-local prefix. 158 */ getMeshLocalAddresses()159 public List<Inet6Address> getMeshLocalAddresses() { 160 List<String> addresses = executeCommand("ipaddr"); 161 List<Inet6Address> meshLocalAddresses = new ArrayList<>(); 162 IpPrefix meshLocalPrefix = mActiveOperationalDataset.getMeshLocalPrefix(); 163 for (String address : addresses) { 164 Inet6Address addr = (Inet6Address) InetAddresses.parseNumericAddress(address); 165 if (meshLocalPrefix.contains(addr)) { 166 meshLocalAddresses.add(addr); 167 } 168 } 169 return meshLocalAddresses; 170 } 171 172 /** 173 * Joins the Thread network using the given {@link ActiveOperationalDataset}. 174 * 175 * @param dataset the Active Operational Dataset 176 */ joinNetwork(ActiveOperationalDataset dataset)177 public void joinNetwork(ActiveOperationalDataset dataset) { 178 mActiveOperationalDataset = dataset; 179 executeCommand("dataset set active " + base16().lowerCase().encode(dataset.toThreadTlvs())); 180 executeCommand("ifconfig up"); 181 executeCommand("thread start"); 182 } 183 184 /** Stops the Thread network radio. */ stopThreadRadio()185 public void stopThreadRadio() { 186 executeCommand("thread stop"); 187 executeCommand("ifconfig down"); 188 } 189 190 /** 191 * Waits for the Thread device to enter the any state of the given {@link List<String>}. 192 * 193 * @param states the list of states to wait for. Valid states are "disabled", "detached", 194 * "child", "router" and "leader". 195 * @param timeout the time to wait for the expected state before throwing 196 */ waitForStateAnyOf(List<String> states, Duration timeout)197 public void waitForStateAnyOf(List<String> states, Duration timeout) throws TimeoutException { 198 waitFor(() -> states.contains(getState()), timeout); 199 } 200 201 /** 202 * Gets the state of the Thread device. 203 * 204 * @return a string representing the state. 205 */ getState()206 public String getState() { 207 return executeCommand("state").get(0); 208 } 209 210 /** Closes the UDP socket. */ udpClose()211 public void udpClose() { 212 executeCommand("udp close"); 213 } 214 215 /** Opens the UDP socket. */ udpOpen()216 public void udpOpen() { 217 executeCommand("udp open"); 218 } 219 220 /** Opens the UDP socket and binds it to a specific address and port. */ udpBind(Inet6Address address, int port)221 public void udpBind(Inet6Address address, int port) { 222 udpClose(); 223 udpOpen(); 224 executeCommand("udp bind %s %d", address.getHostAddress(), port); 225 } 226 227 /** Returns the message received on the UDP socket. */ udpReceive()228 public String udpReceive() throws IOException { 229 Pattern pattern = 230 Pattern.compile("> (\\d+) bytes from ([\\da-f:]+) (\\d+) ([\\x00-\\x7F]+)"); 231 Matcher matcher = pattern.matcher(readLine()); 232 matcher.matches(); 233 234 return matcher.group(4); 235 } 236 237 /** Sends a UDP message to given IP address and port. */ udpSend(String message, InetAddress serverAddr, int serverPort)238 public void udpSend(String message, InetAddress serverAddr, int serverPort) { 239 executeCommand("udp send %s %d %s", serverAddr.getHostAddress(), serverPort, message); 240 } 241 242 /** Enables the SRP client and run in autostart mode. */ autoStartSrpClient()243 public void autoStartSrpClient() { 244 executeCommand("srp client autostart enable"); 245 } 246 247 /** Sets the hostname (e.g. "MyHost") for the SRP client. */ setSrpHostname(String hostname)248 public void setSrpHostname(String hostname) { 249 executeCommand("srp client host name " + hostname); 250 } 251 252 /** Sets the host addresses for the SRP client. */ setSrpHostAddresses(List<Inet6Address> addresses)253 public void setSrpHostAddresses(List<Inet6Address> addresses) { 254 executeCommand( 255 "srp client host address " 256 + String.join( 257 " ", 258 addresses.stream().map(Inet6Address::getHostAddress).toList())); 259 } 260 261 /** Removes the SRP host */ removeSrpHost()262 public void removeSrpHost() { 263 executeCommand("srp client host remove 1 1"); 264 } 265 266 /** 267 * Adds an SRP service for the SRP client and wait for the registration to complete. 268 * 269 * @param serviceName the service name like "MyService" 270 * @param serviceType the service type like "_test._tcp" 271 * @param subtypes the service subtypes like "_sub1" 272 * @param port the port number in range [1, 65535] 273 * @param txtMap the map of TXT names and values 274 * @throws TimeoutException if the service isn't registered within timeout 275 */ addSrpService( String serviceName, String serviceType, List<String> subtypes, int port, Map<String, byte[]> txtMap)276 public void addSrpService( 277 String serviceName, 278 String serviceType, 279 List<String> subtypes, 280 int port, 281 Map<String, byte[]> txtMap) 282 throws TimeoutException { 283 StringBuilder fullServiceType = new StringBuilder(serviceType); 284 for (String subtype : subtypes) { 285 fullServiceType.append(",").append(subtype); 286 } 287 waitForSrpServer(); 288 executeCommand( 289 "srp client service add %s %s %d %d %d %s", 290 serviceName, 291 fullServiceType, 292 port, 293 0 /* priority */, 294 0 /* weight */, 295 txtMapToHexString(txtMap)); 296 waitFor(() -> isSrpServiceRegistered(serviceName, serviceType), SERVICE_DISCOVERY_TIMEOUT); 297 } 298 299 /** 300 * Removes an SRP service for the SRP client. 301 * 302 * @param serviceName the service name like "MyService" 303 * @param serviceType the service type like "_test._tcp" 304 * @param notifyServer whether to notify SRP server about the removal 305 */ removeSrpService(String serviceName, String serviceType, boolean notifyServer)306 public void removeSrpService(String serviceName, String serviceType, boolean notifyServer) { 307 String verb = notifyServer ? "remove" : "clear"; 308 executeCommand("srp client service %s %s %s", verb, serviceName, serviceType); 309 } 310 311 /** 312 * Updates an existing SRP service for the SRP client. 313 * 314 * <p>This is essentially a 'remove' and an 'add' on the SRP client's side. 315 * 316 * @param serviceName the service name like "MyService" 317 * @param serviceType the service type like "_test._tcp" 318 * @param subtypes the service subtypes like "_sub1" 319 * @param port the port number in range [1, 65535] 320 * @param txtMap the map of TXT names and values 321 * @throws TimeoutException if the service isn't updated within timeout 322 */ updateSrpService( String serviceName, String serviceType, List<String> subtypes, int port, Map<String, byte[]> txtMap)323 public void updateSrpService( 324 String serviceName, 325 String serviceType, 326 List<String> subtypes, 327 int port, 328 Map<String, byte[]> txtMap) 329 throws TimeoutException { 330 removeSrpService(serviceName, serviceType, false /* notifyServer */); 331 addSrpService(serviceName, serviceType, subtypes, port, txtMap); 332 } 333 334 /** Checks if an SRP service is registered. */ isSrpServiceRegistered(String serviceName, String serviceType)335 public boolean isSrpServiceRegistered(String serviceName, String serviceType) { 336 List<String> lines = executeCommand("srp client service"); 337 for (String line : lines) { 338 if (line.contains(serviceName) && line.contains(serviceType)) { 339 return line.contains("Registered"); 340 } 341 } 342 return false; 343 } 344 345 /** Checks if an SRP host is registered. */ isSrpHostRegistered()346 public boolean isSrpHostRegistered() { 347 List<String> lines = executeCommand("srp client host"); 348 for (String line : lines) { 349 return line.contains("Registered"); 350 } 351 return false; 352 } 353 354 /** Sets the DNS server address. */ setDnsServerAddress(String address)355 public void setDnsServerAddress(String address) { 356 executeCommand("dns config " + address); 357 } 358 359 /** Resolves the {@code queryType} record of the {@code hostname} via DNS. */ resolveHost(String hostname, int queryType)360 public List<InetAddress> resolveHost(String hostname, int queryType) { 361 // CLI output: 362 // DNS response for hostname.com. - fd12::abc1 TTL:50 fd12::abc2 TTL:50 fd12::abc3 TTL:50 363 364 String command; 365 switch (queryType) { 366 case TYPE_A -> command = "resolve4"; 367 case TYPE_AAAA -> command = "resolve"; 368 default -> throw new IllegalArgumentException("Invalid query type: " + queryType); 369 } 370 final List<InetAddress> addresses = new ArrayList<>(); 371 String line; 372 try { 373 line = executeCommand("dns " + command + " " + hostname).get(0); 374 } catch (IllegalStateException e) { 375 return addresses; 376 } 377 final String[] addressTtlPairs = line.split("-")[1].strip().split(" "); 378 for (int i = 0; i < addressTtlPairs.length; i += 2) { 379 addresses.add(InetAddresses.parseNumericAddress(addressTtlPairs[i])); 380 } 381 return addresses; 382 } 383 384 /** Returns the first browsed service instance of {@code serviceType}. */ browseService(String serviceType)385 public NsdServiceInfo browseService(String serviceType) { 386 // CLI output: 387 // DNS browse response for _testservice._tcp.default.service.arpa. 388 // test-service 389 // Port:12345, Priority:0, Weight:0, TTL:10 390 // Host:testhost.default.service.arpa. 391 // HostAddress:2001:0:0:0:0:0:0:1 TTL:10 392 // TXT:[key1=0102, key2=03] TTL:10 393 394 List<String> lines = executeCommand("dns browse " + serviceType); 395 NsdServiceInfo info = new NsdServiceInfo(); 396 info.setServiceName(lines.get(1)); 397 info.setServiceType(serviceType); 398 info.setPort(DnsServiceCliOutputParser.parsePort(lines.get(2))); 399 info.setHostname(DnsServiceCliOutputParser.parseHostname(lines.get(3))); 400 info.setHostAddresses(List.of(DnsServiceCliOutputParser.parseHostAddress(lines.get(4)))); 401 DnsServiceCliOutputParser.parseTxtIntoServiceInfo(lines.get(5), info); 402 403 return info; 404 } 405 406 /** Returns the resolved service instance. */ resolveService(String serviceName, String serviceType)407 public NsdServiceInfo resolveService(String serviceName, String serviceType) { 408 // CLI output: 409 // DNS service resolution response for test-service for service 410 // _test._tcp.default.service.arpa. 411 // Port:12345, Priority:0, Weight:0, TTL:10 412 // Host:Android.default.service.arpa. 413 // HostAddress:2001:0:0:0:0:0:0:1 TTL:10 414 // TXT:[key1=0102, key2=03] TTL:10 415 416 List<String> lines = executeCommand("dns service %s %s", serviceName, serviceType); 417 NsdServiceInfo info = new NsdServiceInfo(); 418 info.setServiceName(serviceName); 419 info.setServiceType(serviceType); 420 info.setPort(DnsServiceCliOutputParser.parsePort(lines.get(1))); 421 info.setHostname(DnsServiceCliOutputParser.parseHostname(lines.get(2))); 422 info.setHostAddresses(List.of(DnsServiceCliOutputParser.parseHostAddress(lines.get(3)))); 423 DnsServiceCliOutputParser.parseTxtIntoServiceInfo(lines.get(4), info); 424 425 return info; 426 } 427 428 /** Runs the "factoryreset" command on the device. */ factoryReset()429 public void factoryReset() { 430 try { 431 mWriter.write("factoryreset\n"); 432 mWriter.flush(); 433 // fill the input buffer to avoid truncating next command 434 for (int i = 0; i < 1000; ++i) { 435 mWriter.write("\n"); 436 } 437 mWriter.flush(); 438 } catch (IOException e) { 439 throw new IllegalStateException("Failed to run factoryreset on ot-cli-ftd", e); 440 } 441 } 442 subscribeMulticastAddress(Inet6Address address)443 public void subscribeMulticastAddress(Inet6Address address) { 444 executeCommand("ipmaddr add " + address.getHostAddress()); 445 } 446 ping(InetAddress address, Inet6Address source)447 public void ping(InetAddress address, Inet6Address source) { 448 ping( 449 address, 450 source, 451 PING_SIZE, 452 1 /* count */, 453 PING_INTERVAL, 454 HOP_LIMIT, 455 PING_TIMEOUT_0_1_SECOND); 456 } 457 ping(InetAddress address)458 public void ping(InetAddress address) { 459 ping( 460 address, 461 null, 462 PING_SIZE, 463 1 /* count */, 464 PING_INTERVAL, 465 HOP_LIMIT, 466 PING_TIMEOUT_0_1_SECOND); 467 } 468 469 /** Returns the number of ping reply packets received. */ ping(InetAddress address, int count)470 public int ping(InetAddress address, int count) { 471 List<String> output = 472 ping( 473 address, 474 null, 475 PING_SIZE, 476 count, 477 PING_INTERVAL, 478 HOP_LIMIT, 479 PING_TIMEOUT_1_SECOND); 480 return getReceivedPacketsCount(output); 481 } 482 ping( InetAddress address, Inet6Address source, int size, int count, int interval, int hopLimit, float timeout)483 private List<String> ping( 484 InetAddress address, 485 Inet6Address source, 486 int size, 487 int count, 488 int interval, 489 int hopLimit, 490 float timeout) { 491 String cmd = 492 "ping" 493 + ((source == null) ? "" : (" -I " + source.getHostAddress())) 494 + " " 495 + address.getHostAddress() 496 + " " 497 + size 498 + " " 499 + count 500 + " " 501 + interval 502 + " " 503 + hopLimit 504 + " " 505 + timeout; 506 return executeCommand(cmd); 507 } 508 getReceivedPacketsCount(List<String> stringList)509 private int getReceivedPacketsCount(List<String> stringList) { 510 Pattern pattern = Pattern.compile("([\\d]+) packets received"); 511 512 for (String message : stringList) { 513 Matcher matcher = pattern.matcher(message); 514 if (matcher.find()) { 515 String packetCountStr = matcher.group(1); 516 return Integer.parseInt(packetCountStr); 517 } 518 } 519 // No match found 520 return -1; 521 } 522 523 /** Waits for an SRP server to be present in Network Data */ waitForSrpServer()524 private void waitForSrpServer() throws TimeoutException { 525 // CLI output: 526 // > srp client server 527 // [fd64:db12:25f4:7e0b:1bfc:6344:25ac:2dd7]:53538 528 // Done 529 waitFor( 530 () -> { 531 final String serverAddr = executeCommand("srp client server").get(0); 532 final int lastColonIndex = serverAddr.lastIndexOf(':'); 533 final int port = Integer.parseInt(serverAddr.substring(lastColonIndex + 1)); 534 return port > 0; 535 }, 536 SERVICE_DISCOVERY_TIMEOUT); 537 } 538 539 @FormatMethod executeCommand(String commandFormat, Object... args)540 private List<String> executeCommand(String commandFormat, Object... args) { 541 return executeCommand(String.format(commandFormat, args)); 542 } 543 executeCommand(String command)544 private List<String> executeCommand(String command) { 545 try { 546 mWriter.write(command + "\n"); 547 mWriter.flush(); 548 } catch (IOException e) { 549 throw new IllegalStateException( 550 "Failed to write the command " + command + " to ot-cli-ftd", e); 551 } 552 try { 553 return readUntilDone(); 554 } catch (IOException e) { 555 throw new IllegalStateException( 556 "Failed to read the ot-cli-ftd output of command: " + command, e); 557 } 558 } 559 readLine()560 private String readLine() throws IOException { 561 final CompletableFuture<String> future = new CompletableFuture<>(); 562 mReaderHandler.post( 563 () -> { 564 try { 565 future.complete(mReader.readLine()); 566 } catch (IOException e) { 567 future.completeExceptionally(e); 568 } 569 }); 570 try { 571 return future.get(READ_LINE_TIMEOUT_SECONDS, SECONDS); 572 } catch (InterruptedException | ExecutionException | TimeoutException e) { 573 throw new IOException("Failed to read a line from ot-cli-ftd"); 574 } 575 } 576 readUntilDone()577 private List<String> readUntilDone() throws IOException { 578 ArrayList<String> result = new ArrayList<>(); 579 String line; 580 while ((line = readLine()) != null) { 581 if (line.equals("Done")) { 582 break; 583 } 584 if (line.startsWith("Error")) { 585 throw new IOException("ot-cli-ftd reported an error: " + line); 586 } 587 if (!line.startsWith("> ")) { 588 result.add(line); 589 } 590 } 591 return result; 592 } 593 txtMapToHexString(Map<String, byte[]> txtMap)594 private static String txtMapToHexString(Map<String, byte[]> txtMap) { 595 if (txtMap == null) { 596 return ""; 597 } 598 StringBuilder sb = new StringBuilder(); 599 for (Map.Entry<String, byte[]> entry : txtMap.entrySet()) { 600 int length = entry.getKey().length() + entry.getValue().length + 1; 601 sb.append(String.format("%02x", length)); 602 sb.append(toHexString(entry.getKey())); 603 sb.append(toHexString("=")); 604 sb.append(toHexString(entry.getValue())); 605 } 606 return sb.toString(); 607 } 608 toHexString(String s)609 private static String toHexString(String s) { 610 return toHexString(s.getBytes(StandardCharsets.UTF_8)); 611 } 612 toHexString(byte[] bytes)613 private static String toHexString(byte[] bytes) { 614 return base16().encode(bytes); 615 } 616 617 private static final class DnsServiceCliOutputParser { 618 /** Returns the first match in the input of a given regex pattern. */ firstMatchOf(String input, String regex)619 private static Matcher firstMatchOf(String input, String regex) { 620 Matcher matcher = Pattern.compile(regex).matcher(input); 621 matcher.find(); 622 return matcher; 623 } 624 625 // Example: "Port:12345" parsePort(String line)626 private static int parsePort(String line) { 627 return Integer.parseInt(firstMatchOf(line, "Port:(\\d+)").group(1)); 628 } 629 630 // Example: "Host:Android.default.service.arpa." parseHostname(String line)631 private static String parseHostname(String line) { 632 return firstMatchOf(line, "Host:(.+)").group(1); 633 } 634 635 // Example: "HostAddress:2001:0:0:0:0:0:0:1" parseHostAddress(String line)636 private static InetAddress parseHostAddress(String line) { 637 return InetAddresses.parseNumericAddress( 638 firstMatchOf(line, "HostAddress:([^ ]+)").group(1)); 639 } 640 641 // Example: "TXT:[key1=0102, key2=03]" parseTxtIntoServiceInfo(String line, NsdServiceInfo serviceInfo)642 private static void parseTxtIntoServiceInfo(String line, NsdServiceInfo serviceInfo) { 643 String txtString = firstMatchOf(line, "TXT:\\[([^\\]]+)\\]").group(1); 644 for (String txtEntry : txtString.split(",")) { 645 String[] nameAndValue = txtEntry.trim().split("="); 646 String name = nameAndValue[0]; 647 String value = nameAndValue[1]; 648 byte[] bytes = new byte[value.length() / 2]; 649 for (int i = 0; i < value.length(); i += 2) { 650 byte b = (byte) ((value.charAt(i) - '0') << 4 | (value.charAt(i + 1) - '0')); 651 bytes[i / 2] = b; 652 } 653 serviceInfo.setAttribute(name, bytes); 654 } 655 } 656 } 657 } 658