[mdns] add API for setting/getting TTL value
To support Thread Border Router use case, an Android device needs to act
as a mDNS service discovery (RFC 8766) & advertising proxy (
https://datatracker.ietf.org/doc/draft-ietf-dnssd-advertising-proxy).
This means it needs to advertise and discover DNS-SD services of custom
TTL value on behalf of other devices.
This commit fulfills this requirement by adding TTL getter/setter to
NsdServiceInfo. When registering a service, the specified TTL will be
used for corresponding resource records.
Bug: 284903641
Test: atest CtsNetTestCases FrameworksNetTests
Change-Id: I23837834847dc3a0fb452c5929a1b36e3fc1fb3f
diff --git a/framework-t/src/android/net/nsd/AdvertisingRequest.java b/framework-t/src/android/net/nsd/AdvertisingRequest.java
index b1ef98f..d778654 100644
--- a/framework-t/src/android/net/nsd/AdvertisingRequest.java
+++ b/framework-t/src/android/net/nsd/AdvertisingRequest.java
@@ -15,13 +15,17 @@
*/
package android.net.nsd;
+import static java.util.Objects.requireNonNull;
+
import android.annotation.LongDef;
import android.annotation.NonNull;
+import android.annotation.Nullable;
import android.os.Parcel;
import android.os.Parcelable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.time.Duration;
import java.util.Objects;
/**
@@ -34,7 +38,7 @@
/**
* Only update the registration without sending exit and re-announcement.
*/
- public static final int NSD_ADVERTISING_UPDATE_ONLY = 1;
+ public static final long NSD_ADVERTISING_UPDATE_ONLY = 1;
@NonNull
@@ -46,7 +50,9 @@
NsdServiceInfo.class.getClassLoader(), NsdServiceInfo.class);
final int protocolType = in.readInt();
final long advertiseConfig = in.readLong();
- return new AdvertisingRequest(serviceInfo, protocolType, advertiseConfig);
+ final long ttlSeconds = in.readLong();
+ final Duration ttl = ttlSeconds < 0 ? null : Duration.ofSeconds(ttlSeconds);
+ return new AdvertisingRequest(serviceInfo, protocolType, advertiseConfig, ttl);
}
@Override
@@ -60,6 +66,9 @@
// Bitmask of @AdvertisingConfig flags. Uses a long to allow 64 possible flags in the future.
private final long mAdvertisingConfig;
+ @Nullable
+ private final Duration mTtl;
+
/**
* @hide
*/
@@ -73,10 +82,11 @@
* The constructor for the advertiseRequest
*/
private AdvertisingRequest(@NonNull NsdServiceInfo serviceInfo, int protocolType,
- long advertisingConfig) {
+ long advertisingConfig, @NonNull Duration ttl) {
mServiceInfo = serviceInfo;
mProtocolType = protocolType;
mAdvertisingConfig = advertisingConfig;
+ mTtl = ttl;
}
/**
@@ -101,12 +111,25 @@
return mAdvertisingConfig;
}
+ /**
+ * Returns the time interval that the resource records may be cached on a DNS resolver or
+ * {@code null} if not specified.
+ *
+ * @hide
+ */
+ // @FlaggedApi(NsdManager.Flags.NSD_CUSTOM_TTL_ENABLED)
+ @Nullable
+ public Duration getTtl() {
+ return mTtl;
+ }
+
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("serviceInfo: ").append(mServiceInfo)
.append(", protocolType: ").append(mProtocolType)
- .append(", advertisingConfig: ").append(mAdvertisingConfig);
+ .append(", advertisingConfig: ").append(mAdvertisingConfig)
+ .append(", ttl: ").append(mTtl);
return sb.toString();
}
@@ -120,13 +143,14 @@
final AdvertisingRequest otherRequest = (AdvertisingRequest) other;
return mServiceInfo.equals(otherRequest.mServiceInfo)
&& mProtocolType == otherRequest.mProtocolType
- && mAdvertisingConfig == otherRequest.mAdvertisingConfig;
+ && mAdvertisingConfig == otherRequest.mAdvertisingConfig
+ && Objects.equals(mTtl, otherRequest.mTtl);
}
}
@Override
public int hashCode() {
- return Objects.hash(mServiceInfo, mProtocolType, mAdvertisingConfig);
+ return Objects.hash(mServiceInfo, mProtocolType, mAdvertisingConfig, mTtl);
}
@Override
@@ -139,6 +163,7 @@
dest.writeParcelable(mServiceInfo, flags);
dest.writeInt(mProtocolType);
dest.writeLong(mAdvertisingConfig);
+ dest.writeLong(mTtl == null ? -1 : mTtl.getSeconds());
}
// @FlaggedApi(NsdManager.Flags.ADVERTISE_REQUEST_API)
@@ -151,6 +176,8 @@
private final NsdServiceInfo mServiceInfo;
private final int mProtocolType;
private long mAdvertisingConfig;
+ @Nullable
+ private Duration mTtl;
/**
* Creates a new {@link Builder} object.
*/
@@ -170,11 +197,44 @@
return this;
}
+ /**
+ * Sets the time interval that the resource records may be cached on a DNS resolver.
+ *
+ * If this method is not called or {@code ttl} is {@code null}, default TTL values
+ * will be used for the service when it's registered. Otherwise, the {@code ttl}
+ * will be used for all resource records of this service.
+ *
+ * When registering a service, {@link NsdManager#FAILURE_BAD_PARAMETERS} will be returned
+ * if {@code ttl} is smaller than 30 seconds.
+ *
+ * Note: only number of seconds of {@code ttl} is used.
+ *
+ * @param ttl the maximum duration that the DNS resource records will be cached
+ *
+ * @see AdvertisingRequest#getTtl
+ * @hide
+ */
+ // @FlaggedApi(NsdManager.Flags.NSD_CUSTOM_TTL_ENABLED)
+ @NonNull
+ public Builder setTtl(@Nullable Duration ttl) {
+ if (ttl == null) {
+ mTtl = null;
+ return this;
+ }
+ final long ttlSeconds = ttl.getSeconds();
+ if (ttlSeconds < 0 || ttlSeconds > 0xffffffffL) {
+ throw new IllegalArgumentException(
+ "ttlSeconds exceeds the allowed range (value = " + ttlSeconds
+ + ", allowedRanged = [0, 0xffffffffL])");
+ }
+ mTtl = Duration.ofSeconds(ttlSeconds);
+ return this;
+ }
/** Creates a new {@link AdvertisingRequest} object. */
@NonNull
public AdvertisingRequest build() {
- return new AdvertisingRequest(mServiceInfo, mProtocolType, mAdvertisingConfig);
+ return new AdvertisingRequest(mServiceInfo, mProtocolType, mAdvertisingConfig, mTtl);
}
}
}
diff --git a/framework-t/src/android/net/nsd/NsdManager.java b/framework-t/src/android/net/nsd/NsdManager.java
index f6e1324..1001423 100644
--- a/framework-t/src/android/net/nsd/NsdManager.java
+++ b/framework-t/src/android/net/nsd/NsdManager.java
@@ -160,6 +160,8 @@
"com.android.net.flags.advertise_request_api";
static final String NSD_CUSTOM_HOSTNAME_ENABLED =
"com.android.net.flags.nsd_custom_hostname_enabled";
+ static final String NSD_CUSTOM_TTL_ENABLED =
+ "com.android.net.flags.nsd_custom_ttl_enabled";
}
/**
@@ -327,6 +329,20 @@
/** Dns based service discovery protocol */
public static final int PROTOCOL_DNS_SD = 0x0001;
+ /**
+ * The minimum TTL seconds which is allowed for a service registration.
+ *
+ * @hide
+ */
+ public static final long TTL_SECONDS_MIN = 30L;
+
+ /**
+ * The maximum TTL seconds which is allowed for a service registration.
+ *
+ * @hide
+ */
+ public static final long TTL_SECONDS_MAX = 10 * 3600L;
+
private static final SparseArray<String> EVENT_NAMES = new SparseArray<>();
static {
EVENT_NAMES.put(DISCOVER_SERVICES, "DISCOVER_SERVICES");
diff --git a/framework-t/src/android/net/nsd/NsdServiceInfo.java b/framework-t/src/android/net/nsd/NsdServiceInfo.java
index 146d4ca..4b1252e 100644
--- a/framework-t/src/android/net/nsd/NsdServiceInfo.java
+++ b/framework-t/src/android/net/nsd/NsdServiceInfo.java
@@ -19,12 +19,15 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import android.annotation.FlaggedApi;
+import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.compat.annotation.UnsupportedAppUsage;
import android.net.Network;
+import android.net.nsd.NsdManager.RegistrationListener;
import android.os.Parcel;
import android.os.Parcelable;
+import android.os.Process;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
@@ -35,6 +38,7 @@
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -71,6 +75,10 @@
private int mInterfaceIndex;
+ // The timestamp that all resource records associated with this service are considered invalid.
+ @Nullable
+ private Instant mExpirationTime;
+
public NsdServiceInfo() {
mSubtypes = new ArraySet<>();
mTxtRecord = new ArrayMap<>();
@@ -99,6 +107,7 @@
mPort = other.getPort();
mNetwork = other.getNetwork();
mInterfaceIndex = other.getInterfaceIndex();
+ mExpirationTime = other.getExpirationTime();
}
/** Get the service name */
@@ -490,6 +499,38 @@
return Collections.unmodifiableSet(mSubtypes);
}
+ /**
+ * Sets the timestamp after when this service is expired.
+ *
+ * Note: only number of seconds of {@code expirationTime} is used.
+ *
+ * @hide
+ */
+ public void setExpirationTime(@Nullable Instant expirationTime) {
+ if (expirationTime == null) {
+ mExpirationTime = null;
+ } else {
+ mExpirationTime = Instant.ofEpochSecond(expirationTime.getEpochSecond());
+ }
+ }
+
+ /**
+ * Returns the timestamp after when this service is expired or {@code null} if it's unknown.
+ *
+ * A service is considered expired if any of its DNS record is expired.
+ *
+ * Clients that are depending on the refreshness of the service information should not continue
+ * use this service after the returned timestamp. Instead, clients may re-send queries for the
+ * service to get updated the service information.
+ *
+ * @hide
+ */
+ // @FlaggedApi(NsdManager.Flags.NSD_CUSTOM_TTL_ENABLED)
+ @Nullable
+ public Instant getExpirationTime() {
+ return mExpirationTime;
+ }
+
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
@@ -499,7 +540,8 @@
.append(", hostAddresses: ").append(TextUtils.join(", ", mHostAddresses))
.append(", hostname: ").append(mHostname)
.append(", port: ").append(mPort)
- .append(", network: ").append(mNetwork);
+ .append(", network: ").append(mNetwork)
+ .append(", expirationTime: ").append(mExpirationTime);
byte[] txtRecord = getTxtRecord();
sb.append(", txtRecord: ").append(new String(txtRecord, StandardCharsets.UTF_8));
@@ -539,6 +581,7 @@
InetAddressUtils.parcelInetAddress(dest, address, flags);
}
dest.writeString(mHostname);
+ dest.writeLong(mExpirationTime != null ? mExpirationTime.getEpochSecond() : -1);
}
/** Implement the Parcelable interface */
@@ -569,6 +612,8 @@
info.mHostAddresses.add(InetAddressUtils.unparcelInetAddress(in));
}
info.mHostname = in.readString();
+ final long seconds = in.readLong();
+ info.setExpirationTime(seconds < 0 ? null : Instant.ofEpochSecond(seconds));
return info;
}
diff --git a/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
new file mode 100644
index 0000000..fa8b0d5
--- /dev/null
+++ b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2024 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 android.net.nsd
+
+import android.net.Network
+import android.net.nsd.AdvertisingRequest
+import android.net.nsd.AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdManager.PROTOCOL_DNS_SD
+import android.net.nsd.NsdServiceInfo
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.parcelingRoundTrip
+import com.android.testutils.assertThrows
+import java.time.Duration
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO: move this class to CTS tests when AdvertisingRequest is made public
+/** Unit tests for {@link AdvertisingRequest}. */
+@IgnoreUpTo(Build.VERSION_CODES.S_V2)
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@ConnectivityModuleTest
+class AdvertisingRequestTest {
+ @Test
+ fun testParcelingIsLossLess() {
+ val info = NsdServiceInfo().apply {
+ serviceType = "_ipp._tcp"
+ }
+ val beforeParcel = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+ .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+ .setTtl(Duration.ofSeconds(30L))
+ .build()
+
+ val afterParcel = parcelingRoundTrip(beforeParcel)
+
+ assertEquals(beforeParcel.serviceInfo.serviceType, afterParcel.serviceInfo.serviceType);
+ assertEquals(beforeParcel.advertisingConfig, afterParcel.advertisingConfig);
+ }
+
+@Test
+fun testBuilder_setNullTtl_success() {
+ val info = NsdServiceInfo().apply {
+ serviceType = "_ipp._tcp"
+ }
+ val request = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+ .setTtl(null)
+ .build()
+
+ assertNull(request.ttl)
+}
+
+ @Test
+ fun testBuilder_setPropertiesSuccess() {
+ val info = NsdServiceInfo().apply {
+ serviceType = "_ipp._tcp"
+ }
+ val request = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+ .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+ .setTtl(Duration.ofSeconds(100L))
+ .build()
+
+ assertEquals("_ipp._tcp", request.serviceInfo.serviceType)
+ assertEquals(PROTOCOL_DNS_SD, request.protocolType)
+ assertEquals(NSD_ADVERTISING_UPDATE_ONLY, request.advertisingConfig)
+ assertEquals(Duration.ofSeconds(100L), request.ttl)
+ }
+
+ @Test
+ fun testEquality() {
+ val info = NsdServiceInfo().apply {
+ serviceType = "_ipp._tcp"
+ }
+ val request1 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD).build()
+ val request2 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD).build()
+ val request3 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+ .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+ .setTtl(Duration.ofSeconds(120L))
+ .build()
+ val request4 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+ .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+ .setTtl(Duration.ofSeconds(120L))
+ .build()
+
+ assertEquals(request1, request2)
+ assertEquals(request3, request4)
+ assertNotEquals(request1, request3)
+ assertNotEquals(request2, request4)
+ }
+}
diff --git a/tests/unit/java/android/net/nsd/NsdManagerTest.java b/tests/unit/java/android/net/nsd/NsdManagerTest.java
index 951675c..76a649e 100644
--- a/tests/unit/java/android/net/nsd/NsdManagerTest.java
+++ b/tests/unit/java/android/net/nsd/NsdManagerTest.java
@@ -23,6 +23,7 @@
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -54,6 +55,7 @@
import java.net.InetAddress;
import java.util.List;
+import java.time.Duration;
@DevSdkIgnoreRunner.MonitorThreadLeak
@RunWith(DevSdkIgnoreRunner.class)
@@ -224,6 +226,23 @@
verify(listener, timeout(mTimeoutMs).times(1)).onServiceRegistered(request);
}
+ @Test
+ public void testRegisterServiceWithCustomTtl() throws Exception {
+ final NsdManager manager = mManager;
+ final NsdServiceInfo info = new NsdServiceInfo("another_name2", "another_type2");
+ info.setPort(2203);
+ final AdvertisingRequest request = new AdvertisingRequest.Builder(info, PROTOCOL)
+ .setTtl(Duration.ofSeconds(30)).build();
+ final NsdManager.RegistrationListener listener = mock(
+ NsdManager.RegistrationListener.class);
+
+ manager.registerService(request, Runnable::run, listener);
+
+ AdvertisingRequest capturedRequest = getAdvertisingRequest(
+ req -> verify(mServiceConn).registerService(anyInt(), req.capture()));
+ assertEquals(request, capturedRequest);
+ }
+
private void doTestRegisterService() throws Exception {
NsdManager manager = mManager;
@@ -501,4 +520,12 @@
verifier.accept(captor);
return captor.getValue();
}
+
+ AdvertisingRequest getAdvertisingRequest(
+ ThrowingConsumer<ArgumentCaptor<AdvertisingRequest>> verifier) throws Exception {
+ final ArgumentCaptor<AdvertisingRequest> captor =
+ ArgumentCaptor.forClass(AdvertisingRequest.class);
+ verifier.accept(captor);
+ return captor.getValue();
+ }
}