[Feature sync] fix handling arbitrary bytes in TXT value

RFC 6763 defines that TXT value can accept both utf-8 string and
binary data. Current implementation will always cast the TXT
value to a utf-8 string and will cause data lose when there are
non-utf-8 chars in the TXT value. This commit fixes this by
having the browser passing the TXT values back as byte[].

Also fixed the TXT key&value parsing issues per RFC 6763
section 6.5: accept cases of no '=' and reject empty key.

Bug: 254155029
Test: atest FrameworksNetTests CtsNetTestCases
Change-Id: I4b755e60ad6e59db19faa41556dd214993d73896
diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsPacketReader.java b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketReader.java
index 61c5f5a..856a2cd 100644
--- a/service/mdns/com/android/server/connectivity/mdns/MdnsPacketReader.java
+++ b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketReader.java
@@ -16,8 +16,11 @@
 
 package com.android.server.connectivity.mdns;
 
+import android.annotation.Nullable;
 import android.util.SparseArray;
 
+import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
+
 import java.io.EOFException;
 import java.io.IOException;
 import java.net.DatagramPacket;
@@ -195,6 +198,16 @@
         return val;
     }
 
+    @Nullable
+    public TextEntry readTextEntry() throws EOFException {
+        int len = readUInt8();
+        checkRemaining(len);
+        byte[] bytes = new byte[len];
+        System.arraycopy(buf, pos, bytes, 0, bytes.length);
+        pos += len;
+        return TextEntry.fromBytes(bytes);
+    }
+
     /**
      * Reads a specific number of bytes.
      *
diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsPacketWriter.java b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketWriter.java
index 2fed36d..b78aa5d 100644
--- a/service/mdns/com/android/server/connectivity/mdns/MdnsPacketWriter.java
+++ b/service/mdns/com/android/server/connectivity/mdns/MdnsPacketWriter.java
@@ -16,6 +16,8 @@
 
 package com.android.server.connectivity.mdns;
 
+import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
+
 import java.io.IOException;
 import java.net.DatagramPacket;
 import java.net.SocketAddress;
@@ -147,6 +149,12 @@
         writeBytes(utf8);
     }
 
+    public void writeTextEntry(TextEntry textEntry) throws IOException {
+        byte[] bytes = textEntry.toBytes();
+        writeUInt8(bytes.length);
+        writeBytes(bytes);
+    }
+
     /**
      * Writes a series of labels. Uses name compression.
      *
diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceInfo.java b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceInfo.java
index 2e4a4e5..d142280 100644
--- a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceInfo.java
+++ b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceInfo.java
@@ -17,11 +17,16 @@
 package com.android.server.connectivity.mdns;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.text.TextUtils;
 
+import com.android.net.module.util.ByteUtils;
+
+import java.nio.charset.Charset;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -34,6 +39,8 @@
  * @hide
  */
 public class MdnsServiceInfo implements Parcelable {
+    private static final Charset US_ASCII = Charset.forName("us-ascii");
+    private static final Charset UTF_8 = Charset.forName("utf-8");
 
     /** @hide */
     public static final Parcelable.Creator<MdnsServiceInfo> CREATOR =
@@ -49,7 +56,8 @@
                             source.readInt(),
                             source.readString(),
                             source.readString(),
-                            source.createStringArrayList());
+                            source.createStringArrayList(),
+                            source.createTypedArrayList(TextEntry.CREATOR));
                 }
 
                 @Override
@@ -65,8 +73,33 @@
     private final int port;
     private final String ipv4Address;
     private final String ipv6Address;
-    private final Map<String, String> attributes = new HashMap<>();
-    List<String> textStrings;
+    final List<String> textStrings;
+    @Nullable
+    final List<TextEntry> textEntries;
+
+    private final Map<String, byte[]> attributes;
+
+    /** Constructs a {@link MdnsServiceInfo} object with default values. */
+    public MdnsServiceInfo(
+            String serviceInstanceName,
+            String[] serviceType,
+            List<String> subtypes,
+            String[] hostName,
+            int port,
+            String ipv4Address,
+            String ipv6Address,
+            List<String> textStrings) {
+        this(
+                serviceInstanceName,
+                serviceType,
+                subtypes,
+                hostName,
+                port,
+                ipv4Address,
+                ipv6Address,
+                textStrings,
+                /* textEntries= */ null);
+    }
 
     /**
      * Constructs a {@link MdnsServiceInfo} object with default values.
@@ -81,7 +114,8 @@
             int port,
             String ipv4Address,
             String ipv6Address,
-            List<String> textStrings) {
+            List<String> textStrings,
+            @Nullable List<TextEntry> textEntries) {
         this.serviceInstanceName = serviceInstanceName;
         this.serviceType = serviceType;
         this.subtypes = new ArrayList<>();
@@ -92,16 +126,40 @@
         this.port = port;
         this.ipv4Address = ipv4Address;
         this.ipv6Address = ipv6Address;
+        this.textStrings = new ArrayList<>();
         if (textStrings != null) {
-            for (String text : textStrings) {
-                int pos = text.indexOf('=');
-                if (pos < 1) {
-                    continue;
-                }
-                attributes.put(text.substring(0, pos).toLowerCase(Locale.ENGLISH),
-                        text.substring(++pos));
+            this.textStrings.addAll(textStrings);
+        }
+        this.textEntries = (textEntries == null) ? null : new ArrayList<>(textEntries);
+
+        // The module side sends both {@code textStrings} and {@code textEntries} for backward
+        // compatibility. We should prefer only {@code textEntries} if it's not null.
+        List<TextEntry> entries =
+                (this.textEntries != null) ? this.textEntries : parseTextStrings(this.textStrings);
+        Map<String, byte[]> attributes = new HashMap<>(entries.size());
+        for (TextEntry entry : entries) {
+            String key = entry.getKey().toLowerCase(Locale.ENGLISH);
+
+            // Per https://datatracker.ietf.org/doc/html/rfc6763#section-6.4, only the first entry
+            // of the same key should be accepted:
+            // If a client receives a TXT record containing the same key more than once, then the
+            // client MUST silently ignore all but the first occurrence of that attribute.
+            if (!attributes.containsKey(key)) {
+                attributes.put(key, entry.getValue());
             }
         }
+        this.attributes = Collections.unmodifiableMap(attributes);
+    }
+
+    private static List<TextEntry> parseTextStrings(List<String> textStrings) {
+        List<TextEntry> list = new ArrayList(textStrings.size());
+        for (String textString : textStrings) {
+            TextEntry entry = TextEntry.fromString(textString);
+            if (entry != null) {
+                list.add(entry);
+            }
+        }
+        return Collections.unmodifiableList(list);
     }
 
     /** @return the name of this service instance. */
@@ -148,16 +206,35 @@
     }
 
     /**
-     * @return the attribute value for {@code key}.
-     * @return {@code null} if no attribute value exists for {@code key}.
+     * Returns attribute value for {@code key} as a UTF-8 string. It's the caller who must make sure
+     * that the value of {@code key} is indeed a UTF-8 string. {@code null} will be returned if no
+     * attribute value exists for {@code key}.
      */
+    @Nullable
     public String getAttributeByKey(@NonNull String key) {
+        byte[] value = getAttributeAsBytes(key);
+        if (value == null) {
+            return null;
+        }
+        return new String(value, UTF_8);
+    }
+
+    /**
+     * Returns the attribute value for {@code key} as a byte array. {@code null} will be returned if
+     * no attribute value exists for {@code key}.
+     */
+    @Nullable
+    public byte[] getAttributeAsBytes(@NonNull String key) {
         return attributes.get(key.toLowerCase(Locale.ENGLISH));
     }
 
     /** @return an immutable map of all attributes. */
     public Map<String, String> getAttributes() {
-        return Collections.unmodifiableMap(attributes);
+        Map<String, String> map = new HashMap<>(attributes.size());
+        for (Map.Entry<String, byte[]> kv : attributes.entrySet()) {
+            map.put(kv.getKey(), new String(kv.getValue(), UTF_8));
+        }
+        return Collections.unmodifiableMap(map);
     }
 
     @Override
@@ -167,14 +244,6 @@
 
     @Override
     public void writeToParcel(Parcel out, int flags) {
-        if (textStrings == null) {
-            // Lazily initialize the parcelable field mTextStrings.
-            textStrings = new ArrayList<>(attributes.size());
-            for (Map.Entry<String, String> kv : attributes.entrySet()) {
-                textStrings.add(String.format(Locale.ROOT, "%s=%s", kv.getKey(), kv.getValue()));
-            }
-        }
-
         out.writeString(serviceInstanceName);
         out.writeStringArray(serviceType);
         out.writeStringList(subtypes);
@@ -183,6 +252,7 @@
         out.writeString(ipv4Address);
         out.writeString(ipv6Address);
         out.writeStringList(textStrings);
+        out.writeTypedList(textEntries);
     }
 
     @Override
@@ -195,4 +265,114 @@
                 ipv4Address,
                 port);
     }
+
+
+    /** Represents a DNS TXT key-value pair defined by RFC 6763. */
+    public static final class TextEntry implements Parcelable {
+        public static final Parcelable.Creator<TextEntry> CREATOR =
+                new Parcelable.Creator<TextEntry>() {
+                    @Override
+                    public TextEntry createFromParcel(Parcel source) {
+                        return new TextEntry(source);
+                    }
+
+                    @Override
+                    public TextEntry[] newArray(int size) {
+                        return new TextEntry[size];
+                    }
+                };
+
+        private final String key;
+        private final byte[] value;
+
+        /** Creates a new {@link TextEntry} instance from a '=' separated string. */
+        @Nullable
+        public static TextEntry fromString(String textString) {
+            return fromBytes(textString.getBytes(UTF_8));
+        }
+
+        /** Creates a new {@link TextEntry} instance from a '=' separated byte array. */
+        @Nullable
+        public static TextEntry fromBytes(byte[] textBytes) {
+            int delimitPos = ByteUtils.indexOf(textBytes, (byte) '=');
+
+            // Per https://datatracker.ietf.org/doc/html/rfc6763#section-6.4:
+            // 1. The key MUST be at least one character.  DNS-SD TXT record strings
+            // beginning with an '=' character (i.e., the key is missing) MUST be
+            // silently ignored.
+            // 2. If there is no '=' in a DNS-SD TXT record string, then it is a
+            // boolean attribute, simply identified as being present, with no value.
+            if (delimitPos < 0) {
+                return new TextEntry(new String(textBytes, US_ASCII), "");
+            } else if (delimitPos == 0) {
+                return null;
+            }
+            return new TextEntry(
+                    new String(Arrays.copyOf(textBytes, delimitPos), US_ASCII),
+                    Arrays.copyOfRange(textBytes, delimitPos + 1, textBytes.length));
+        }
+
+        /** Creates a new {@link TextEntry} with given key and value of a UTF-8 string. */
+        public TextEntry(String key, String value) {
+            this(key, value.getBytes(UTF_8));
+        }
+
+        /** Creates a new {@link TextEntry} with given key and value of a byte array. */
+        public TextEntry(String key, byte[] value) {
+            this.key = key;
+            this.value = value.clone();
+        }
+
+        private TextEntry(Parcel in) {
+            key = in.readString();
+            value = in.createByteArray();
+        }
+
+        public String getKey() {
+            return key;
+        }
+
+        public byte[] getValue() {
+            return value.clone();
+        }
+
+        /** Converts this {@link TextEntry} instance to '=' separated byte array. */
+        public byte[] toBytes() {
+            return ByteUtils.concat(key.getBytes(US_ASCII), new byte[]{'='}, value);
+        }
+
+        /** Converts this {@link TextEntry} instance to '=' separated string. */
+        @Override
+        public String toString() {
+            return key + "=" + new String(value, UTF_8);
+        }
+
+        @Override
+        public boolean equals(@Nullable Object other) {
+            if (this == other) {
+                return true;
+            } else if (!(other instanceof TextEntry)) {
+                return false;
+            }
+            TextEntry otherEntry = (TextEntry) other;
+
+            return key.equals(otherEntry.key) && Arrays.equals(value, otherEntry.value);
+        }
+
+        @Override
+        public int hashCode() {
+            return 31 * key.hashCode() + Arrays.hashCode(value);
+        }
+
+        @Override
+        public int describeContents() {
+            return 0;
+        }
+
+        @Override
+        public void writeToParcel(Parcel out, int flags) {
+            out.writeString(key);
+            out.writeByteArray(value);
+        }
+    }
 }
\ No newline at end of file
diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 4fbc809..3747323 100644
--- a/service/mdns/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service/mdns/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -110,7 +110,8 @@
                 port,
                 ipv4Address,
                 ipv6Address,
-                response.getTextRecord().getStrings());
+                response.getTextRecord().getStrings(),
+                response.getTextRecord().getEntries());
     }
 
     /**
diff --git a/service/mdns/com/android/server/connectivity/mdns/MdnsTextRecord.java b/service/mdns/com/android/server/connectivity/mdns/MdnsTextRecord.java
index a364560..73ecdfa 100644
--- a/service/mdns/com/android/server/connectivity/mdns/MdnsTextRecord.java
+++ b/service/mdns/com/android/server/connectivity/mdns/MdnsTextRecord.java
@@ -17,6 +17,7 @@
 package com.android.server.connectivity.mdns;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -24,12 +25,12 @@
 import java.util.List;
 import java.util.Objects;
 
-/** An mDNS "TXT" record, which contains a list of text strings. */
+/** An mDNS "TXT" record, which contains a list of {@link TextEntry}. */
 // TODO(b/242631897): Resolve nullness suppression.
 @SuppressWarnings("nullness")
 @VisibleForTesting
 public class MdnsTextRecord extends MdnsRecord {
-    private List<String> strings;
+    private List<TextEntry> entries;
 
     public MdnsTextRecord(String[] name, MdnsPacketReader reader) throws IOException {
         super(name, TYPE_TXT, reader);
@@ -37,22 +38,34 @@
 
     /** Returns the list of strings. */
     public List<String> getStrings() {
-        return Collections.unmodifiableList(strings);
+        final List<String> list = new ArrayList<>(entries.size());
+        for (TextEntry entry : entries) {
+            list.add(entry.toString());
+        }
+        return Collections.unmodifiableList(list);
+    }
+
+    /** Returns the list of TXT key-value pairs. */
+    public List<TextEntry> getEntries() {
+        return Collections.unmodifiableList(entries);
     }
 
     @Override
     protected void readData(MdnsPacketReader reader) throws IOException {
-        strings = new ArrayList<>();
+        entries = new ArrayList<>();
         while (reader.getRemaining() > 0) {
-            strings.add(reader.readString());
+            TextEntry entry = reader.readTextEntry();
+            if (entry != null) {
+                entries.add(entry);
+            }
         }
     }
 
     @Override
     protected void writeData(MdnsPacketWriter writer) throws IOException {
-        if (strings != null) {
-            for (String string : strings) {
-                writer.writeString(string);
+        if (entries != null) {
+            for (TextEntry entry : entries) {
+                writer.writeTextEntry(entry);
             }
         }
     }
@@ -61,9 +74,9 @@
     public String toString() {
         StringBuilder sb = new StringBuilder();
         sb.append("TXT: {");
-        if (strings != null) {
-            for (String string : strings) {
-                sb.append(' ').append(string);
+        if (entries != null) {
+            for (TextEntry entry : entries) {
+                sb.append(' ').append(entry);
             }
         }
         sb.append("}");
@@ -73,7 +86,7 @@
 
     @Override
     public int hashCode() {
-        return (super.hashCode() * 31) + Objects.hash(strings);
+        return (super.hashCode() * 31) + Objects.hash(entries);
     }
 
     @Override
@@ -85,6 +98,6 @@
             return false;
         }
 
-        return super.equals(other) && Objects.equals(strings, ((MdnsTextRecord) other).strings);
+        return super.equals(other) && Objects.equals(entries, ((MdnsTextRecord) other).entries);
     }
 }
\ No newline at end of file
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordTests.java
index fdb4d4a..9fc4674 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordTests.java
@@ -22,16 +22,19 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
 
 import android.util.Log;
 
 import com.android.net.module.util.HexDump;
+import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.io.EOFException;
 import java.io.IOException;
 import java.net.DatagramPacket;
 import java.net.Inet4Address;
@@ -309,6 +312,14 @@
         assertEquals("b=1234567890", strings.get(1));
         assertEquals("xyz=!@#$", strings.get(2));
 
+        List<TextEntry> entries = record.getEntries();
+        assertNotNull(entries);
+        assertEquals(3, entries.size());
+
+        assertEquals(new TextEntry("a", "hello there"), entries.get(0));
+        assertEquals(new TextEntry("b", "1234567890"), entries.get(1));
+        assertEquals(new TextEntry("xyz", "!@#$"), entries.get(2));
+
         // Encode
         MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE);
         record.write(writer, record.getReceiptTime());
@@ -321,4 +332,48 @@
 
         assertEquals(dataInText, dataOutText);
     }
+
+    @Test
+    public void textRecord_recordDoesNotHaveDataOfGivenLength_throwsEOFException()
+            throws Exception {
+        final byte[] dataIn = HexDump.hexStringToByteArray(
+                "0474657374000010"
+                        + "000100001194000D"
+                        + "0D613D68656C6C6F" //The TXT entry starts with length of 13, but only 12
+                        + "2074686572"); // characters are following it.
+        DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length);
+        MdnsPacketReader reader = new MdnsPacketReader(packet);
+        String[] name = reader.readLabels();
+        MdnsRecord.labelsToString(name);
+        reader.readUInt16();
+
+        assertThrows(EOFException.class, () -> new MdnsTextRecord(name, reader));
+    }
+
+    @Test
+    public void textRecord_entriesIncludeNonUtf8Bytes_returnsTheSameUtf8Bytes() throws Exception {
+        final byte[] dataIn = HexDump.hexStringToByteArray(
+                "0474657374000010"
+                        + "0001000011940024"
+                        + "0D613D68656C6C6F"
+                        + "2074686572650C62"
+                        + "3D31323334353637"
+                        + "3839300878797A3D"
+                        + "FFEFDFCF");
+        DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length);
+        MdnsPacketReader reader = new MdnsPacketReader(packet);
+        String[] name = reader.readLabels();
+        MdnsRecord.labelsToString(name);
+        reader.readUInt16();
+
+        MdnsTextRecord record = new MdnsTextRecord(name, reader);
+
+        List<TextEntry> entries = record.getEntries();
+        assertNotNull(entries);
+        assertEquals(3, entries.size());
+        assertEquals(new TextEntry("a", "hello there"), entries.get(0));
+        assertEquals(new TextEntry("b", "1234567890"), entries.get(1));
+        assertEquals(new TextEntry("xyz", HexDump.hexStringToByteArray("FFEFDFCF")),
+                entries.get(2));
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
new file mode 100644
index 0000000..79d6046
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity.mdns;
+
+import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.os.Parcel;
+
+import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
+import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRunner;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+import java.util.Map;
+
+@RunWith(DevSdkIgnoreRunner.class)
+@DevSdkIgnoreRule.IgnoreUpTo(SC_V2)
+public class MdnsServiceInfoTest {
+    @Test
+    public void constructor_createWithOnlyTextStrings_correctAttributes() {
+        MdnsServiceInfo info =
+                new MdnsServiceInfo(
+                        "my-mdns-service",
+                        new String[] {"_googlecast", "_tcp"},
+                        List.of(),
+                        new String[] {"my-host", "local"},
+                        12345,
+                        "192.168.1.1",
+                        "2001::1",
+                        List.of("vn=Google Inc.", "mn=Google Nest Hub Max"),
+                        /* textEntries= */ null);
+
+        assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
+        assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
+    }
+
+    @Test
+    public void constructor_createWithOnlyTextEntries_correctAttributes() {
+        MdnsServiceInfo info =
+                new MdnsServiceInfo(
+                        "my-mdns-service",
+                        new String[] {"_googlecast", "_tcp"},
+                        List.of(),
+                        new String[] {"my-host", "local"},
+                        12345,
+                        "192.168.1.1",
+                        "2001::1",
+                        /* textStrings= */ null,
+                        List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
+
+        assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
+        assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
+    }
+
+    @Test
+    public void constructor_createWithBothTextStringsAndTextEntries_acceptsOnlyTextEntries() {
+        MdnsServiceInfo info =
+                new MdnsServiceInfo(
+                        "my-mdns-service",
+                        new String[] {"_googlecast", "_tcp"},
+                        List.of(),
+                        new String[] {"my-host", "local"},
+                        12345,
+                        "192.168.1.1",
+                        "2001::1",
+                        List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
+                        List.of(
+                                MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
+
+        assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
+                info.getAttributes());
+    }
+
+    @Test
+    public void constructor_createWithDuplicateKeys_acceptsTheFirstOne() {
+        MdnsServiceInfo info =
+                new MdnsServiceInfo(
+                        "my-mdns-service",
+                        new String[] {"_googlecast", "_tcp"},
+                        List.of(),
+                        new String[] {"my-host", "local"},
+                        12345,
+                        "192.168.1.1",
+                        "2001::1",
+                        List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
+                        List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max"),
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google WiFi Router")));
+
+        assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
+                info.getAttributes());
+    }
+
+    @Test
+    public void parcelable_canBeParceledAndUnparceled() {
+        Parcel parcel = Parcel.obtain();
+        MdnsServiceInfo beforeParcel =
+                new MdnsServiceInfo(
+                        "my-mdns-service",
+                        new String[] {"_googlecast", "_tcp"},
+                        List.of(),
+                        new String[] {"my-host", "local"},
+                        12345,
+                        "192.168.1.1",
+                        "2001::1",
+                        List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
+                        List.of(
+                                MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
+
+        beforeParcel.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        MdnsServiceInfo afterParcel = MdnsServiceInfo.CREATOR.createFromParcel(parcel);
+
+        assertEquals(beforeParcel.getServiceInstanceName(), afterParcel.getServiceInstanceName());
+        assertArrayEquals(beforeParcel.getServiceType(), afterParcel.getServiceType());
+        assertEquals(beforeParcel.getSubtypes(), afterParcel.getSubtypes());
+        assertArrayEquals(beforeParcel.getHostName(), afterParcel.getHostName());
+        assertEquals(beforeParcel.getPort(), afterParcel.getPort());
+        assertEquals(beforeParcel.getIpv4Address(), afterParcel.getIpv4Address());
+        assertEquals(beforeParcel.getIpv6Address(), afterParcel.getIpv6Address());
+        assertEquals(beforeParcel.getAttributes(), afterParcel.getAttributes());
+    }
+
+    @Test
+    public void textEntry_parcelable_canBeParceledAndUnparceled() {
+        Parcel parcel = Parcel.obtain();
+        TextEntry beforeParcel = new TextEntry("AA", new byte[] {(byte) 0xFF, (byte) 0xFC});
+
+        beforeParcel.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        TextEntry afterParcel = TextEntry.CREATOR.createFromParcel(parcel);
+
+        assertEquals(beforeParcel, afterParcel);
+    }
+
+    @Test
+    public void textEntry_fromString_keyValueAreExpected() {
+        TextEntry entry = TextEntry.fromString("AA=xxyyzz");
+
+        assertEquals("AA", entry.getKey());
+        assertArrayEquals(new byte[] {'x', 'x', 'y', 'y', 'z', 'z'}, entry.getValue());
+    }
+
+    @Test
+    public void textEntry_fromStringToString_textUnchanged() {
+        TextEntry entry = TextEntry.fromString("AA=xxyyzz");
+
+        assertEquals("AA=xxyyzz", entry.toString());
+    }
+
+    @Test
+    public void textEntry_fromStringWithoutAssignPunc_valueisEmpty() {
+        TextEntry entry = TextEntry.fromString("AA");
+
+        assertEquals("AA", entry.getKey());
+        assertArrayEquals(new byte[] {}, entry.getValue());
+    }
+
+    @Test
+    public void textEntry_fromStringAssignPuncAtBeginning_returnsNull() {
+        TextEntry entry = TextEntry.fromString("=AA");
+
+        assertNull(entry);
+    }
+
+    @Test
+    public void textEntry_fromBytes_keyAndValueAreExpected() {
+        TextEntry entry = TextEntry.fromBytes(
+                new byte[] {'A', 'A', '=', 'x', 'x', 'y', 'y', 'z', 'z'});
+
+        assertEquals("AA", entry.getKey());
+        assertArrayEquals(new byte[] {'x', 'x', 'y', 'y', 'z', 'z'}, entry.getValue());
+    }
+
+    @Test
+    public void textEntry_fromBytesToBytes_textUnchanged() {
+        TextEntry entry = TextEntry.fromBytes(
+                new byte[] {'A', 'A', '=', 'x', 'x', 'y', 'y', 'z', 'z'});
+
+        assertArrayEquals(new byte[] {'A', 'A', '=', 'x', 'x', 'y', 'y', 'z', 'z'},
+                entry.toBytes());
+    }
+
+    @Test
+    public void textEntry_fromBytesWithoutAssignPunc_valueisEmpty() {
+        TextEntry entry = TextEntry.fromBytes(new byte[] {'A', 'A'});
+
+        assertEquals("AA", entry.getKey());
+        assertArrayEquals(new byte[] {}, entry.getValue());
+    }
+
+    @Test
+    public void textEntry_fromBytesAssignPuncAtBeginning_returnsNull() {
+        TextEntry entry = TextEntry.fromBytes(new byte[] {'=', 'A', 'A'});
+
+        assertNull(entry);
+    }
+
+    @Test
+    public void textEntry_fromNonUtf8Bytes_keyValueAreExpected() {
+        TextEntry entry = TextEntry.fromBytes(
+                new byte[] {'A', 'A', '=', (byte) 0xFF, (byte) 0xFE, (byte) 0xFD});
+
+        assertEquals("AA", entry.getKey());
+        assertArrayEquals(new byte[] {(byte) 0xFF, (byte) 0xFE, (byte) 0xFD}, entry.getValue());
+    }
+
+    @Test
+    public void textEntry_equals() {
+        assertEquals(new TextEntry("AA", "xxyyzz"), new TextEntry("AA", "xxyyzz"));
+        assertEquals(new TextEntry("BB", "xxyyzz"), new TextEntry("BB", "xxyyzz"));
+        assertEquals(new TextEntry("AA", "XXYYZZ"), new TextEntry("AA", "XXYYZZ"));
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
index 5843fd0..c84c386 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -32,8 +32,11 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import android.annotation.NonNull;
 
+import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
 import com.android.server.connectivity.mdns.MdnsServiceTypeClient.QueryTaskConfig;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
@@ -53,7 +56,6 @@
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.SocketAddress;
-import java.net.UnknownHostException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -495,7 +497,7 @@
     }
 
     @Test
-    public void reportExistingServiceToNewlyRegisteredListeners() throws UnknownHostException {
+    public void reportExistingServiceToNewlyRegisteredListeners() throws Exception {
         // Process the initial response.
         MdnsResponse initialResponse =
                 createResponse(
@@ -732,7 +734,7 @@
             int port,
             @NonNull List<String> subtypes,
             @NonNull Map<String, String> textAttributes)
-            throws UnknownHostException {
+            throws Exception {
         String[] hostName = new String[]{"hostname"};
         MdnsServiceRecord serviceRecord = mock(MdnsServiceRecord.class);
         when(serviceRecord.getServiceHost()).thenReturn(hostName);
@@ -753,10 +755,13 @@
 
         MdnsTextRecord textRecord = mock(MdnsTextRecord.class);
         List<String> textStrings = new ArrayList<>();
+        List<TextEntry> textEntries = new ArrayList<>();
         for (Map.Entry<String, String> kv : textAttributes.entrySet()) {
             textStrings.add(kv.getKey() + "=" + kv.getValue());
+            textEntries.add(new TextEntry(kv.getKey(), kv.getValue().getBytes(UTF_8)));
         }
         when(textRecord.getStrings()).thenReturn(textStrings);
+        when(textRecord.getEntries()).thenReturn(textEntries);
 
         response.setServiceRecord(serviceRecord);
         response.setTextRecord(textRecord);