Merge "[remoteauth] Clean up unused deps" into main
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 13653d8..9757daa 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -213,6 +213,7 @@
             "android.net.http.apihelpers",
             "android.net.netstats.provider",
             "android.net.nsd",
+            "android.net.thread",
             "android.net.wear",
         ],
     },
diff --git a/Tethering/src/com/android/networkstack/tethering/util/SyncStateMachine.java b/Tethering/src/com/android/networkstack/tethering/util/SyncStateMachine.java
new file mode 100644
index 0000000..a17eb26
--- /dev/null
+++ b/Tethering/src/com/android/networkstack/tethering/util/SyncStateMachine.java
@@ -0,0 +1,333 @@
+/**
+ * Copyright (C) 2023 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.networkstack.tethering.util;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Message;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.internal.util.State;
+
+import java.util.ArrayDeque;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * An implementation of a state machine, meant to be called synchronously.
+ *
+ * This class implements a finite state automaton based on the same State
+ * class as StateMachine.
+ * All methods of this class must be called on only one thread.
+ */
+public class SyncStateMachine {
+    @NonNull private final String mName;
+    @NonNull private final Thread mMyThread;
+    private final boolean mDbg;
+    private final ArrayMap<State, StateInfo> mStateInfo = new ArrayMap<>();
+
+    // mCurrentState is the current state. mDestState is the target state that mCurrentState will
+    // transition to. The value of mDestState can be changed when a state processes a message and
+    // calls #transitionTo, but it cannot be changed during the state transition. When the state
+    // transition is complete, mDestState will be set to mCurrentState. Both mCurrentState and
+    // mDestState only be null before state machine starts and must only be touched on mMyThread.
+    @Nullable private State mCurrentState;
+    @Nullable private State mDestState;
+    private final ArrayDeque<Message> mSelfMsgQueue = new ArrayDeque<Message>();
+
+    // MIN_VALUE means not currently processing any message.
+    private int mCurrentlyProcessing = Integer.MIN_VALUE;
+    // Indicates whether automaton can send self message. Self messages can only be sent by
+    // automaton from State#enter, State#exit, or State#processMessage. Calling from outside
+    // of State is not allowed.
+    private boolean mSelfMsgAllowed = false;
+
+    /**
+     * A information class about a state and its parent. Used to maintain the state hierarchy.
+     */
+    public static class StateInfo {
+        /** The state who owns this StateInfo. */
+        public final State state;
+        /** The parent state. */
+        public final State parent;
+        // True when the state has been entered and on the stack.
+        private boolean mActive;
+
+        public StateInfo(@NonNull final State child, @Nullable final State parent) {
+            this.state = child;
+            this.parent = parent;
+        }
+    }
+
+    /**
+     * The constructor.
+     *
+     * @param name of this machine.
+     * @param thread the running thread of this machine. It must either be the thread on which this
+     * constructor is called, or a thread that is not started yet.
+     */
+    public SyncStateMachine(@NonNull final String name, @NonNull final Thread thread) {
+        this(name, thread, false /* debug */);
+    }
+
+    /**
+     * The constructor.
+     *
+     * @param name of this machine.
+     * @param thread the running thread of this machine. It must either be the thread on which this
+     * constructor is called, or a thread that is not started yet.
+     * @param dbg whether to print debug logs.
+     */
+    public SyncStateMachine(@NonNull final String name, @NonNull final Thread thread,
+            final boolean dbg) {
+        mMyThread = thread;
+        // Machine can either be setup from machine thread or before machine thread started.
+        ensureCorrectOrNotStartedThread();
+
+        mName = name;
+        mDbg = dbg;
+    }
+
+    /**
+     * Add all of states to the state machine. Different StateInfos which have same state are not
+     * allowed. In other words, a state can not have multiple parent states. #addAllStates can
+     * only be called once either from mMyThread or before mMyThread started.
+     */
+    public final void addAllStates(@NonNull final List<StateInfo> stateInfos) {
+        ensureCorrectOrNotStartedThread();
+
+        if (mCurrentState != null) {
+            throw new IllegalStateException("State only can be added before started");
+        }
+
+        if (stateInfos.isEmpty()) throw new IllegalStateException("Empty state is not allowed");
+
+        if (!mStateInfo.isEmpty()) throw new IllegalStateException("States are already configured");
+
+        final Set<Class> usedClasses = new ArraySet<>();
+        for (final StateInfo info : stateInfos) {
+            Objects.requireNonNull(info.state);
+            if (!usedClasses.add(info.state.getClass())) {
+                throw new IllegalStateException("Adding the same state multiple times in a state "
+                        + "machine is forbidden because it tends to be confusing; it can be done "
+                        + "with anonymous subclasses but consider carefully whether you want to "
+                        + "use a single state or other alternatives instead.");
+            }
+
+            mStateInfo.put(info.state, info);
+        }
+
+        // Check whether all of parent states indicated from StateInfo are added.
+        for (final StateInfo info : stateInfos) {
+            if (info.parent != null) ensureExistingState(info.parent);
+        }
+    }
+
+    /**
+     * Start the state machine. The initial state can't be child state.
+     *
+     * @param initialState the first state of this machine. The state must be exact state object
+     * setting up by {@link #addAllStates}, not a copy of it.
+     */
+    public final void start(@NonNull final State initialState) {
+        ensureCorrectThread();
+        ensureExistingState(initialState);
+
+        mDestState = initialState;
+        mSelfMsgAllowed = true;
+        performTransitions();
+        mSelfMsgAllowed = false;
+        // If sendSelfMessage was called inside initialState#enter(), mSelfMsgQueue must be
+        // processed.
+        maybeProcessSelfMessageQueue();
+    }
+
+    /**
+     * Process the message synchronously then perform state transition. This method is used
+     * externally to the automaton to request that the automaton process the given message.
+     * The message is processed sequentially, so calling this method recursively is not permitted.
+     * In other words, using this method inside State#enter, State#exit, or State#processMessage
+     * is incorrect and will result in an IllegalStateException.
+     */
+    public final void processMessage(int what, int arg1, int arg2, @Nullable Object obj) {
+        ensureCorrectThread();
+
+        if (mCurrentlyProcessing != Integer.MIN_VALUE) {
+            throw new IllegalStateException("Message(" + mCurrentlyProcessing
+                    + ") is still being processed");
+        }
+
+        // mCurrentlyProcessing tracks the external message request and it prevents this method to
+        // be called recursively. Once this message is processed and the transitions have been
+        // performed, the automaton will process the self message queue. The messages in the self
+        // message queue are added from within the automaton during processing external message.
+        // mCurrentlyProcessing is still the original external one and it will not prevent self
+        // messages from being processed.
+        mCurrentlyProcessing = what;
+        final Message msg = Message.obtain(null, what, arg1, arg2, obj);
+        currentStateProcessMessageThenPerformTransitions(msg);
+        msg.recycle();
+        maybeProcessSelfMessageQueue();
+
+        mCurrentlyProcessing = Integer.MIN_VALUE;
+    }
+
+    private void maybeProcessSelfMessageQueue() {
+        while (!mSelfMsgQueue.isEmpty()) {
+            currentStateProcessMessageThenPerformTransitions(mSelfMsgQueue.poll());
+        }
+    }
+
+    private void currentStateProcessMessageThenPerformTransitions(@NonNull final Message msg) {
+        mSelfMsgAllowed = true;
+        StateInfo consideredState = mStateInfo.get(mCurrentState);
+        while (null != consideredState) {
+            // Ideally this should compare with IState.HANDLED, but it is not public field so just
+            // checking whether the return value is true (IState.HANDLED = true).
+            if (consideredState.state.processMessage(msg)) {
+                if (mDbg) {
+                    Log.d(mName, "State " + consideredState.state
+                            + " processed message " + msg.what);
+                }
+                break;
+            }
+            consideredState = mStateInfo.get(consideredState.parent);
+        }
+        if (null == consideredState) {
+            Log.wtf(mName, "Message " + msg.what + " was not handled");
+        }
+
+        performTransitions();
+        mSelfMsgAllowed = false;
+    }
+
+    /**
+     * Send self message during state transition.
+     *
+     * Must only be used inside State processMessage, enter or exit. The typical use case is
+     * something wrong happens during state transition, sending an error message which would be
+     * handled after finishing current state transitions.
+     */
+    public final void sendSelfMessage(int what, int arg1, int arg2, Object obj) {
+        if (!mSelfMsgAllowed) {
+            throw new IllegalStateException("sendSelfMessage can only be called inside "
+                    + "State#enter, State#exit or State#processMessage");
+        }
+
+        mSelfMsgQueue.add(Message.obtain(null, what, arg1, arg2, obj));
+    }
+
+    /**
+     * Transition to destination state. Upon returning from processMessage the automaton will
+     * transition to the given destination state.
+     *
+     * This function can NOT be called inside the State enter and exit function. The transition
+     * target is always defined and can never be changed mid-way of state transition.
+     *
+     * @param destState will be the state to transition to. The state must be the same instance set
+     * up by {@link #addAllStates}, not a copy of it.
+     */
+    public final void transitionTo(@NonNull final State destState) {
+        if (mDbg) Log.d(mName, "transitionTo " + destState);
+        ensureCorrectThread();
+        ensureExistingState(destState);
+
+        if (mDestState == mCurrentState) {
+            mDestState = destState;
+        } else {
+            throw new IllegalStateException("Destination already specified");
+        }
+    }
+
+    private void performTransitions() {
+        // 1. Determine the common ancestor state of current/destination states
+        // 2. Invoke state exit list from current state to common ancestor state.
+        // 3. Invoke state enter list from common ancestor state to destState by going
+        // through mEnterStateStack.
+        if (mDestState == mCurrentState) return;
+
+        final StateInfo commonAncestor = getLastActiveAncestor(mStateInfo.get(mDestState));
+
+        executeExitMethods(commonAncestor, mStateInfo.get(mCurrentState));
+        executeEnterMethods(commonAncestor, mStateInfo.get(mDestState));
+        mCurrentState = mDestState;
+    }
+
+    // Null is the root of all states.
+    private StateInfo getLastActiveAncestor(@Nullable final StateInfo start) {
+        if (null == start || start.mActive) return start;
+
+        return getLastActiveAncestor(mStateInfo.get(start.parent));
+    }
+
+    // Call the exit method from current state to common ancestor state.
+    // Both the commonAncestor and exitingState StateInfo can be null because null is the ancestor
+    // of all states.
+    // For example: When transitioning from state1 to state2, the
+    // executeExitMethods(commonAncestor, exitingState) function will be called twice, once with
+    // null and state1 as the argument, and once with null and null as the argument.
+    //              root
+    //              |   \
+    // current <- state1 state2 -> destination
+    private void executeExitMethods(@Nullable StateInfo commonAncestor,
+            @Nullable StateInfo exitingState) {
+        if (commonAncestor == exitingState) return;
+
+        if (mDbg) Log.d(mName, exitingState.state + " exit()");
+        exitingState.state.exit();
+        exitingState.mActive = false;
+        executeExitMethods(commonAncestor, mStateInfo.get(exitingState.parent));
+    }
+
+    // Call the enter method from common ancestor state to destination state.
+    // Both the commonAncestor and enteringState StateInfo can be null because null is the ancestor
+    // of all states.
+    // For example: When transitioning from state1 to state2, the
+    // executeEnterMethods(commonAncestor, enteringState) function will be called twice, once with
+    // null and state2 as the argument, and once with null and null as the argument.
+    //              root
+    //              |   \
+    // current <- state1 state2 -> destination
+    private void executeEnterMethods(@Nullable StateInfo commonAncestor,
+            @Nullable StateInfo enteringState) {
+        if (enteringState == commonAncestor) return;
+
+        executeEnterMethods(commonAncestor, mStateInfo.get(enteringState.parent));
+        if (mDbg) Log.d(mName, enteringState.state + " enter()");
+        enteringState.state.enter();
+        enteringState.mActive = true;
+    }
+
+    private void ensureCorrectThread() {
+        if (!mMyThread.equals(Thread.currentThread())) {
+            throw new IllegalStateException("Called from wrong thread");
+        }
+    }
+
+    private void ensureCorrectOrNotStartedThread() {
+        if (!mMyThread.isAlive()) return;
+
+        ensureCorrectThread();
+    }
+
+    private void ensureExistingState(@NonNull final State state) {
+        if (!mStateInfo.containsKey(state)) throw new IllegalStateException("Invalid state");
+    }
+}
diff --git a/bpf_progs/netd.c b/bpf_progs/netd.c
index c3258e9..0054d4a 100644
--- a/bpf_progs/netd.c
+++ b/bpf_progs/netd.c
@@ -401,11 +401,6 @@
     return true;  // disallowed interface
 }
 
-// DROP_IF_SET is set of rules that DROP if rule is globally enabled, and per-uid bit is set
-#define DROP_IF_SET (STANDBY_MATCH | OEM_DENY_1_MATCH | OEM_DENY_2_MATCH | OEM_DENY_3_MATCH)
-// DROP_IF_UNSET is set of rules that should DROP if globally enabled, and per-uid bit is NOT set
-#define DROP_IF_UNSET (DOZABLE_MATCH | POWERSAVE_MATCH | RESTRICTED_MATCH | LOW_POWER_STANDBY_MATCH)
-
 static __always_inline inline int bpf_owner_match(struct __sk_buff* skb, uint32_t uid,
                                                   bool egress, const unsigned kver) {
     if (is_system_uid(uid)) return PASS;
@@ -418,12 +413,7 @@
     uint32_t uidRules = uidEntry ? uidEntry->rule : 0;
     uint32_t allowed_iif = uidEntry ? uidEntry->iif : 0;
 
-    // Warning: funky bit-wise arithmetic: in parallel, for all DROP_IF_SET/UNSET rules
-    // check whether the rules are globally enabled, and if so whether the rules are
-    // set/unset for the specific uid.  DROP if that is the case for ANY of the rules.
-    // We achieve this by masking out only the bits/rules we're interested in checking,
-    // and negating (via bit-wise xor) the bits/rules that should drop if unset.
-    if (enabledRules & (DROP_IF_SET | DROP_IF_UNSET) & (uidRules ^ DROP_IF_UNSET)) return DROP;
+    if (isBlockedByUidRules(enabledRules, uidRules)) return DROP;
 
     if (!egress && skb->ifindex != 1) {
         if (ingress_should_discard(skb, kver)) return DROP;
diff --git a/bpf_progs/netd.h b/bpf_progs/netd.h
index 6e9acaa..dd27bf9 100644
--- a/bpf_progs/netd.h
+++ b/bpf_progs/netd.h
@@ -235,3 +235,17 @@
 #define CURRENT_STATS_MAP_CONFIGURATION_KEY 1
 
 #undef STRUCT_SIZE
+
+// DROP_IF_SET is set of rules that DROP if rule is globally enabled, and per-uid bit is set
+#define DROP_IF_SET (STANDBY_MATCH | OEM_DENY_1_MATCH | OEM_DENY_2_MATCH | OEM_DENY_3_MATCH)
+// DROP_IF_UNSET is set of rules that should DROP if globally enabled, and per-uid bit is NOT set
+#define DROP_IF_UNSET (DOZABLE_MATCH | POWERSAVE_MATCH | RESTRICTED_MATCH | LOW_POWER_STANDBY_MATCH)
+
+// Warning: funky bit-wise arithmetic: in parallel, for all DROP_IF_SET/UNSET rules
+// check whether the rules are globally enabled, and if so whether the rules are
+// set/unset for the specific uid.  DROP if that is the case for ANY of the rules.
+// We achieve this by masking out only the bits/rules we're interested in checking,
+// and negating (via bit-wise xor) the bits/rules that should drop if unset.
+static inline bool isBlockedByUidRules(BpfConfig enabledRules, uint32_t uidRules) {
+    return enabledRules & (DROP_IF_SET | DROP_IF_UNSET) & (uidRules ^ DROP_IF_UNSET);
+}
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index 64762b4..ea465aa 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -388,3 +388,16 @@
 
 }
 
+package android.net.thread {
+
+  public class ThreadNetworkController {
+    method public int getThreadVersion();
+    field public static final int THREAD_VERSION_1_3 = 4; // 0x4
+  }
+
+  public class ThreadNetworkManager {
+    method @NonNull public java.util.List<android.net.thread.ThreadNetworkController> getAllThreadNetworkControllers();
+  }
+
+}
+
diff --git a/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java b/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
index d9c9d74..d89964d 100644
--- a/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
+++ b/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
@@ -24,6 +24,8 @@
 import android.net.nsd.INsdManager;
 import android.net.nsd.MDnsManager;
 import android.net.nsd.NsdManager;
+import android.net.thread.IThreadNetworkManager;
+import android.net.thread.ThreadNetworkManager;
 
 /**
  * Class for performing registration for Connectivity services which are exposed via updatable APIs
@@ -89,5 +91,14 @@
                     return new MDnsManager(service);
                 }
         );
+
+        SystemServiceRegistry.registerContextAwareService(
+                ThreadNetworkManager.SERVICE_NAME,
+                ThreadNetworkManager.class,
+                (context, serviceBinder) -> {
+                    IThreadNetworkManager managerService =
+                            IThreadNetworkManager.Stub.asInterface(serviceBinder);
+                    return new ThreadNetworkManager(context, managerService);
+                });
     }
 }
diff --git a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java
index 923730c..4b5874b 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingParameters.java
@@ -15,5 +15,67 @@
  */
 package com.android.server.remoteauth.ranging;
 
-/** The set of parameters to start ranging. */
-public class RangingParameters {}
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+
+/** The set of parameters to initiate {@link RangingSession#start}. */
+public class RangingParameters {
+
+    /** Parameters for {@link UwbRangingSession}. */
+    private final UwbAddress mUwbLocalAddress;
+
+    private final androidx.core.uwb.backend.impl.internal.RangingParameters mUwbRangingParameters;
+
+    public UwbAddress getUwbLocalAddress() {
+        return mUwbLocalAddress;
+    }
+
+    public androidx.core.uwb.backend.impl.internal.RangingParameters getUwbRangingParameters() {
+        return mUwbRangingParameters;
+    }
+
+    private RangingParameters(
+            UwbAddress uwbLocalAddress,
+            androidx.core.uwb.backend.impl.internal.RangingParameters uwbRangingParameters) {
+        mUwbLocalAddress = uwbLocalAddress;
+        mUwbRangingParameters = uwbRangingParameters;
+    }
+
+    /** Builder class for {@link RangingParameters}. */
+    public static final class Builder {
+        private UwbAddress mUwbLocalAddress;
+        private androidx.core.uwb.backend.impl.internal.RangingParameters mUwbRangingParameters;
+
+        /**
+         * Sets the uwb local address.
+         *
+         * <p>Only required if {@link SessionParameters#getRangingMethod}=={@link
+         * RANGING_METHOD_UWB} and {@link SessionParameters#getAutoDeriveParams} == false
+         */
+        public Builder setUwbLocalAddress(UwbAddress uwbLocalAddress) {
+            mUwbLocalAddress = uwbLocalAddress;
+            return this;
+        }
+
+        /**
+         * Sets the uwb ranging parameters.
+         *
+         * <p>Only required if {@link SessionParameters#getRangingMethod}=={@link
+         * RANGING_METHOD_UWB}.
+         *
+         * <p>If {@link SessionParameters#getAutoDeriveParams} == true, all required uwb parameters
+         * including uwbLocalAddress, complexChannel, peerAddresses, and sessionKeyInfo will be
+         * automatically derived, so unnecessary to provide and the other uwb parameters are
+         * optional.
+         */
+        public Builder setUwbRangingParameters(
+                androidx.core.uwb.backend.impl.internal.RangingParameters uwbRangingParameters) {
+            mUwbRangingParameters = uwbRangingParameters;
+            return this;
+        }
+
+        /** Builds {@link RangingParameters}. */
+        public RangingParameters build() {
+            return new RangingParameters(mUwbLocalAddress, mUwbRangingParameters);
+        }
+    }
+}
diff --git a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java
index adb36c5..a922168 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/ranging/RangingSession.java
@@ -37,7 +37,8 @@
  * <p>A session can be started and stopped multiple times. After starting, updates ({@link
  * RangingReport}, {@link RangingError}, etc) will be reported via the provided {@link
  * RangingCallback}. BaseKey and SyncData are used for auto derivation of supported ranging
- * parameters, which will be implementation specific.
+ * parameters, which will be implementation specific. All session creation shall only be conducted
+ * via {@link RangingManager#createSession}.
  *
  * <p>Ranging method specific implementation shall be implemented in the extended class.
  */
diff --git a/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java b/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java
index 2015b66..62463e1 100644
--- a/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java
+++ b/remoteauth/service/java/com/android/server/remoteauth/ranging/UwbRangingSession.java
@@ -15,30 +15,219 @@
  */
 package com.android.server.remoteauth.ranging;
 
-import android.annotation.NonNull;
-import android.content.Context;
+import static androidx.core.uwb.backend.impl.internal.RangingDevice.SESSION_ID_UNSET;
+import static androidx.core.uwb.backend.impl.internal.Utils.STATUS_OK;
+import static androidx.core.uwb.backend.impl.internal.Utils.SUPPORTED_BPRF_PREAMBLE_INDEX;
+import static androidx.core.uwb.backend.impl.internal.UwbAddress.SHORT_ADDRESS_LENGTH;
 
+import static com.android.server.remoteauth.ranging.RangingReport.PROXIMITY_STATE_INSIDE;
+import static com.android.server.remoteauth.ranging.RangingReport.PROXIMITY_STATE_OUTSIDE;
+import static com.android.server.remoteauth.ranging.SessionParameters.DEVICE_ROLE_INITIATOR;
+
+import static com.google.uwb.support.fira.FiraParams.UWB_CHANNEL_9;
+
+import android.annotation.NonNull;
+import android.annotation.RequiresApi;
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+
+import androidx.core.uwb.backend.impl.internal.RangingController;
+import androidx.core.uwb.backend.impl.internal.RangingDevice;
+import androidx.core.uwb.backend.impl.internal.RangingPosition;
+import androidx.core.uwb.backend.impl.internal.RangingSessionCallback;
+import androidx.core.uwb.backend.impl.internal.RangingSessionCallback.RangingSuspendedReason;
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+import androidx.core.uwb.backend.impl.internal.UwbComplexChannel;
+import androidx.core.uwb.backend.impl.internal.UwbDevice;
 import androidx.core.uwb.backend.impl.internal.UwbServiceImpl;
 
-import java.util.concurrent.Executor;
+import com.android.internal.util.Preconditions;
 
-/** UWB (ultra wide-band) implementation of {@link RangingSession}. */
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+/** UWB (ultra wide-band) specific implementation of {@link RangingSession}. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class UwbRangingSession extends RangingSession {
-    private static final int DERIVED_DATA_LENGTH = 1;
+    private static final String TAG = "UwbRangingSession";
+
+    private static final int COMPLEX_CHANNEL_LENGTH = 1;
+    private static final int STS_KEY_LENGTH = 16;
+    private static final int DERIVED_DATA_LENGTH =
+            COMPLEX_CHANNEL_LENGTH + SHORT_ADDRESS_LENGTH + SHORT_ADDRESS_LENGTH + STS_KEY_LENGTH;
+
+    private final UwbServiceImpl mUwbServiceImpl;
+    private final RangingDevice mRangingDevice;
+
+    private Executor mExecutor;
+    private RangingCallback mRangingCallback;
 
     public UwbRangingSession(
             @NonNull Context context,
             @NonNull SessionParameters sessionParameters,
             @NonNull UwbServiceImpl uwbServiceImpl) {
         super(context, sessionParameters, DERIVED_DATA_LENGTH);
+        Preconditions.checkNotNull(uwbServiceImpl);
+        mUwbServiceImpl = uwbServiceImpl;
+        if (sessionParameters.getDeviceRole() == DEVICE_ROLE_INITIATOR) {
+            mRangingDevice = (RangingDevice) mUwbServiceImpl.getController(context);
+        } else {
+            mRangingDevice = (RangingDevice) mUwbServiceImpl.getControlee(context);
+        }
     }
 
     @Override
     public void start(
             @NonNull RangingParameters rangingParameters,
             @NonNull Executor executor,
-            @NonNull RangingCallback rangingCallback) {}
+            @NonNull RangingCallback rangingCallback) {
+        Preconditions.checkNotNull(rangingParameters, "rangingParameters must not be null");
+        Preconditions.checkNotNull(executor, "executor must not be null");
+        Preconditions.checkNotNull(rangingCallback, "rangingCallback must not be null");
+
+        setUwbRangingParameters(rangingParameters);
+        int status =
+                mRangingDevice.startRanging(
+                        convertCallback(rangingCallback, executor),
+                        Executors.newSingleThreadExecutor());
+        if (status != STATUS_OK) {
+            Log.w(TAG, String.format("Uwb ranging start failed with status %d", status));
+            executor.execute(
+                    () -> rangingCallback.onError(mSessionInfo, RANGING_ERROR_FAILED_TO_START));
+            return;
+        }
+        mExecutor = executor;
+        mRangingCallback = rangingCallback;
+        Log.i(TAG, "start");
+    }
 
     @Override
-    public void stop() {}
+    public void stop() {
+        if (mRangingCallback == null) {
+            Log.w(TAG, String.format("Failed to stop unstarted session"));
+            return;
+        }
+        int status = mRangingDevice.stopRanging();
+        if (status != STATUS_OK) {
+            Log.w(TAG, String.format("Uwb ranging stop failed with status %d", status));
+            mExecutor.execute(
+                    () -> mRangingCallback.onError(mSessionInfo, RANGING_ERROR_FAILED_TO_STOP));
+            return;
+        }
+        mRangingCallback = null;
+        Log.i(TAG, "stop");
+    }
+
+    private void setUwbRangingParameters(RangingParameters rangingParameters) {
+        androidx.core.uwb.backend.impl.internal.RangingParameters params =
+                rangingParameters.getUwbRangingParameters();
+        Preconditions.checkNotNull(params, "uwbRangingParameters must not be null");
+        if (mAutoDeriveParams) {
+            Preconditions.checkArgument(mDerivedData.length == DERIVED_DATA_LENGTH);
+            ByteBuffer buffer = ByteBuffer.wrap(mDerivedData);
+
+            byte complexChannelByte = buffer.get();
+            int preambleIndex =
+                    SUPPORTED_BPRF_PREAMBLE_INDEX.get(
+                            Math.abs(complexChannelByte) % SUPPORTED_BPRF_PREAMBLE_INDEX.size());
+            // Selecting channel 9 since it's the only mandatory channel.
+            UwbComplexChannel complexChannel = new UwbComplexChannel(UWB_CHANNEL_9, preambleIndex);
+
+            byte[] localAddress = new byte[SHORT_ADDRESS_LENGTH];
+            byte[] peerAddress = new byte[SHORT_ADDRESS_LENGTH];
+            if (mRangingDevice instanceof RangingController) {
+                ((RangingController) mRangingDevice).setComplexChannel(complexChannel);
+                buffer.get(localAddress);
+                buffer.get(peerAddress);
+            } else {
+                buffer.get(peerAddress);
+                buffer.get(localAddress);
+            }
+            byte[] stsKey = new byte[STS_KEY_LENGTH];
+            buffer.get(stsKey);
+
+            mRangingDevice.setLocalAddress(UwbAddress.fromBytes(localAddress));
+            mRangingDevice.setRangingParameters(
+                    new androidx.core.uwb.backend.impl.internal.RangingParameters(
+                            params.getUwbConfigId(),
+                            SESSION_ID_UNSET,
+                            /* subSessionId= */ SESSION_ID_UNSET,
+                            stsKey,
+                            /* subSessionInfo= */ new byte[] {},
+                            complexChannel,
+                            List.of(UwbAddress.fromBytes(peerAddress)),
+                            params.getRangingUpdateRate(),
+                            params.getUwbRangeDataNtfConfig(),
+                            params.getSlotDuration(),
+                            params.isAoaDisabled()));
+        } else {
+            UwbAddress localAddress = rangingParameters.getUwbLocalAddress();
+            Preconditions.checkNotNull(localAddress, "localAddress must not be null");
+            UwbComplexChannel complexChannel = params.getComplexChannel();
+            Preconditions.checkNotNull(complexChannel, "complexChannel must not be null");
+            mRangingDevice.setLocalAddress(localAddress);
+            if (mRangingDevice instanceof RangingController) {
+                ((RangingController) mRangingDevice).setComplexChannel(complexChannel);
+            }
+            mRangingDevice.setRangingParameters(params);
+        }
+    }
+
+    private RangingSessionCallback convertCallback(RangingCallback callback, Executor executor) {
+        return new RangingSessionCallback() {
+
+            @Override
+            public void onRangingInitialized(UwbDevice device) {
+                Log.i(TAG, "onRangingInitialized");
+            }
+
+            @Override
+            public void onRangingResult(UwbDevice device, RangingPosition position) {
+                float distanceM = position.getDistance().getValue();
+                int proximityState =
+                        (mLowerProximityBoundaryM <= distanceM
+                                        && distanceM <= mUpperProximityBoundaryM)
+                                ? PROXIMITY_STATE_INSIDE
+                                : PROXIMITY_STATE_OUTSIDE;
+                position.getDistance().getValue();
+                RangingReport rangingReport =
+                        new RangingReport.Builder()
+                                .setDistanceM(distanceM)
+                                .setProximityState(proximityState)
+                                .build();
+                executor.execute(() -> callback.onRangingReport(mSessionInfo, rangingReport));
+            }
+
+            @Override
+            public void onRangingSuspended(UwbDevice device, @RangingSuspendedReason int reason) {
+                executor.execute(() -> callback.onError(mSessionInfo, convertError(reason)));
+            }
+        };
+    }
+
+    @RangingError
+    private static int convertError(@RangingSuspendedReason int reason) {
+        if (reason == RangingSessionCallback.REASON_WRONG_PARAMETERS) {
+            return RANGING_ERROR_INVALID_PARAMETERS;
+        }
+        if (reason == RangingSessionCallback.REASON_STOP_RANGING_CALLED) {
+            return RANGING_ERROR_STOPPED_BY_REQUEST;
+        }
+        if (reason == RangingSessionCallback.REASON_STOPPED_BY_PEER) {
+            return RANGING_ERROR_STOPPED_BY_PEER;
+        }
+        if (reason == RangingSessionCallback.REASON_FAILED_TO_START) {
+            return RANGING_ERROR_FAILED_TO_START;
+        }
+        if (reason == RangingSessionCallback.REASON_SYSTEM_POLICY) {
+            return RANGING_ERROR_SYSTEM_ERROR;
+        }
+        if (reason == RangingSessionCallback.REASON_MAX_RANGING_ROUND_RETRY_REACHED) {
+            return RANGING_ERROR_SYSTEM_TIMEOUT;
+        }
+        return RANGING_ERROR_UNKNOWN;
+    }
 }
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingParametersTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingParametersTest.java
new file mode 100644
index 0000000..3be5e70
--- /dev/null
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingParametersTest.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2023 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.remoteauth.ranging;
+
+import static androidx.core.uwb.backend.impl.internal.RangingDevice.SESSION_ID_UNSET;
+import static androidx.core.uwb.backend.impl.internal.Utils.CONFIG_PROVISIONED_UNICAST_DS_TWR;
+import static androidx.core.uwb.backend.impl.internal.Utils.DURATION_1_MS;
+import static androidx.core.uwb.backend.impl.internal.Utils.NORMAL;
+
+import static com.google.uwb.support.fira.FiraParams.UWB_CHANNEL_9;
+
+import static org.junit.Assert.assertEquals;
+
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+import androidx.core.uwb.backend.impl.internal.UwbComplexChannel;
+import androidx.core.uwb.backend.impl.internal.UwbRangeDataNtfConfig;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+/** Unit test for {@link RangingParameters}. */
+@RunWith(AndroidJUnit4.class)
+public class RangingParametersTest {
+
+    private static final UwbAddress TEST_UWB_LOCAL_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x00, 0x01});
+    private static final androidx.core.uwb.backend.impl.internal.RangingParameters
+            TEST_UWB_RANGING_PARAMETERS =
+                    new androidx.core.uwb.backend.impl.internal.RangingParameters(
+                            CONFIG_PROVISIONED_UNICAST_DS_TWR,
+                            /* sessionId= */ SESSION_ID_UNSET,
+                            /* subSessionId= */ SESSION_ID_UNSET,
+                            /* SessionInfo= */ new byte[] {},
+                            /* subSessionInfo= */ new byte[] {},
+                            new UwbComplexChannel(UWB_CHANNEL_9, /* preambleIndex= */ 9),
+                            List.of(UwbAddress.fromBytes(new byte[] {0x00, 0x02})),
+                            /* rangingUpdateRate= */ NORMAL,
+                            new UwbRangeDataNtfConfig.Builder().build(),
+                            /* slotDuration= */ DURATION_1_MS,
+                            /* isAoaDisabled= */ false);
+
+    @Test
+    public void testBuildingRangingParameters_success() {
+        final RangingParameters rangingParameters =
+                new RangingParameters.Builder()
+                        .setUwbLocalAddress(TEST_UWB_LOCAL_ADDRESS)
+                        .setUwbRangingParameters(TEST_UWB_RANGING_PARAMETERS)
+                        .build();
+
+        assertEquals(rangingParameters.getUwbLocalAddress(), TEST_UWB_LOCAL_ADDRESS);
+        assertEquals(rangingParameters.getUwbRangingParameters(), TEST_UWB_RANGING_PARAMETERS);
+    }
+}
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java
new file mode 100644
index 0000000..91198ab
--- /dev/null
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java
@@ -0,0 +1,375 @@
+/*
+ * Copyright (C) 2023 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.remoteauth.ranging;
+
+import static androidx.core.uwb.backend.impl.internal.RangingDevice.SESSION_ID_UNSET;
+import static androidx.core.uwb.backend.impl.internal.RangingMeasurement.CONFIDENCE_HIGH;
+import static androidx.core.uwb.backend.impl.internal.Utils.CONFIG_PROVISIONED_UNICAST_DS_TWR;
+import static androidx.core.uwb.backend.impl.internal.Utils.DURATION_1_MS;
+import static androidx.core.uwb.backend.impl.internal.Utils.NORMAL;
+import static androidx.core.uwb.backend.impl.internal.Utils.STATUS_ERROR;
+import static androidx.core.uwb.backend.impl.internal.Utils.STATUS_OK;
+
+import static com.android.server.remoteauth.ranging.RangingCapabilities.RANGING_METHOD_UWB;
+import static com.android.server.remoteauth.ranging.RangingReport.PROXIMITY_STATE_INSIDE;
+import static com.android.server.remoteauth.ranging.RangingSession.RANGING_ERROR_FAILED_TO_START;
+import static com.android.server.remoteauth.ranging.RangingSession.RANGING_ERROR_FAILED_TO_STOP;
+import static com.android.server.remoteauth.ranging.SessionParameters.DEVICE_ROLE_INITIATOR;
+import static com.android.server.remoteauth.ranging.SessionParameters.DEVICE_ROLE_RESPONDER;
+
+import static com.google.uwb.support.fira.FiraParams.UWB_CHANNEL_9;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+
+import androidx.core.uwb.backend.impl.internal.RangingControlee;
+import androidx.core.uwb.backend.impl.internal.RangingController;
+import androidx.core.uwb.backend.impl.internal.RangingMeasurement;
+import androidx.core.uwb.backend.impl.internal.RangingPosition;
+import androidx.core.uwb.backend.impl.internal.RangingSessionCallback;
+import androidx.core.uwb.backend.impl.internal.UwbAddress;
+import androidx.core.uwb.backend.impl.internal.UwbComplexChannel;
+import androidx.core.uwb.backend.impl.internal.UwbDevice;
+import androidx.core.uwb.backend.impl.internal.UwbRangeDataNtfConfig;
+import androidx.core.uwb.backend.impl.internal.UwbServiceImpl;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.server.remoteauth.ranging.RangingCapabilities.RangingMethod;
+import com.android.server.remoteauth.ranging.RangingSession.RangingCallback;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/** Unit test for {@link UwbRangingSession}. */
+@RunWith(AndroidJUnit4.class)
+public class UwbRangingSessionTest {
+
+    private static final String TEST_DEVICE_ID = "test_device_id";
+    @RangingMethod private static final int TEST_RANGING_METHOD = RANGING_METHOD_UWB;
+    private static final float TEST_LOWER_PROXIMITY_BOUNDARY_M = 1.0f;
+    private static final float TEST_UPPER_PROXIMITY_BOUNDARY_M = 2.5f;
+    private static final boolean TEST_AUTO_DERIVE_PARAMS = true;
+    private static final byte[] TEST_BASE_KEY =
+            new byte[] {
+                0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
+                0x0e, 0x0f
+            };
+    private static final byte[] TEST_SYNC_DATA =
+            new byte[] {
+                0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+                0x0f, 0x00
+            };
+    private static final SessionParameters TEST_SESSION_PARAMETER_INITIATOR =
+            new SessionParameters.Builder()
+                    .setDeviceId(TEST_DEVICE_ID)
+                    .setRangingMethod(TEST_RANGING_METHOD)
+                    .setDeviceRole(DEVICE_ROLE_INITIATOR)
+                    .setLowerProximityBoundaryM(TEST_LOWER_PROXIMITY_BOUNDARY_M)
+                    .setUpperProximityBoundaryM(TEST_UPPER_PROXIMITY_BOUNDARY_M)
+                    .build();
+    private static final SessionParameters TEST_SESSION_PARAMETER_RESPONDER =
+            new SessionParameters.Builder()
+                    .setDeviceId(TEST_DEVICE_ID)
+                    .setRangingMethod(TEST_RANGING_METHOD)
+                    .setDeviceRole(DEVICE_ROLE_RESPONDER)
+                    .setLowerProximityBoundaryM(TEST_LOWER_PROXIMITY_BOUNDARY_M)
+                    .setUpperProximityBoundaryM(TEST_UPPER_PROXIMITY_BOUNDARY_M)
+                    .build();
+    private static final SessionParameters TEST_SESSION_PARAMETER_INITIATOR_W_AD =
+            new SessionParameters.Builder()
+                    .setDeviceId(TEST_DEVICE_ID)
+                    .setRangingMethod(TEST_RANGING_METHOD)
+                    .setDeviceRole(DEVICE_ROLE_INITIATOR)
+                    .setLowerProximityBoundaryM(TEST_LOWER_PROXIMITY_BOUNDARY_M)
+                    .setUpperProximityBoundaryM(TEST_UPPER_PROXIMITY_BOUNDARY_M)
+                    .setAutoDeriveParams(TEST_AUTO_DERIVE_PARAMS)
+                    .setBaseKey(TEST_BASE_KEY)
+                    .setSyncData(TEST_SYNC_DATA)
+                    .build();
+    private static final UwbAddress TEST_UWB_LOCAL_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x00, 0x01});
+    private static final UwbAddress TEST_UWB_PEER_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x00, 0x02});
+    private static final UwbComplexChannel TEST_UWB_COMPLEX_CHANNEL =
+            new UwbComplexChannel(UWB_CHANNEL_9, /* preambleIndex= */ 9);
+    private static final androidx.core.uwb.backend.impl.internal.RangingParameters
+            TEST_UWB_RANGING_PARAMETERS =
+                    new androidx.core.uwb.backend.impl.internal.RangingParameters(
+                            CONFIG_PROVISIONED_UNICAST_DS_TWR,
+                            /* sessionId= */ SESSION_ID_UNSET,
+                            /* subSessionId= */ SESSION_ID_UNSET,
+                            /* SessionInfo= */ new byte[] {},
+                            /* subSessionInfo= */ new byte[] {},
+                            TEST_UWB_COMPLEX_CHANNEL,
+                            List.of(TEST_UWB_PEER_ADDRESS),
+                            NORMAL,
+                            new UwbRangeDataNtfConfig.Builder().build(),
+                            DURATION_1_MS,
+                            /* isAoaDisabled= */ false);
+    private static final RangingParameters TEST_RANGING_PARAMETERS =
+            new RangingParameters.Builder()
+                    .setUwbLocalAddress(TEST_UWB_LOCAL_ADDRESS)
+                    .setUwbRangingParameters(TEST_UWB_RANGING_PARAMETERS)
+                    .build();
+    private static final UwbAddress TEST_DERIVED_UWB_LOCAL_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {0x4C, (byte) 0xB4});
+    private static final UwbAddress TEST_DERIVED_UWB_PEER_ADDRESS =
+            UwbAddress.fromBytes(new byte[] {(byte) 0xAE, 0x2E});
+    private static final UwbComplexChannel TEST_DERIVED_UWB_COMPLEX_CHANNEL =
+            new UwbComplexChannel(UWB_CHANNEL_9, /* preambleIndex= */ 12);
+    private static final byte[] TEST_DERIVED_STS_KEY =
+            new byte[] {
+                0x76,
+                (byte) 0xD7,
+                (byte) 0xB6,
+                0x1A,
+                (byte) 0x8D,
+                0x29,
+                0x1A,
+                0x52,
+                (byte) 0xBB,
+                (byte) 0xBF,
+                (byte) 0xE6,
+                0x28,
+                (byte) 0xAD,
+                0x44,
+                (byte) 0xFB,
+                0x2E
+            };
+
+    private static final UwbDevice TEST_UWB_DEVICE =
+            UwbDevice.createForAddress(TEST_UWB_PEER_ADDRESS.toBytes());
+    private static final float TEST_DISTANCE = 1.5f;
+    private static final RangingMeasurement TEST_RANGING_MEASUREMENT =
+            new RangingMeasurement(
+                    /* confidence= */ CONFIDENCE_HIGH,
+                    /* value= */ TEST_DISTANCE,
+                    /* valid= */ true);
+    private static final RangingPosition TEST_RANGING_POSITION =
+            new RangingPosition(
+                    TEST_RANGING_MEASUREMENT,
+                    /* azimuth= */ null,
+                    /* elevation= */ null,
+                    /* dlTdoaMeasurement= */ null,
+                    /* elapsedRealtimeNanos= */ 0,
+                    /* rssi= */ 0);
+
+    @Mock private Context mContext;
+    @Mock private UwbServiceImpl mUwbServiceImpl;
+    @Mock private RangingController mRangingController;
+    @Mock private RangingControlee mRangingControlee;
+    @Mock private RangingCallback mRangingCallback;
+    @Mock private Executor mCallbackExecutor;
+
+    private UwbRangingSession mUwbRangingSession;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        when(mUwbServiceImpl.getController(mContext)).thenReturn(mRangingController);
+        when(mUwbServiceImpl.getControlee(mContext)).thenReturn(mRangingControlee);
+        when(mRangingController.startRanging(any(), any())).thenReturn(STATUS_OK);
+        when(mRangingControlee.startRanging(any(), any())).thenReturn(STATUS_OK);
+        doAnswer(
+                invocation -> {
+                    Runnable t = invocation.getArgument(0);
+                    t.run();
+                    return true;
+                })
+                .when(mCallbackExecutor)
+                .execute(any(Runnable.class));
+    }
+
+    @Test
+    public void testConstruction_nullArgument() {
+        assertThrows(
+                NullPointerException.class,
+                () ->
+                        new UwbRangingSession(
+                                null, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl));
+        assertThrows(
+                NullPointerException.class,
+                () -> new UwbRangingSession(mContext, null, mUwbServiceImpl));
+        assertThrows(
+                NullPointerException.class,
+                () -> new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, null));
+    }
+
+    @Test
+    public void testConstruction_initiatorSuccess() {
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        verify(mUwbServiceImpl, times(1)).getController(mContext);
+    }
+
+    @Test
+    public void testConstruction_responderSuccess() {
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_RESPONDER, mUwbServiceImpl);
+        verify(mUwbServiceImpl, times(1)).getControlee(mContext);
+    }
+
+    @Test
+    public void testStart_nullArgument() {
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+
+        assertThrows(
+                NullPointerException.class,
+                () -> mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, null));
+        assertThrows(
+                NullPointerException.class,
+                () -> mUwbRangingSession.start(null, mCallbackExecutor, mRangingCallback));
+        assertThrows(
+                NullPointerException.class,
+                () -> mUwbRangingSession.start(TEST_RANGING_PARAMETERS, null, mRangingCallback));
+        assertThrows(
+                NullPointerException.class,
+                () ->
+                        mUwbRangingSession.start(
+                                new RangingParameters.Builder().build(),
+                                mCallbackExecutor,
+                                mRangingCallback));
+    }
+
+    @Test
+    public void testStart_initiatorWithoutADFailed() {
+        when(mRangingController.startRanging(any(), any())).thenReturn(STATUS_ERROR);
+
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, mRangingCallback);
+
+        verify(mRangingController, times(1)).setComplexChannel(TEST_UWB_COMPLEX_CHANNEL);
+        verify(mRangingController, times(1)).setLocalAddress(TEST_UWB_LOCAL_ADDRESS);
+        verify(mRangingController, times(1)).setRangingParameters(TEST_UWB_RANGING_PARAMETERS);
+        verify(mRangingController, times(1)).startRanging(any(), any());
+        ArgumentCaptor<SessionInfo> captor = ArgumentCaptor.forClass(SessionInfo.class);
+        verify(mRangingCallback, times(1))
+                .onError(captor.capture(), eq(RANGING_ERROR_FAILED_TO_START));
+        assertEquals(captor.getValue().getDeviceId(), TEST_DEVICE_ID);
+    }
+
+    private void testRangingCallback() {
+        Answer startRangingResponse =
+                new Answer() {
+                    public Object answer(InvocationOnMock invocation) {
+                        Object[] args = invocation.getArguments();
+                        RangingSessionCallback cb = (RangingSessionCallback) args[0];
+                        cb.onRangingInitialized(TEST_UWB_DEVICE);
+                        cb.onRangingResult(TEST_UWB_DEVICE, TEST_RANGING_POSITION);
+                        return STATUS_OK;
+                    }
+                };
+        doAnswer(startRangingResponse)
+                .when(mRangingController)
+                .startRanging(any(RangingSessionCallback.class), any());
+    }
+
+    @Test
+    public void testStart_initiatorWithADSucceed() {
+        testRangingCallback();
+        mUwbRangingSession =
+                new UwbRangingSession(
+                        mContext, TEST_SESSION_PARAMETER_INITIATOR_W_AD, mUwbServiceImpl);
+        mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, mRangingCallback);
+
+        verify(mRangingController, times(1)).setComplexChannel(TEST_DERIVED_UWB_COMPLEX_CHANNEL);
+        verify(mRangingController, times(1)).setLocalAddress(TEST_DERIVED_UWB_LOCAL_ADDRESS);
+        ArgumentCaptor<androidx.core.uwb.backend.impl.internal.RangingParameters> captor =
+                ArgumentCaptor.forClass(
+                        androidx.core.uwb.backend.impl.internal.RangingParameters.class);
+        verify(mRangingController, times(1)).setRangingParameters(captor.capture());
+        assertEquals(
+                captor.getValue().getUwbConfigId(), TEST_UWB_RANGING_PARAMETERS.getUwbConfigId());
+        assertEquals(captor.getValue().getSessionId(), SESSION_ID_UNSET);
+        assertEquals(captor.getValue().getSubSessionId(), SESSION_ID_UNSET);
+        assertArrayEquals(captor.getValue().getSessionKeyInfo(), TEST_DERIVED_STS_KEY);
+        assertArrayEquals(captor.getValue().getSubSessionKeyInfo(), new byte[] {});
+        assertEquals(captor.getValue().getComplexChannel(), TEST_DERIVED_UWB_COMPLEX_CHANNEL);
+        assertEquals(captor.getValue().getPeerAddresses().get(0), TEST_DERIVED_UWB_PEER_ADDRESS);
+        assertEquals(
+                captor.getValue().getRangingUpdateRate(),
+                TEST_UWB_RANGING_PARAMETERS.getRangingUpdateRate());
+        assertEquals(
+                captor.getValue().getUwbRangeDataNtfConfig(),
+                TEST_UWB_RANGING_PARAMETERS.getUwbRangeDataNtfConfig());
+        assertEquals(
+                captor.getValue().getSlotDuration(), TEST_UWB_RANGING_PARAMETERS.getSlotDuration());
+        assertEquals(
+                captor.getValue().isAoaDisabled(), TEST_UWB_RANGING_PARAMETERS.isAoaDisabled());
+        verify(mRangingController, times(1)).startRanging(any(), any());
+        ArgumentCaptor<SessionInfo> captor2 = ArgumentCaptor.forClass(SessionInfo.class);
+        ArgumentCaptor<RangingReport> captor3 = ArgumentCaptor.forClass(RangingReport.class);
+        verify(mRangingCallback, times(1)).onRangingReport(captor2.capture(), captor3.capture());
+        assertEquals(captor2.getValue().getDeviceId(), TEST_DEVICE_ID);
+        RangingReport rangingReport = captor3.getValue();
+        assertEquals(rangingReport.getDistanceM(), TEST_DISTANCE, 0.0f);
+        assertEquals(rangingReport.getProximityState(), PROXIMITY_STATE_INSIDE);
+    }
+
+    @Test
+    public void testStop_sessionNotStarted() {
+        when(mRangingController.stopRanging()).thenReturn(STATUS_ERROR);
+
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        mUwbRangingSession.stop();
+
+        verifyZeroInteractions(mRangingController);
+        verifyZeroInteractions(mRangingCallback);
+    }
+
+    @Test
+    public void testStop_failed() {
+        when(mRangingController.stopRanging()).thenReturn(STATUS_ERROR);
+
+        mUwbRangingSession =
+                new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
+        mUwbRangingSession.start(TEST_RANGING_PARAMETERS, mCallbackExecutor, mRangingCallback);
+        mUwbRangingSession.stop();
+
+        verify(mRangingController, times(1)).setComplexChannel(any());
+        verify(mRangingController, times(1)).setLocalAddress(any());
+        verify(mRangingController, times(1)).setRangingParameters(any());
+        verify(mRangingController, times(1)).startRanging(any(), any());
+        verify(mRangingController, times(1)).stopRanging();
+        ArgumentCaptor<SessionInfo> captor = ArgumentCaptor.forClass(SessionInfo.class);
+        verify(mRangingCallback, times(1))
+                .onError(captor.capture(), eq(RANGING_ERROR_FAILED_TO_STOP));
+        assertEquals(captor.getValue().getDeviceId(), TEST_DEVICE_ID);
+    }
+}
diff --git a/service-t/src/com/android/server/ConnectivityServiceInitializer.java b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
index 624c5df..003ec8c 100644
--- a/service-t/src/com/android/server/ConnectivityServiceInitializer.java
+++ b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
@@ -16,7 +16,10 @@
 
 package com.android.server;
 
+import android.annotation.Nullable;
 import android.content.Context;
+import android.content.pm.PackageManager;
+import android.net.thread.ThreadNetworkManager;
 import android.util.Log;
 
 import com.android.modules.utils.build.SdkLevel;
@@ -26,6 +29,7 @@
 import com.android.server.ethernet.EthernetServiceImpl;
 import com.android.server.nearby.NearbyService;
 import com.android.server.remoteauth.RemoteAuthService;
+import com.android.server.thread.ThreadNetworkService;
 
 /**
  * Connectivity service initializer for core networking. This is called by system server to create
@@ -40,6 +44,7 @@
     private final NearbyService mNearbyService;
     private final EthernetServiceImpl mEthernetServiceImpl;
     private final RemoteAuthService mRemoteAuthService;
+    private final ThreadNetworkService mThreadNetworkService;
 
     public ConnectivityServiceInitializer(Context context) {
         super(context);
@@ -52,6 +57,7 @@
         mNsdService = createNsdService(context);
         mNearbyService = createNearbyService(context);
         mRemoteAuthService = createRemoteAuthService(context);
+        mThreadNetworkService = createThreadNetworkService(context);
     }
 
     @Override
@@ -93,6 +99,12 @@
             publishBinderService(RemoteAuthService.SERVICE_NAME, mRemoteAuthService,
                     /* allowIsolated= */ false);
         }
+
+        if (mThreadNetworkService != null) {
+            Log.i(TAG, "Registering " + ThreadNetworkManager.SERVICE_NAME);
+            publishBinderService(ThreadNetworkManager.SERVICE_NAME, mThreadNetworkService,
+                    /* allowIsolated= */ false);
+        }
     }
 
     @Override
@@ -104,6 +116,10 @@
         if (phase == SystemService.PHASE_SYSTEM_SERVICES_READY && mEthernetServiceImpl != null) {
             mEthernetServiceImpl.start();
         }
+
+        if (mThreadNetworkService != null) {
+            mThreadNetworkService.onBootPhase(phase);
+        }
     }
 
     /**
@@ -171,4 +187,25 @@
         }
         return EthernetService.create(context);
     }
+
+    /**
+     * Returns Thread network service instance if supported.
+     * Thread is supported if all of below are satisfied:
+     * 1. the FEATURE_THREAD_NETWORK is available
+     * 2. the SDK level is V+, or SDK level is U and the device is a TV
+     */
+    @Nullable
+    private ThreadNetworkService createThreadNetworkService(final Context context) {
+        final PackageManager pm = context.getPackageManager();
+        if (!pm.hasSystemFeature(ThreadNetworkManager.FEATURE_NAME)) {
+            return null;
+        }
+        if (!SdkLevel.isAtLeastU()) {
+            return null;
+        }
+        if (!SdkLevel.isAtLeastV() && !pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
+            return null;
+        }
+        return new ThreadNetworkService(context);
+    }
 }
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index ee79ef2..59a63f2 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -48,7 +48,7 @@
       // "src_devicecommon/**/*.kt",
   ],
   sdk_version: "module_current",
-  min_sdk_version: "29",
+  min_sdk_version: "30",
   target_sdk_version: "30",
   apex_available: [
       "//apex_available:anyapex",
@@ -128,7 +128,7 @@
         "framework/com/android/net/module/util/HexDump.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
@@ -153,7 +153,7 @@
         "device/com/android/net/module/util/structs/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
@@ -178,7 +178,7 @@
         "device/com/android/net/module/util/netlink/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
@@ -204,7 +204,7 @@
         "device/com/android/net/module/util/ip/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
@@ -232,7 +232,7 @@
         ":net-utils-framework-common-srcs",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     libs: [
         "androidx.annotation_annotation",
         "framework-annotations-lib",
@@ -310,7 +310,7 @@
         "device/com/android/net/module/util/async/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
     ],
@@ -332,7 +332,7 @@
         "device/com/android/net/module/util/wear/*.java",
     ],
     sdk_version: "module_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     visibility: [
         "//packages/modules/Connectivity:__subpackages__",
     ],
diff --git a/staticlibs/client-libs/Android.bp b/staticlibs/client-libs/Android.bp
index c560045..c938dd6 100644
--- a/staticlibs/client-libs/Android.bp
+++ b/staticlibs/client-libs/Android.bp
@@ -6,7 +6,7 @@
     name: "netd-client",
     srcs: ["netd/**/*"],
     sdk_version: "system_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     apex_available: [
         "//apex_available:platform",
         "com.android.tethering",
diff --git a/staticlibs/client-libs/tests/unit/Android.bp b/staticlibs/client-libs/tests/unit/Android.bp
index 220a6c1..03e3e70 100644
--- a/staticlibs/client-libs/tests/unit/Android.bp
+++ b/staticlibs/client-libs/tests/unit/Android.bp
@@ -8,7 +8,7 @@
         "src/**/*.java",
         "src/**/*.kt",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     static_libs: [
         "androidx.test.rules",
         "mockito-target-extended-minus-junit4",
diff --git a/staticlibs/native/netjniutils/Android.bp b/staticlibs/native/netjniutils/Android.bp
index 22fd1fa..ca3bbbc 100644
--- a/staticlibs/native/netjniutils/Android.bp
+++ b/staticlibs/native/netjniutils/Android.bp
@@ -31,8 +31,8 @@
         "-Werror",
         "-Wno-unused-parameter",
     ],
-    sdk_version: "29",
-    min_sdk_version: "29",
+    sdk_version: "30",
+    min_sdk_version: "30",
     apex_available: [
         "//apex_available:anyapex",
         "//apex_available:platform",
diff --git a/staticlibs/netd/Android.bp b/staticlibs/netd/Android.bp
index d135a1c..65b3b09 100644
--- a/staticlibs/netd/Android.bp
+++ b/staticlibs/netd/Android.bp
@@ -19,7 +19,7 @@
 java_library {
     name: "netd_aidl_interface-lateststable-java",
     sdk_version: "system_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     static_libs: [
         "netd_aidl_interface-V13-java",
     ],
@@ -38,7 +38,7 @@
     apex_available: [
         "com.android.resolv",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
 }
 
 cc_library_static {
@@ -50,7 +50,7 @@
         "com.android.resolv",
         "com.android.tethering",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
 }
 
 cc_defaults {
@@ -96,17 +96,17 @@
                 "com.android.tethering",
                 "com.android.wifi",
             ],
-            // this is part of updatable modules(NetworkStack) which targets 29(Q)
-            min_sdk_version: "29",
+            // this is part of updatable modules(NetworkStack) which targets 30(R)
+            min_sdk_version: "30",
         },
         ndk: {
             apex_available: [
                 "//apex_available:platform",
                 "com.android.tethering",
             ],
-            // This is necessary for the DnsResovler tests to run in Android Q.
-            // Soong would recognize this value and produce the Q compatible aidl library.
-            min_sdk_version: "29",
+            // This is necessary for the DnsResovler tests to run in Android R.
+            // Soong would recognize this value and produce the R compatible aidl library.
+            min_sdk_version: "30",
         },
     },
     versions_with_info: [
@@ -170,7 +170,7 @@
 java_library {
     name: "netd_event_listener_interface-lateststable-java",
     sdk_version: "system_current",
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     static_libs: [
         "netd_event_listener_interface-V1-java",
     ],
@@ -194,7 +194,7 @@
                 "//apex_available:platform",
                 "com.android.resolv",
             ],
-            min_sdk_version: "29",
+            min_sdk_version: "30",
         },
         java: {
             apex_available: [
@@ -202,7 +202,7 @@
                 "com.android.wifi",
                 "com.android.tethering",
             ],
-            min_sdk_version: "29",
+            min_sdk_version: "30",
         },
     },
     versions_with_info: [
diff --git a/staticlibs/netd/libnetdutils/Android.bp b/staticlibs/netd/libnetdutils/Android.bp
index 3169033..fdb9380 100644
--- a/staticlibs/netd/libnetdutils/Android.bp
+++ b/staticlibs/netd/libnetdutils/Android.bp
@@ -40,7 +40,7 @@
         "com.android.resolv",
         "com.android.tethering",
     ],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
 }
 
 cc_test {
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 40371e6..031e52f 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -9,7 +9,7 @@
 android_library {
     name: "NetworkStaticLibTestsLib",
     srcs: ["src/**/*.java","src/**/*.kt"],
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     defaults: ["framework-connectivity-test-defaults"],
     static_libs: [
         "androidx.test.rules",
diff --git a/staticlibs/testutils/app/connectivitychecker/Android.bp b/staticlibs/testutils/app/connectivitychecker/Android.bp
index f7118cf..049ec9e 100644
--- a/staticlibs/testutils/app/connectivitychecker/Android.bp
+++ b/staticlibs/testutils/app/connectivitychecker/Android.bp
@@ -20,9 +20,9 @@
     name: "ConnectivityTestPreparer",
     srcs: ["src/**/*.kt"],
     sdk_version: "system_current",
-    // Allow running the test on any device with SDK Q+, even when built from a branch that uses
+    // Allow running the test on any device with SDK R+, even when built from a branch that uses
     // an unstable SDK, by targeting a stable SDK regardless of the build SDK.
-    min_sdk_version: "29",
+    min_sdk_version: "30",
     target_sdk_version: "30",
     static_libs: [
         "androidx.test.rules",
diff --git a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
index f1f0975..d75d9ca 100644
--- a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
+++ b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
@@ -18,17 +18,10 @@
 
 import android.content.pm.PackageManager.FEATURE_TELEPHONY
 import android.content.pm.PackageManager.FEATURE_WIFI
-import android.net.ConnectivityManager
-import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
-import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
-import android.net.NetworkRequest
 import android.telephony.TelephonyManager
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.testutils.ConnectUtil
-import com.android.testutils.RecorderCallback
-import com.android.testutils.TestableNetworkCallback
-import com.android.testutils.tryTest
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import org.junit.Test
@@ -36,8 +29,9 @@
 
 @RunWith(AndroidJUnit4::class)
 class ConnectivityCheckTest {
-    val context by lazy { InstrumentationRegistry.getInstrumentation().context }
-    val pm by lazy { context.packageManager }
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val pm by lazy { context.packageManager }
+    private val connectUtil by lazy { ConnectUtil(context) }
 
     @Test
     fun testCheckConnectivity() {
@@ -47,7 +41,7 @@
 
     private fun checkWifiSetup() {
         if (!pm.hasSystemFeature(FEATURE_WIFI)) return
-        ConnectUtil(context).ensureWifiConnected()
+        connectUtil.ensureWifiValidated()
     }
 
     private fun checkTelephonySetup() {
@@ -69,20 +63,6 @@
         assertTrue(tm.isDataConnectivityPossible,
             "The device is not setup with a SIM card that supports data connectivity. " +
                     commonError)
-        val cb = TestableNetworkCallback()
-        val cm = context.getSystemService(ConnectivityManager::class.java)
-                ?: fail("Could not get ConnectivityManager")
-        cm.requestNetwork(
-                NetworkRequest.Builder()
-                        .addTransportType(TRANSPORT_CELLULAR)
-                        .addCapability(NET_CAPABILITY_INTERNET).build(), cb)
-        tryTest {
-            cb.poll { it is RecorderCallback.CallbackEntry.Available }
-                    ?: fail("The device does not have mobile data available. Check that it is " +
-                            "setup with a SIM card that has a working data plan, and that the " +
-                            "APN configuration is valid.")
-        } cleanup {
-            cm.unregisterNetworkCallback(cb)
-        }
+        connectUtil.ensureCellularValidated()
     }
 }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
index 71f7877..b1d64f8 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
@@ -23,6 +23,9 @@
 import android.content.IntentFilter
 import android.net.ConnectivityManager
 import android.net.Network
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
 import android.net.NetworkRequest
 import android.net.wifi.ScanResult
@@ -33,6 +36,7 @@
 import android.util.Log
 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
 import com.android.testutils.RecorderCallback.CallbackEntry
+import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.TimeUnit
 import kotlin.test.assertNotNull
@@ -56,13 +60,35 @@
     private val wifiManager = context.getSystemService(WifiManager::class.java)
             ?: fail("Could not find WifiManager")
 
-    fun ensureWifiConnected(): Network {
-        val callback = TestableNetworkCallback()
+    fun ensureWifiConnected(): Network = ensureWifiConnected(requireValidated = false)
+    fun ensureWifiValidated(): Network = ensureWifiConnected(requireValidated = true)
+
+    fun ensureCellularValidated(): Network {
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(
+            NetworkRequest.Builder()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_INTERNET).build(), cb)
+        return tryTest {
+            val errorMsg = "The device does not have mobile data available. Check that it is " +
+                    "setup with a SIM card that has a working data plan, that the APN " +
+                    "configuration is valid, and that the device can access the internet through " +
+                    "mobile data."
+            cb.eventuallyExpect<CapabilitiesChanged>(errorMsg) {
+                it.caps.hasCapability(NET_CAPABILITY_VALIDATED)
+            }.network
+        } cleanup {
+            cm.unregisterNetworkCallback(cb)
+        }
+    }
+
+    private fun ensureWifiConnected(requireValidated: Boolean): Network {
+        val callback = TestableNetworkCallback(timeoutMs = WIFI_CONNECT_TIMEOUT_MS)
         cm.registerNetworkCallback(NetworkRequest.Builder()
                 .addTransportType(TRANSPORT_WIFI)
                 .build(), callback)
 
-        try {
+        return tryTest {
             val connInfo = wifiManager.connectionInfo
             Log.d(TAG, "connInfo=" + connInfo)
             if (connInfo == null || connInfo.networkId == -1) {
@@ -73,12 +99,19 @@
                 val config = getOrCreateWifiConfiguration()
                 connectToWifiConfig(config)
             }
-            val cb = callback.poll(WIFI_CONNECT_TIMEOUT_MS) { it is CallbackEntry.Available }
-            assertNotNull(cb, "Could not connect to a wifi access point within " +
-                    "$WIFI_CONNECT_TIMEOUT_MS ms. Check that the test device has a wifi network " +
-                    "configured, and that the test access point is functioning properly.")
-            return cb.network
-        } finally {
+            val errorMsg = if (requireValidated) {
+                "The wifi access point did not have access to the internet after " +
+                        "$WIFI_CONNECT_TIMEOUT_MS ms. Check that it has a working connection."
+            } else {
+                "Could not connect to a wifi access point within $WIFI_CONNECT_TIMEOUT_MS ms. " +
+                        "Check that the test device has a wifi network configured, and that the " +
+                        "test access point is functioning properly."
+            }
+            val cb = callback.eventuallyExpect<CapabilitiesChanged>(errorMsg) {
+                (!requireValidated || it.caps.hasCapability(NET_CAPABILITY_VALIDATED))
+            }
+            cb.network
+        } cleanup {
             cm.unregisterNetworkCallback(callback)
         }
     }
@@ -201,3 +234,10 @@
         }
     }
 }
+
+private inline fun <reified T : CallbackEntry> TestableNetworkCallback.eventuallyExpect(
+    errorMsg: String,
+    crossinline predicate: (T) -> Boolean = { true }
+): T = history.poll(defaultTimeoutMs, mark) { it is T && predicate(it) }.also {
+    assertNotNull(it, errorMsg)
+} as T
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index 3a76cc2..59aefa5 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -2908,7 +2908,6 @@
 
     @AppModeFull(reason = "WRITE_DEVICE_CONFIG permission can't be granted to instant apps")
     @Test
-    @SkipMainlinePresubmit(reason = "Out of SLO flakiness")
     public void testRejectPartialConnectivity_TearDownNetwork() throws Exception {
         assumeTrue(TestUtils.shouldTestSApis());
         assumeTrue("testAcceptPartialConnectivity_validatedNetwork cannot execute"
diff --git a/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt b/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt
index bc13442..eef3f87 100644
--- a/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt
+++ b/tests/cts/net/src/android/net/cts/MdnsTestUtils.kt
@@ -233,46 +233,51 @@
     }
 }
 
+private fun getMdnsPayload(packet: ByteArray) = packet.copyOfRange(
+    ETHER_HEADER_LEN + IPV6_HEADER_LEN + UDP_HEADER_LEN, packet.size)
+
 fun TapPacketReader.pollForMdnsPacket(
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS,
     predicate: (TestDnsPacket) -> Boolean
-): ByteArray? {
+): TestDnsPacket? {
     val mdnsProbeFilter = IPv6UdpFilter(srcPort = MDNS_PORT, dstPort = MDNS_PORT).and {
-        val mdnsPayload = it.copyOfRange(
-            ETHER_HEADER_LEN + IPV6_HEADER_LEN + UDP_HEADER_LEN, it.size
-        )
+        val mdnsPayload = getMdnsPayload(it)
         try {
             predicate(TestDnsPacket(mdnsPayload))
         } catch (e: DnsPacket.ParseException) {
             false
         }
     }
-    return poll(timeoutMs, mdnsProbeFilter)
+    return poll(timeoutMs, mdnsProbeFilter)?.let { TestDnsPacket(getMdnsPayload(it)) }
 }
 
 fun TapPacketReader.pollForProbe(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) { it.isProbeFor("$serviceName.$serviceType.local") }
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) {
+    it.isProbeFor("$serviceName.$serviceType.local")
+}
 
 fun TapPacketReader.pollForAdvertisement(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) { it.isReplyFor("$serviceName.$serviceType.local") }
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) {
+    it.isReplyFor("$serviceName.$serviceType.local")
+}
 
 fun TapPacketReader.pollForQuery(
     recordName: String,
-    recordType: Int,
+    vararg requiredTypes: Int,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) { it.isQueryFor(recordName, recordType) }
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) { it.isQueryFor(recordName, *requiredTypes) }
 
 fun TapPacketReader.pollForReply(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
-): ByteArray? = pollForMdnsPacket(timeoutMs) {
+): TestDnsPacket? = pollForMdnsPacket(timeoutMs) {
     it.isReplyFor("$serviceName.$serviceType.local")
 }
 
@@ -289,7 +294,9 @@
         it.dName == name && it.nsType == DnsResolver.TYPE_SRV
     }
 
-    fun isQueryFor(name: String, type: Int): Boolean = mRecords[QDSECTION].any {
-        it.dName == name && it.nsType == type
+    fun isQueryFor(name: String, vararg requiredTypes: Int): Boolean = requiredTypes.all { type ->
+        mRecords[QDSECTION].any {
+            it.dName == name && it.nsType == type
+        }
     }
 }
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index 27bd5d3..9c44a3e 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -20,6 +20,7 @@
 import android.app.compat.CompatChanges
 import android.net.ConnectivityManager
 import android.net.ConnectivityManager.NetworkCallback
+import android.net.DnsResolver
 import android.net.InetAddresses.parseNumericAddress
 import android.net.LinkAddress
 import android.net.LinkProperties
@@ -87,6 +88,7 @@
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
 import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.assertEmpty
 import com.android.testutils.filters.CtsNetTestCasesMaxTargetSdk30
 import com.android.testutils.filters.CtsNetTestCasesMaxTargetSdk33
 import com.android.testutils.runAsShell
@@ -424,11 +426,7 @@
 
     @Test
     fun testNsdManager_DiscoverOnNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val registrationRecord = NsdRegistrationRecord()
         val registeredInfo = registerService(registrationRecord, si)
 
@@ -455,11 +453,7 @@
 
     @Test
     fun testNsdManager_DiscoverWithNetworkRequest() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val handler = Handler(handlerThread.looper)
         val executor = Executor { handler.post(it) }
 
@@ -524,11 +518,6 @@
 
     @Test
     fun testNsdManager_DiscoverWithNetworkRequest_NoMatchingNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
         val handler = Handler(handlerThread.looper)
         val executor = Executor { handler.post(it) }
 
@@ -568,11 +557,7 @@
 
     @Test
     fun testNsdManager_ResolveOnNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val registrationRecord = NsdRegistrationRecord()
         val registeredInfo = registerService(registrationRecord, si)
         tryTest {
@@ -610,12 +595,7 @@
 
     @Test
     fun testNsdManager_RegisterOnNetwork() {
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = this.serviceName
-        si.network = testNetwork1.network
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo(testNetwork1.network)
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
         registerService(registrationRecord, si)
@@ -889,11 +869,7 @@
 
     @Test
     fun testStopServiceResolution() {
-        val si = NsdServiceInfo()
-        si.serviceType = this@NsdManagerTest.serviceType
-        si.serviceName = this@NsdManagerTest.serviceName
-        si.port = 12345 // Test won't try to connect so port does not matter
-
+        val si = makeTestServiceInfo()
         val resolveRecord = NsdResolveRecord()
         // Try to resolve an unknown service then stop it immediately.
         // Expected ResolutionStopped callback.
@@ -911,12 +887,7 @@
         val addresses = lp.addresses
         assertFalse(addresses.isEmpty())
 
-        val si = NsdServiceInfo().apply {
-            serviceType = this@NsdManagerTest.serviceType
-            serviceName = this@NsdManagerTest.serviceName
-            network = testNetwork1.network
-            port = 12345 // Test won't try to connect so port does not matter
-        }
+        val si = makeTestServiceInfo(testNetwork1.network)
 
         // Register service on the network
         val registrationRecord = NsdRegistrationRecord()
@@ -1022,11 +993,7 @@
         // This test requires shims supporting T+ APIs (NsdServiceInfo.network)
         assumeTrue(TestUtils.shouldTestTApis())
 
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = serviceName
-        si.network = testNetwork1.network
-        si.port = 12345 // Test won't try to connect so port does not matter
+        val si = makeTestServiceInfo(testNetwork1.network)
 
         val packetReader = TapPacketReader(Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
@@ -1063,11 +1030,7 @@
         // This test requires shims supporting T+ APIs (NsdServiceInfo.network)
         assumeTrue(TestUtils.shouldTestTApis())
 
-        val si = NsdServiceInfo()
-        si.serviceType = serviceType
-        si.serviceName = serviceName
-        si.network = testNetwork1.network
-        si.port = 12345 // Test won't try to connect so port does not matter
+        val si = makeTestServiceInfo(testNetwork1.network)
 
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
@@ -1137,6 +1100,127 @@
         }
     }
 
+    // Test that even if only a PTR record is received as a reply when discovering, without the
+    // SRV, TXT, address records as recommended (but not mandated) by RFC 6763 12, the service can
+    // still be discovered.
+    @Test
+    fun testDiscoveryWithPtrOnlyResponse_ServiceIsFound() {
+        // Register service on testNetwork1
+        val discoveryRecord = NsdDiscoveryRecord()
+        val packetReader = TapPacketReader(Handler(handlerThread.looper),
+                testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
+        packetReader.startAsyncForTest()
+        handlerThread.waitForIdle(TIMEOUT_MS)
+
+        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD,
+                testNetwork1.network, { it.run() }, discoveryRecord)
+
+        tryTest {
+            discoveryRecord.expectCallback<DiscoveryStarted>()
+            assertNotNull(packetReader.pollForQuery("$serviceType.local", DnsResolver.TYPE_PTR))
+            /*
+            Generated with:
+            scapy.raw(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+                scapy.DNSRR(rrname='_nmt123456789._tcp.local', type='PTR', ttl=120,
+                rdata='NsdTest123456789._nmt123456789._tcp.local'))).hex()
+             */
+            val ptrResponsePayload = HexDump.hexStringToByteArray("0000840000000001000000000d5f6e" +
+                    "6d74313233343536373839045f746370056c6f63616c00000c000100000078002b104e736454" +
+                    "6573743132333435363738390d5f6e6d74313233343536373839045f746370056c6f63616c00")
+
+            replaceServiceNameAndTypeWithTestSuffix(ptrResponsePayload)
+            packetReader.sendResponse(buildMdnsPacket(ptrResponsePayload))
+
+            val serviceFound = discoveryRecord.expectCallback<ServiceFound>()
+            serviceFound.serviceInfo.let {
+                assertEquals(serviceName, it.serviceName)
+                // Discovered service types have a dot at the end
+                assertEquals("$serviceType.", it.serviceType)
+                assertEquals(testNetwork1.network, it.network)
+                // ServiceFound does not provide port, address or attributes (only information
+                // available in the PTR record is included in that callback, regardless of whether
+                // other records exist).
+                assertEquals(0, it.port)
+                assertEmpty(it.hostAddresses)
+                assertEquals(0, it.attributes.size)
+            }
+        } cleanup {
+            nsdManager.stopServiceDiscovery(discoveryRecord)
+            discoveryRecord.expectCallback<DiscoveryStopped>()
+        }
+    }
+
+    // Test RFC 6763 12. "Clients MUST be capable of functioning correctly with DNS servers [...]
+    // that fail to generate these additional records automatically, by issuing subsequent queries
+    // for any further record(s) they require"
+    @Test
+    fun testResolveWhenServerSendsNoAdditionalRecord() {
+        // Resolve service on testNetwork1
+        val resolveRecord = NsdResolveRecord()
+        val packetReader = TapPacketReader(Handler(handlerThread.looper),
+                testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
+        packetReader.startAsyncForTest()
+        handlerThread.waitForIdle(TIMEOUT_MS)
+
+        val si = makeTestServiceInfo(testNetwork1.network)
+        nsdManager.resolveService(si, { it.run() }, resolveRecord)
+
+        val serviceFullName = "$serviceName.$serviceType.local"
+        // The query should ask for ANY, since both SRV and TXT are requested. Note legacy
+        // mdnsresponder will ask for SRV and TXT separately, and will not proceed to asking for
+        // address records without an answer for both.
+        val srvTxtQuery = packetReader.pollForQuery(serviceFullName, DnsResolver.TYPE_ANY)
+        assertNotNull(srvTxtQuery)
+
+        /*
+        Generated with:
+        scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+            scapy.DNSRRSRV(rrname='NsdTest123456789._nmt123456789._tcp.local',
+                rclass=0x8001, port=31234, target='testhost.local', ttl=120) /
+            scapy.DNSRR(rrname='NsdTest123456789._nmt123456789._tcp.local', type='TXT', ttl=120,
+                rdata='testkey=testvalue')
+        ))).hex()
+         */
+        val srvTxtResponsePayload = HexDump.hexStringToByteArray("000084000000000200000000104" +
+                "e7364546573743132333435363738390d5f6e6d74313233343536373839045f746370056c6f6" +
+                "3616c0000218001000000780011000000007a020874657374686f7374c030c00c00100001000" +
+                "00078001211746573746b65793d7465737476616c7565")
+        replaceServiceNameAndTypeWithTestSuffix(srvTxtResponsePayload)
+        packetReader.sendResponse(buildMdnsPacket(srvTxtResponsePayload))
+
+        val testHostname = "testhost.local"
+        val addressQuery = packetReader.pollForQuery(testHostname,
+            DnsResolver.TYPE_A, DnsResolver.TYPE_AAAA)
+        assertNotNull(addressQuery)
+
+        /*
+        Generated with:
+        scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+            scapy.DNSRR(rrname='testhost.local', type='A', ttl=120,
+                rdata='192.0.2.123') /
+            scapy.DNSRR(rrname='testhost.local', type='AAAA', ttl=120,
+                rdata='2001:db8::123')
+        ))).hex()
+         */
+        val addressPayload = HexDump.hexStringToByteArray("0000840000000002000000000874657374" +
+                "686f7374056c6f63616c0000010001000000780004c000027bc00c001c000100000078001020" +
+                "010db8000000000000000000000123")
+        packetReader.sendResponse(buildMdnsPacket(addressPayload))
+
+        val serviceResolved = resolveRecord.expectCallback<ServiceResolved>()
+        serviceResolved.serviceInfo.let {
+            assertEquals(serviceName, it.serviceName)
+            assertEquals(".$serviceType", it.serviceType)
+            assertEquals(testNetwork1.network, it.network)
+            assertEquals(31234, it.port)
+            assertEquals(1, it.attributes.size)
+            assertArrayEquals("testvalue".encodeToByteArray(), it.attributes["testkey"])
+        }
+        assertEquals(
+                setOf(parseNumericAddress("192.0.2.123"), parseNumericAddress("2001:db8::123")),
+                serviceResolved.serviceInfo.hostAddresses.toSet())
+    }
+
     private fun buildConflictingAnnouncement(): ByteBuffer {
         /*
         Generated with:
@@ -1148,21 +1232,37 @@
         val mdnsPayload = HexDump.hexStringToByteArray("000084000000000100000000104e736454657" +
                 "3743132333435363738390d5f6e6d74313233343536373839045f746370056c6f63616c00002" +
                 "18001000000780016000000007a0208636f6e666c696374056c6f63616c00")
-        val packetBuffer = ByteBuffer.wrap(mdnsPayload)
-        // Replace service name and types in the packet with the random ones used in the test.
+        replaceServiceNameAndTypeWithTestSuffix(mdnsPayload)
+
+        return buildMdnsPacket(mdnsPayload)
+    }
+
+    /**
+     * Replaces occurrences of "NsdTest123456789" and "_nmt123456789" in mDNS payload with the
+     * actual random name and type that are used by the test.
+     */
+    private fun replaceServiceNameAndTypeWithTestSuffix(mdnsPayload: ByteArray) {
         // Test service name and types have consistent length and are always ASCII
         val testPacketName = "NsdTest123456789".encodeToByteArray()
         val testPacketTypePrefix = "_nmt123456789".encodeToByteArray()
         val encodedServiceName = serviceName.encodeToByteArray()
         val encodedTypePrefix = serviceType.split('.')[0].encodeToByteArray()
-        assertEquals(testPacketName.size, encodedServiceName.size)
-        assertEquals(testPacketTypePrefix.size, encodedTypePrefix.size)
-        packetBuffer.position(mdnsPayload.indexOf(testPacketName))
-        packetBuffer.put(encodedServiceName)
-        packetBuffer.position(mdnsPayload.indexOf(testPacketTypePrefix))
-        packetBuffer.put(encodedTypePrefix)
 
-        return buildMdnsPacket(mdnsPayload)
+        val packetBuffer = ByteBuffer.wrap(mdnsPayload)
+        replaceAll(packetBuffer, testPacketName, encodedServiceName)
+        replaceAll(packetBuffer, testPacketTypePrefix, encodedTypePrefix)
+    }
+
+    private tailrec fun replaceAll(buffer: ByteBuffer, source: ByteArray, replacement: ByteArray) {
+        assertEquals(source.size, replacement.size)
+        val index = buffer.array().indexOf(source)
+        if (index < 0) return
+
+        val origPosition = buffer.position()
+        buffer.position(index)
+        buffer.put(replacement)
+        buffer.position(origPosition)
+        replaceAll(buffer, source, replacement)
     }
 
     private fun buildMdnsPacket(mdnsPayload: ByteArray): ByteBuffer {
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
new file mode 100644
index 0000000..17a74f6
--- /dev/null
+++ b/thread/TEST_MAPPING
@@ -0,0 +1,9 @@
+{
+  // TODO (b/297729075): graduate this test to presubmit once it meets the SLO requirements.
+  // See go/test-mapping-slo-guide
+  "postsubmit": [
+    {
+      "name": "CtsThreadNetworkTestCases"
+    }
+  ]
+}
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
new file mode 100644
index 0000000..0219beb
--- /dev/null
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2023, 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.thread;
+
+/**
+* Interface for communicating with ThreadNetworkControllerService.
+* @hide
+*/
+interface IThreadNetworkController {
+    int getThreadVersion();
+}
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkManager.aidl b/thread/framework/java/android/net/thread/IThreadNetworkManager.aidl
new file mode 100644
index 0000000..0e394b1
--- /dev/null
+++ b/thread/framework/java/android/net/thread/IThreadNetworkManager.aidl
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2023, 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.thread;
+
+import android.net.thread.IThreadNetworkController;
+
+/**
+* Interface for communicating with ThreadNetworkService.
+* @hide
+*/
+interface IThreadNetworkManager {
+    List<IThreadNetworkController> getAllThreadNetworkControllers();
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
new file mode 100644
index 0000000..fe189c2
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2023 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.thread;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.RemoteException;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Provides the primary API for controlling all aspects of a Thread network.
+ *
+ * @hide
+ */
+@SystemApi
+public class ThreadNetworkController {
+
+    /** Thread standard version 1.3. */
+    public static final int THREAD_VERSION_1_3 = 4;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({THREAD_VERSION_1_3})
+    public @interface ThreadVersion {}
+
+    private final IThreadNetworkController mControllerService;
+
+    ThreadNetworkController(@NonNull IThreadNetworkController controllerService) {
+        requireNonNull(controllerService, "controllerService cannot be null");
+
+        mControllerService = controllerService;
+    }
+
+    /** Returns the Thread version this device is operating on. */
+    @ThreadVersion
+    public int getThreadVersion() {
+        try {
+            return mControllerService.getThreadVersion();
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
new file mode 100644
index 0000000..2a253a1
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2023 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.thread;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.annotation.SystemService;
+import android.content.Context;
+import android.os.RemoteException;
+
+import com.android.net.module.util.CollectionUtils;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Provides the primary API for managing app aspects of Thread network connectivity.
+ *
+ * @hide
+ */
+@SystemApi
+@SystemService(ThreadNetworkManager.SERVICE_NAME)
+public class ThreadNetworkManager {
+    /**
+     * This value tracks {@link Context#THREAD_NETWORK_SERVICE}.
+     *
+     * <p>This is needed because at the time this service is created, it needs to support both
+     * Android U and V but {@link Context#THREAD_NETWORK_SERVICE} Is only available on the V branch.
+     *
+     * <p>Note that this is not added to NetworkStack ConstantsShim because we need this constant in
+     * the framework library while ConstantsShim is only linked against the service library.
+     *
+     * @hide
+     */
+    public static final String SERVICE_NAME = "thread_network";
+
+    /**
+     * This value tracks {@link PackageManager#FEATURE_THREAD_NETWORK}.
+     *
+     * <p>This is needed because at the time this service is created, it needs to support both
+     * Android U and V but {@link PackageManager#FEATURE_THREAD_NETWORK} Is only available on the V
+     * branch.
+     *
+     * <p>Note that this is not added to NetworkStack COnstantsShim because we need this constant in
+     * the framework library while ConstantsShim is only linked against the service library.
+     *
+     * @hide
+     */
+    public static final String FEATURE_NAME = "android.hardware.thread_network";
+
+    @NonNull private final Context mContext;
+    @NonNull private final List<ThreadNetworkController> mUnmodifiableControllerServices;
+
+    /**
+     * Creates a new ThreadNetworkManager instance.
+     *
+     * @hide
+     */
+    public ThreadNetworkManager(
+            @NonNull Context context, @NonNull IThreadNetworkManager managerService) {
+        this(context, makeControllers(managerService));
+    }
+
+    private static List<ThreadNetworkController> makeControllers(
+            @NonNull IThreadNetworkManager managerService) {
+        requireNonNull(managerService, "managerService cannot be null");
+
+        List<IThreadNetworkController> controllerServices;
+
+        try {
+            controllerServices = managerService.getAllThreadNetworkControllers();
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+            return Collections.emptyList();
+        }
+
+        return CollectionUtils.map(controllerServices, ThreadNetworkController::new);
+    }
+
+    private ThreadNetworkManager(
+            @NonNull Context context, @NonNull List<ThreadNetworkController> controllerServices) {
+        mContext = context;
+        mUnmodifiableControllerServices = Collections.unmodifiableList(controllerServices);
+    }
+
+    /** Returns the {@link ThreadNetworkController} object of all Thread networks. */
+    @NonNull
+    public List<ThreadNetworkController> getAllThreadNetworkControllers() {
+        return mUnmodifiableControllerServices;
+    }
+}
diff --git a/thread/service/Android.bp b/thread/service/Android.bp
index fda206a..f1af653 100644
--- a/thread/service/Android.bp
+++ b/thread/service/Android.bp
@@ -32,5 +32,11 @@
     // (service-connectivity is only used on 31+) and use 31 here
     min_sdk_version: "30",
     srcs: [":service-thread-sources"],
+    libs: [
+        "framework-connectivity-t-pre-jarjar",
+    ],
+    static_libs: [
+        "net-utils-device-common",
+    ],
     apex_available: ["com.android.tethering"],
 }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
new file mode 100644
index 0000000..e8b95bc
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 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.thread;
+
+import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3;
+
+import android.net.thread.IThreadNetworkController;
+import android.net.thread.ThreadNetworkController;
+
+/** Implementation of the {@link ThreadNetworkController} API. */
+public final class ThreadNetworkControllerService extends IThreadNetworkController.Stub {
+
+    @Override
+    public int getThreadVersion() {
+        return THREAD_VERSION_1_3;
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkService.java b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
new file mode 100644
index 0000000..c6d47df
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2023 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.thread;
+
+import android.content.Context;
+import android.net.thread.IThreadNetworkController;
+import android.net.thread.IThreadNetworkManager;
+
+import com.android.server.SystemService;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Implementation of the Thread network service. This is the entry point of Android Thread feature.
+ */
+public class ThreadNetworkService extends IThreadNetworkManager.Stub {
+    private final ThreadNetworkControllerService mControllerService;
+
+    /** Creates a new {@link ThreadNetworkService} object. */
+    public ThreadNetworkService(Context context) {
+        this(context, new ThreadNetworkControllerService());
+    }
+
+    private ThreadNetworkService(
+            Context context, ThreadNetworkControllerService controllerService) {
+        mControllerService = controllerService;
+    }
+
+    /**
+     * Called by the service initializer.
+     *
+     * @see com.android.server.SystemService#onBootPhase
+     */
+    public void onBootPhase(int phase) {
+        if (phase == SystemService.PHASE_BOOT_COMPLETED) {
+            // TODO: initialize ThreadNetworkManagerService
+        }
+    }
+
+    @Override
+    public List<IThreadNetworkController> getAllThreadNetworkControllers() {
+        return Collections.singletonList(mControllerService);
+    }
+}
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
new file mode 100644
index 0000000..96056c6
--- /dev/null
+++ b/thread/tests/cts/Android.bp
@@ -0,0 +1,50 @@
+//
+// Copyright (C) 2023 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "CtsThreadNetworkTestCases",
+    defaults: ["cts_defaults"],
+    min_sdk_version: "33",
+    sdk_version: "test_current",
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTest.xml",
+    srcs: [
+        "src/**/*.java",
+    ],
+    test_suites: [
+        "cts",
+        "general-tests",
+        "mts-tethering",
+    ],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "compatibility-device-util-axt",
+        "ctstestrunner-axt",
+        "net-tests-utils",
+        "truth-prebuilt",
+    ],
+    libs: [
+        "android.test.base",
+        "android.test.runner",
+    ],
+    // Test coverage system runs on different devices. Need to
+    // compile for all architectures.
+    compile_multilib: "both",
+}
diff --git a/thread/tests/cts/AndroidManifest.xml b/thread/tests/cts/AndroidManifest.xml
new file mode 100644
index 0000000..4370fe3
--- /dev/null
+++ b/thread/tests/cts/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2023 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.
+ -->
+
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.net.thread.cts">
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.net.thread.cts"
+        android:label="CTS tests for android.net.thread" />
+</manifest>
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
new file mode 100644
index 0000000..5ba605f
--- /dev/null
+++ b/thread/tests/cts/AndroidTest.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2023 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.
+ -->
+
+<configuration description="Config for Thread network CTS test cases">
+    <option name="test-tag" value="CtsThreadNetworkTestCases" />
+    <option name="test-suite-tag" value="cts" />
+    <option name="config-descriptor:metadata" key="component" value="framework" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+
+    <!--
+        Only run tests if the device under test is SDK version 33 (Android 13) or above.
+        The Thread feature is only available on V+ and U+ TV devices but this test module
+        needs run on T+ because there are testcases which verifies that Thread service
+        is not support on T or T-.
+    -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk33ModuleController" />
+
+    <!-- Run tests in MTS only if the Tethering Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+
+    <!-- Install test -->
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="CtsThreadNetworkTestCases.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="android.net.thread.cts" />
+    </test>
+</configuration>
diff --git a/thread/tests/cts/OWNERS b/thread/tests/cts/OWNERS
new file mode 100644
index 0000000..6065bf8
--- /dev/null
+++ b/thread/tests/cts/OWNERS
@@ -0,0 +1,3 @@
+# Bug component: 1203089
+
+include platform/packages/modules/Connectivity:main:/thread/OWNERS
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
new file mode 100644
index 0000000..b3118f4
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2023 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.thread.cts;
+
+import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assume.assumeNotNull;
+
+import android.content.Context;
+import android.net.thread.ThreadNetworkController;
+import android.net.thread.ThreadNetworkManager;
+import android.os.Build;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.SmallTest;
+
+import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.DevSdkIgnoreRunner;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+/** CTS tests for {@link ThreadNetworkController}. */
+@SmallTest
+@RunWith(DevSdkIgnoreRunner.class)
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) // Thread is available on only U+
+public class ThreadNetworkControllerTest {
+    @Rule public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private ThreadNetworkManager mManager;
+
+    @Before
+    public void setUp() {
+        mManager = mContext.getSystemService(ThreadNetworkManager.class);
+
+        // TODO: we will also need it in tearDown(), it's better to have a Rule to skip
+        // tests if a feature is not available.
+        assumeNotNull(mManager);
+    }
+
+    private List<ThreadNetworkController> getAllControllers() {
+        return mManager.getAllThreadNetworkControllers();
+    }
+
+    @Test
+    public void getThreadVersion_returnsAtLeastThreadVersion1P3() {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            assertThat(controller.getThreadVersion()).isAtLeast(THREAD_VERSION_1_3);
+        }
+    }
+}
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
new file mode 100644
index 0000000..b6d0d31
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2023 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.thread.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeNotNull;
+import static org.junit.Assume.assumeTrue;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.net.thread.ThreadNetworkController;
+import android.net.thread.ThreadNetworkManager;
+import android.os.Build;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+/** Tests for {@link ThreadNetworkManager}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class ThreadNetworkManagerTest {
+    @Rule public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final PackageManager mPackageManager = mContext.getPackageManager();
+
+    private ThreadNetworkManager mManager;
+
+    @Before
+    public void setUp() {
+        mManager = mContext.getSystemService(ThreadNetworkManager.class);
+    }
+
+    @Test
+    @IgnoreAfter(Build.VERSION_CODES.TIRAMISU)
+    public void getManager_onTOrLower_returnsNull() {
+        assertThat(mManager).isNull();
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void getManager_hasThreadFeatureOnVOrHigher_returnsNonNull() {
+        assumeTrue(mPackageManager.hasSystemFeature("android.hardware.thread_network"));
+
+        assertThat(mManager).isNotNull();
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+    @IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void getManager_onUButNotTv_returnsNull() {
+        assumeFalse(mPackageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK));
+
+        assertThat(mManager).isNull();
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+    @IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public void getManager_onUAndTv_returnsNonNull() {
+        assumeTrue(mPackageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK));
+
+        assertThat(mManager).isNotNull();
+    }
+
+    @Test
+    public void getManager_noThreadFeature_returnsNull() {
+        assumeFalse(mPackageManager.hasSystemFeature("android.hardware.thread_network"));
+
+        assertThat(mManager).isNull();
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+    public void getAllThreadNetworkControllers_managerIsNotNull_returnsNotEmptyList() {
+        assumeNotNull(mManager);
+
+        List<ThreadNetworkController> controllers = mManager.getAllThreadNetworkControllers();
+
+        assertThat(controllers).isNotEmpty();
+    }
+}