diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index e030902637966630f9c4f08573153338abdfb053..246e5bcce2419b2a469f80176554ba264ab37dc0 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -44,6 +44,7 @@ import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.MacAddress;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetheredClient;
 import android.net.TetheringManager;
 import android.net.TetheringRequestParcel;
@@ -71,6 +72,7 @@ import com.android.internal.util.StateMachine;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetdUtils;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.InterfaceController;
 import com.android.net.module.util.ip.IpNeighborMonitor;
@@ -245,6 +247,11 @@ public class IpServer extends StateMachine {
     private final INetd mNetd;
     @NonNull
     private final BpfCoordinator mBpfCoordinator;
+    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
+    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
+    // must be able to find all classes at runtime.
+    @NonNull
+    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
     private final Callback mCallback;
     private final InterfaceController mInterfaceCtrl;
     private final PrivateAddressCoordinator mPrivateAddressCoordinator;
@@ -307,13 +314,15 @@ public class IpServer extends StateMachine {
     // object. It helps to reduce the arguments of the constructor.
     public IpServer(
             String ifaceName, Looper looper, int interfaceType, SharedLog log,
-            INetd netd, @NonNull BpfCoordinator coordinator, Callback callback,
+            INetd netd, @NonNull BpfCoordinator bpfCoordinator,
+            @Nullable LateSdk<RoutingCoordinatorManager> routingCoordinator, Callback callback,
             TetheringConfiguration config, PrivateAddressCoordinator addressCoordinator,
             TetheringMetrics tetheringMetrics, Dependencies deps) {
         super(ifaceName, looper);
         mLog = log.forSubComponent(ifaceName);
         mNetd = netd;
-        mBpfCoordinator = coordinator;
+        mBpfCoordinator = bpfCoordinator;
+        mRoutingCoordinator = routingCoordinator;
         mCallback = callback;
         mInterfaceCtrl = new InterfaceController(ifaceName, mNetd, mLog);
         mIfaceName = ifaceName;
@@ -807,23 +816,33 @@ public class IpServer extends StateMachine {
         for (RouteInfo route : toBeRemoved) mLinkProperties.removeRoute(route);
     }
 
-    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+    private void addInterfaceToNetwork(final int netId, @NonNull final String ifaceName) {
         try {
-            // It's safe to call networkAddInterface() even if
-            // the interface is already in the local_network.
-            mNetd.networkAddInterface(INetd.LOCAL_NET_ID, mIfaceName);
-            try {
-                // Add routes from local network. Note that adding routes that
-                // already exist does not cause an error (EEXIST is silently ignored).
-                NetdUtils.addRoutesToLocalNetwork(mNetd, mIfaceName, toBeAdded);
-            } catch (IllegalStateException e) {
-                mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
-                return;
+            if (null != mRoutingCoordinator.value) {
+                // TODO : remove this call in favor of using the LocalNetworkConfiguration
+                // correctly, which will let ConnectivityService do it automatically.
+                mRoutingCoordinator.value.addInterfaceToNetwork(netId, ifaceName);
+            } else {
+                mNetd.networkAddInterface(netId, ifaceName);
             }
         } catch (ServiceSpecificException | RemoteException e) {
             mLog.e("Failed to add " + mIfaceName + " to local table: ", e);
             return;
         }
+    }
+
+    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+        // It's safe to call addInterfaceToNetwork() even if
+        // the interface is already in the local_network.
+        addInterfaceToNetwork(INetd.LOCAL_NET_ID, mIfaceName);
+        try {
+            // Add routes from local network. Note that adding routes that
+            // already exist does not cause an error (EEXIST is silently ignored).
+            NetdUtils.addRoutesToLocalNetwork(mNetd, mIfaceName, toBeAdded);
+        } catch (IllegalStateException e) {
+            mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
+            return;
+        }
 
         for (RouteInfo route : toBeAdded) mLinkProperties.addRoute(route);
     }
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index b371178244d67f02aaddfdcfd6244954a87a63b3..f52bed9ce67185cd6ec12d5bc2f08df3587ab40f 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -90,6 +90,7 @@ import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherStatesParcel;
 import android.net.TetheredClient;
 import android.net.TetheringCallbackStartedParcel;
@@ -136,6 +137,7 @@ import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.NetdUtils;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
 import com.android.networkstack.apishim.common.BluetoothPanShim.TetheredInterfaceCallbackShim;
@@ -250,6 +252,10 @@ public class Tethering {
     private final Handler mHandler;
     private final INetd mNetd;
     private final NetdCallback mNetdCallback;
+    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
+    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
+    // must be able to find all classes at runtime.
+    @NonNull private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
     private final UserRestrictionActionListener mTetheringRestriction;
     private final ActiveDataSubIdListener mActiveDataSubIdListener;
     private final ConnectedClientsTracker mConnectedClientsTracker;
@@ -296,6 +302,7 @@ public class Tethering {
         mDeps = deps;
         mContext = mDeps.getContext();
         mNetd = mDeps.getINetd(mContext);
+        mRoutingCoordinator = mDeps.getRoutingCoordinator(mContext);
         mLooper = mDeps.getTetheringLooper();
         mNotificationUpdater = mDeps.getNotificationUpdater(mContext, mLooper);
         mTetheringMetrics = mDeps.getTetheringMetrics();
@@ -2835,8 +2842,9 @@ public class Tethering {
         mLog.i("adding IpServer for: " + iface);
         final TetherState tetherState = new TetherState(
                 new IpServer(iface, mLooper, interfaceType, mLog, mNetd, mBpfCoordinator,
-                             makeControlCallback(), mConfig, mPrivateAddressCoordinator,
-                             mTetheringMetrics, mDeps.getIpServerDependencies()), isNcm);
+                        mRoutingCoordinator, makeControlCallback(), mConfig,
+                        mPrivateAddressCoordinator, mTetheringMetrics,
+                        mDeps.getIpServerDependencies()), isNcm);
         mTetherStates.put(iface, tetherState);
         tetherState.ipServer.start();
     }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 741a5c519488759454b5fad2cedf2a7d186864d8..c6468a085fa0f272967076fb32e5376ae04f671a 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -16,11 +16,14 @@
 
 package com.android.networkstack.tethering;
 
+import android.annotation.Nullable;
 import android.app.usage.NetworkStatsManager;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothPan;
 import android.content.Context;
 import android.net.INetd;
+import android.net.RoutingCoordinatorManager;
+import android.net.connectivity.TiramisuConnectivityInternalApiUtil;
 import android.net.ip.IpServer;
 import android.os.Build;
 import android.os.Handler;
@@ -33,6 +36,8 @@ import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
 import com.android.internal.util.StateMachine;
+import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.BluetoothPanShimImpl;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -121,6 +126,16 @@ public abstract class TetheringDependencies {
                 (IBinder) context.getSystemService(Context.NETD_SERVICE));
     }
 
+    /**
+     * Get the routing coordinator, or null if below S.
+     */
+    @Nullable
+    public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(Context context) {
+        if (!SdkLevel.isAtLeastS()) return new LateSdk<>(null);
+        return new LateSdk<>(
+                TiramisuConnectivityInternalApiUtil.getRoutingCoordinatorManager(context));
+    }
+
     /**
      * Get a reference to the TetheringNotificationUpdater to be used by tethering.
      */
@@ -135,7 +150,7 @@ public abstract class TetheringDependencies {
     public abstract Looper getTetheringLooper();
 
     /**
-     *  Get Context of TetheringSerice.
+     *  Get Context of TetheringService.
      */
     public abstract Context getContext();
 
diff --git a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index d497a4dd6b85855b19f2ee8c06a7acb31c25763e..fc9928d6a7a29f610dcfc72fbd218ad412006069 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -62,6 +62,7 @@ import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.spy;
@@ -80,6 +81,7 @@ import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.MacAddress;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherOffloadRuleParcel;
 import android.net.TetherStatsParcel;
 import android.net.dhcp.DhcpServerCallbacks;
@@ -99,9 +101,11 @@ import androidx.annotation.Nullable;
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.BpfMap;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetworkStackConstants;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.Struct.S32;
 import com.android.net.module.util.bpf.Tether4Key;
@@ -193,6 +197,8 @@ public class IpServerTest {
     @Mock private IpNeighborMonitor mIpNeighborMonitor;
     @Mock private IpServer.Dependencies mDependencies;
     @Mock private PrivateAddressCoordinator mAddressCoordinator;
+    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinatorManager =
+            new LateSdk<>(SdkLevel.isAtLeastS() ? mock(RoutingCoordinatorManager.class) : null);
     @Mock private NetworkStatsManager mStatsManager;
     @Mock private TetheringConfiguration mTetherConfig;
     @Mock private ConntrackMonitor mConntrackMonitor;
@@ -249,7 +255,8 @@ public class IpServerTest {
         mBpfCoordinator = spy(new BpfCoordinator(mBpfDeps));
         mIpServer = new IpServer(
                 IFACE_NAME, mLooper.getLooper(), interfaceType, mSharedLog, mNetd, mBpfCoordinator,
-                mCallback, mTetherConfig, mAddressCoordinator, mTetheringMetrics, mDependencies);
+                mRoutingCoordinatorManager, mCallback, mTetherConfig, mAddressCoordinator,
+                mTetheringMetrics, mDependencies);
         mIpServer.start();
         mNeighborEventConsumer = neighborCaptor.getValue();
 
@@ -396,8 +403,8 @@ public class IpServerTest {
         when(mDependencies.getIpNeighborMonitor(any(), any(), any()))
                 .thenReturn(mIpNeighborMonitor);
         mIpServer = new IpServer(IFACE_NAME, mLooper.getLooper(), TETHERING_BLUETOOTH, mSharedLog,
-                mNetd, mBpfCoordinator, mCallback, mTetherConfig, mAddressCoordinator,
-                mTetheringMetrics, mDependencies);
+                mNetd, mBpfCoordinator, mRoutingCoordinatorManager, mCallback, mTetherConfig,
+                mAddressCoordinator, mTetheringMetrics, mDependencies);
         mIpServer.start();
         mLooper.dispatchAll();
         verify(mCallback).updateInterfaceState(
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
index 770507e186989ee47d1a5e28c27e552f541ecdcb..ba39f228c873cdce33cee141ad9b91cc8693a456 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -142,6 +142,7 @@ import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkRequest;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherStatesParcel;
 import android.net.TetheredClient;
 import android.net.TetheredClient.AddressInfo;
@@ -191,6 +192,7 @@ import com.android.internal.util.test.BroadcastInterceptingContext;
 import com.android.internal.util.test.FakeSettingsProvider;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.InterfaceParams;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.IpNeighborMonitor;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -482,6 +484,12 @@ public class TetheringTest {
             return mEntitleMgr;
         }
 
+        @Nullable
+        @Override
+        public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(final Context context) {
+            return new LateSdk<>(null);
+        }
+
         @Override
         public TetheringConfiguration generateTetheringConfiguration(Context ctx, SharedLog log,
                 int subId) {
diff --git a/framework/Android.bp b/framework/Android.bp
index 182c558b37efbb73a98b568c36a2801a96185802..02c678afe17802d275eb58730495741e9f79ee0b 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -292,17 +292,20 @@ java_genrule {
 // Library providing limited APIs within the connectivity module, so that R+ components like
 // Tethering have a controlled way to depend on newer components like framework-connectivity that
 // are not loaded on R.
+// Note that this target needs to have access to hidden classes, and as such needs to list
+// the full libraries instead of the .impl lib (which only expose API classes).
 java_library {
     name: "connectivity-internal-api-util",
     sdk_version: "module_current",
     libs: [
         "androidx.annotation_annotation",
-        "framework-connectivity.impl",
+        "framework-connectivity-pre-jarjar",
     ],
     jarjar_rules: ":framework-connectivity-jarjar-rules",
     srcs: [
-        // Files listed here MUST all be annotated with @RequiresApi(Build.VERSION_CODES.TIRAMISU),
-        // so that API checks are enforced for R+ users of this library
+        // Files listed here MUST all be annotated with @RequiresApi(Build.VERSION_CODES.S)
+        // or above as appropriate so that API checks are enforced for R+ users of this library
+        "src/android/net/RoutingCoordinatorManager.java",
         "src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java",
     ],
     visibility: [
diff --git a/framework/jarjar-excludes.txt b/framework/jarjar-excludes.txt
index 1ac5e8ec4a71b124ee2bc3b300e4761249fec491..bc3c8d1ccb8e87cbda7de30598eb9f5741b3c464 100644
--- a/framework/jarjar-excludes.txt
+++ b/framework/jarjar-excludes.txt
@@ -14,6 +14,15 @@ android\.net\.INetworkAgentRegistry(\$.+)?
 # TODO: move files to android.net.connectivity.visiblefortesting
 android\.net\.IConnectivityDiagnosticsCallback(\$.+)?
 
+# Classes used by tethering as a hidden API are compiled as a lib in target
+# connectivity-internal-api-util. Because it's used by tethering, it can't
+# be jarjared. Classes in android.net.connectivity are exempt from being
+# listed here because they are already in the target package and as such
+# are already not jarjared.
+# Because Tethering can be installed on R without Connectivity, any use
+# of these classes must be protected by a check for >= S SDK.
+# It's unlikely anybody else declares a hidden class with this nameƂ ?
+android\.net\.RoutingCoordinatorManager(\$.+)?
 
 # KeepaliveUtils is used by ConnectivityManager CTS
 # TODO: move into service-connectivity so framework-connectivity stops using
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 915c20da5fbbd20ad7dad96ee8884be6e7e1cc73..5cc196897c8f2ac6f60a5e1b86670a3a25e26125 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -29,6 +29,7 @@ import android.annotation.CallbackExecutor;
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.RequiresApi;
 import android.annotation.RequiresPermission;
 import android.annotation.SdkConstant;
 import android.annotation.SdkConstant.SdkConstantType;
@@ -6174,4 +6175,24 @@ public class ConnectivityManager {
             throw e.rethrowFromSystemServer();
         }
     }
+
+    private static final Object sRoutingCoordinatorManagerLock = new Object();
+    @GuardedBy("sRoutingCoordinatorManagerLock")
+    private static RoutingCoordinatorManager sRoutingCoordinatorManager = null;
+    /** @hide */
+    @RequiresApi(Build.VERSION_CODES.S)
+    public RoutingCoordinatorManager getRoutingCoordinatorManager() {
+        try {
+            synchronized (sRoutingCoordinatorManagerLock) {
+                if (null == sRoutingCoordinatorManager) {
+                    sRoutingCoordinatorManager = new RoutingCoordinatorManager(mContext,
+                            IRoutingCoordinator.Stub.asInterface(
+                                    mService.getRoutingCoordinatorService()));
+                }
+                return sRoutingCoordinatorManager;
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl
index fe277732b0da32f1d7e0ace6ca0b81dc27348dbd..e495448c7c90c4be3ed64530e245a41e405d42ef 100644
--- a/framework/src/android/net/IConnectivityManager.aidl
+++ b/framework/src/android/net/IConnectivityManager.aidl
@@ -259,4 +259,6 @@ interface IConnectivityManager
     void setVpnNetworkPreference(String session, in UidRange[] ranges);
 
     void setTestLowTcpPollingTimerForKeepalive(long timeMs);
+
+    IBinder getRoutingCoordinatorService();
 }
diff --git a/framework/src/android/net/IRoutingCoordinator.aidl b/framework/src/android/net/IRoutingCoordinator.aidl
new file mode 100644
index 0000000000000000000000000000000000000000..a5cda9874e1606337e02e693bcd8c3efa5ca3540
--- /dev/null
+++ b/framework/src/android/net/IRoutingCoordinator.aidl
@@ -0,0 +1,75 @@
+/*
+ * 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;
+
+import android.net.RouteInfo;
+
+/** @hide */
+interface IRoutingCoordinator {
+   /**
+    * Add a route for specific network
+    *
+    * @param netId the network to add the route to
+    * @param route the route to add
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void addRoute(int netId, in RouteInfo route);
+
+   /**
+    * Remove a route for specific network
+    *
+    * @param netId the network to remove the route from
+    * @param route the route to remove
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void removeRoute(int netId, in RouteInfo route);
+
+    /**
+    * Update a route for specific network
+    *
+    * @param netId the network to update the route for
+    * @param route parcelable with route information
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void updateRoute(int netId, in RouteInfo route);
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    void addInterfaceToNetwork(int netId, in String iface);
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+     void removeInterfaceFromNetwork(int netId, in String iface);
+}
diff --git a/framework/src/android/net/RouteInfo.java b/framework/src/android/net/RouteInfo.java
index df5f151a3f78a874fd39630bc92fd350fba1e587..e8ebf8137ace2502067198b033a615833136e628 100644
--- a/framework/src/android/net/RouteInfo.java
+++ b/framework/src/android/net/RouteInfo.java
@@ -584,7 +584,7 @@ public final class RouteInfo implements Parcelable {
             }
             RouteKey p = (RouteKey) o;
             // No need to do anything special for scoped addresses. Inet6Address#equals does not
-            // consider the scope ID, but the netd route IPCs (e.g., INetd#networkAddRouteParcel)
+            // consider the scope ID, but the route IPCs (e.g., RoutingCoordinatorManager#addRoute)
             // and the kernel ignore scoped addresses both in the prefix and in the nexthop and only
             // look at RTA_OIF.
             return Objects.equals(p.mDestination, mDestination)
diff --git a/framework/src/android/net/RoutingCoordinatorManager.java b/framework/src/android/net/RoutingCoordinatorManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..5576cb0f23316b3bef880d338cf5a3111c122e55
--- /dev/null
+++ b/framework/src/android/net/RoutingCoordinatorManager.java
@@ -0,0 +1,126 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+/**
+ * A manager class for talking to the routing coordinator service.
+ *
+ * This class should only be used by the connectivity and tethering module. This is enforced
+ * by the build rules. Do not change build rules to gain access to this class from elsewhere.
+ * @hide
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+public class RoutingCoordinatorManager {
+    @NonNull final Context mContext;
+    @NonNull final IRoutingCoordinator mService;
+
+    public RoutingCoordinatorManager(@NonNull final Context context,
+            @NonNull final IRoutingCoordinator service) {
+        mContext = context;
+        mService = service;
+    }
+
+    /**
+     * Add a route for specific network
+     *
+     * @param netId the network to add the route to
+     * @param route the route to add
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void addRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.addRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove a route for specific network
+     *
+     * @param netId the network to remove the route from
+     * @param route the route to remove
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void removeRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.removeRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Update a route for specific network
+     *
+     * @param netId the network to update the route for
+     * @param route parcelable with route information
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void updateRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.updateRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    public void addInterfaceToNetwork(final int netId, final String iface) {
+        try {
+            mService.addInterfaceToNetwork(netId, iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    public void removeInterfaceFromNetwork(final int netId, final String iface) {
+        try {
+            mService.removeInterfaceFromNetwork(netId, iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+}
diff --git a/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java b/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
index d65858f9ab7730b40e3389165a367cc4dba60220..c2d75d23242b48f1e7666957eb620794f017adab 100644
--- a/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
+++ b/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
@@ -18,6 +18,7 @@ package android.net.connectivity;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
+import android.net.RoutingCoordinatorManager;
 import android.os.Build;
 import android.os.IBinder;
 
@@ -34,15 +35,28 @@ import androidx.annotation.RequiresApi;
  * linter).
  * @hide
  */
-@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+// TODO : rename this so that it doesn't reference "Tiramisu" since it can be used in S.
+@RequiresApi(Build.VERSION_CODES.S)
 public class TiramisuConnectivityInternalApiUtil {
 
     /**
      * Get a service binder token for
      * {@link com.android.server.connectivity.wear.CompanionDeviceManagerProxyService}.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public static IBinder getCompanionDeviceManagerProxyService(Context ctx) {
         final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
         return cm.getCompanionDeviceManagerProxyService();
     }
+
+    /**
+     * Obtain a routing coordinator manager from a context, possibly cross-module.
+     * @param ctx the context
+     * @return an instance of the coordinator manager
+     */
+    @RequiresApi(Build.VERSION_CODES.S)
+    public static RoutingCoordinatorManager getRoutingCoordinatorManager(Context ctx) {
+        final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
+        return cm.getRoutingCoordinatorManager();
+    }
 }
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 2797b47e317bd2955584eb13a7f1b326f0df4f16..12f105d6bffc8f33cc02396e005b0935e3c8f57c 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -199,7 +199,6 @@ import android.net.QosFilter;
 import android.net.QosSocketFilter;
 import android.net.QosSocketInfo;
 import android.net.RouteInfo;
-import android.net.RouteInfoParcel;
 import android.net.SocketKeepalive;
 import android.net.TetheringManager;
 import android.net.TransportInfo;
@@ -330,6 +329,7 @@ import com.android.server.connectivity.PermissionMonitor;
 import com.android.server.connectivity.ProfileNetworkPreferenceInfo;
 import com.android.server.connectivity.ProxyTracker;
 import com.android.server.connectivity.QosCallbackTracker;
+import com.android.server.connectivity.RoutingCoordinatorService;
 import com.android.server.connectivity.UidRangeUtils;
 import com.android.server.connectivity.VpnNetworkPreferenceInfo;
 import com.android.server.connectivity.wear.CompanionDeviceManagerProxyService;
@@ -493,6 +493,7 @@ public class ConnectivityService extends IConnectivityManager.Stub
     @GuardedBy("mTNSLock")
     private TestNetworkService mTNS;
     private final CompanionDeviceManagerProxyService mCdmps;
+    private final RoutingCoordinatorService mRoutingCoordinatorService;
 
     private final Object mTNSLock = new Object();
 
@@ -1826,6 +1827,8 @@ public class ConnectivityService extends IConnectivityManager.Stub
             mCdmps = null;
         }
 
+        mRoutingCoordinatorService = new RoutingCoordinatorService(netd);
+
         mDestroyFrozenSockets = mDeps.isAtLeastU()
                 && mDeps.isFeatureEnabled(context, KEY_DESTROY_FROZEN_SOCKETS_VERSION);
         mDelayDestroyFrozenSockets = mDeps.isAtLeastU()
@@ -8515,7 +8518,7 @@ public class ConnectivityService extends IConnectivityManager.Stub
             for (final String iface : interfaceDiff.added) {
                 try {
                     if (DBG) log("Adding iface " + iface + " to network " + netId);
-                    mNetd.networkAddInterface(netId, iface);
+                    mRoutingCoordinatorService.addInterfaceToNetwork(netId, iface);
                     wakeupModifyInterface(iface, nai, true);
                     mDeps.reportNetworkInterfaceForTransports(mContext, iface,
                             nai.networkCapabilities.getTransportTypes());
@@ -8528,45 +8531,13 @@ public class ConnectivityService extends IConnectivityManager.Stub
             try {
                 if (DBG) log("Removing iface " + iface + " from network " + netId);
                 wakeupModifyInterface(iface, nai, false);
-                mNetd.networkRemoveInterface(netId, iface);
+                mRoutingCoordinatorService.removeInterfaceFromNetwork(netId, iface);
             } catch (Exception e) {
                 loge("Exception removing interface: " + e);
             }
         }
     }
 
-    // TODO: move to frameworks/libs/net.
-    private RouteInfoParcel convertRouteInfo(RouteInfo route) {
-        final String nextHop;
-
-        switch (route.getType()) {
-            case RouteInfo.RTN_UNICAST:
-                if (route.hasGateway()) {
-                    nextHop = route.getGateway().getHostAddress();
-                } else {
-                    nextHop = INetd.NEXTHOP_NONE;
-                }
-                break;
-            case RouteInfo.RTN_UNREACHABLE:
-                nextHop = INetd.NEXTHOP_UNREACHABLE;
-                break;
-            case RouteInfo.RTN_THROW:
-                nextHop = INetd.NEXTHOP_THROW;
-                break;
-            default:
-                nextHop = INetd.NEXTHOP_NONE;
-                break;
-        }
-
-        final RouteInfoParcel rip = new RouteInfoParcel();
-        rip.ifName = route.getInterface();
-        rip.destination = route.getDestination().toString();
-        rip.nextHop = nextHop;
-        rip.mtu = route.getMtu();
-
-        return rip;
-    }
-
     /**
      * Have netd update routes from oldLp to newLp.
      * @return true if routes changed between oldLp and newLp
@@ -8587,10 +8558,10 @@ public class ConnectivityService extends IConnectivityManager.Stub
             if (route.hasGateway()) continue;
             if (VDBG || DDBG) log("Adding Route [" + route + "] to network " + netId);
             try {
-                mNetd.networkAddRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.addRoute(netId, route);
             } catch (Exception e) {
                 if ((route.getDestination().getAddress() instanceof Inet4Address) || VDBG) {
-                    loge("Exception in networkAddRouteParcel for non-gateway: " + e);
+                    loge("Exception in addRoute for non-gateway: " + e);
                 }
             }
         }
@@ -8598,10 +8569,10 @@ public class ConnectivityService extends IConnectivityManager.Stub
             if (!route.hasGateway()) continue;
             if (VDBG || DDBG) log("Adding Route [" + route + "] to network " + netId);
             try {
-                mNetd.networkAddRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.addRoute(netId, route);
             } catch (Exception e) {
                 if ((route.getGateway() instanceof Inet4Address) || VDBG) {
-                    loge("Exception in networkAddRouteParcel for gateway: " + e);
+                    loge("Exception in addRoute for gateway: " + e);
                 }
             }
         }
@@ -8609,18 +8580,18 @@ public class ConnectivityService extends IConnectivityManager.Stub
         for (RouteInfo route : routeDiff.removed) {
             if (VDBG || DDBG) log("Removing Route [" + route + "] from network " + netId);
             try {
-                mNetd.networkRemoveRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.removeRoute(netId, route);
             } catch (Exception e) {
-                loge("Exception in networkRemoveRouteParcel: " + e);
+                loge("Exception in removeRoute: " + e);
             }
         }
 
         for (RouteInfo route : routeDiff.updated) {
             if (VDBG || DDBG) log("Updating Route [" + route + "] from network " + netId);
             try {
-                mNetd.networkUpdateRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.updateRoute(netId, route);
             } catch (Exception e) {
-                loge("Exception in networkUpdateRouteParcel: " + e);
+                loge("Exception in updateRoute: " + e);
             }
         }
         return !routeDiff.added.isEmpty() || !routeDiff.removed.isEmpty()
@@ -10261,7 +10232,7 @@ public class ConnectivityService extends IConnectivityManager.Stub
             // If a rate limit has been configured and is applicable to this network (network
             // provides internet connectivity), apply it. The tc police filter cannot be attached
             // before the clsact qdisc is added which happens as part of updateLinkProperties ->
-            // updateInterfaces -> INetd#networkAddInterface.
+            // updateInterfaces -> RoutingCoordinatorManager#addInterfaceToNetwork
             // Note: in case of a system server crash, the NetworkController constructor in netd
             // (called when netd starts up) deletes the clsact qdisc of all interfaces.
             if (canNetworkBeRateLimited(networkAgent) && mIngressRateLimit >= 0) {
@@ -12740,4 +12711,10 @@ public class ConnectivityService extends IConnectivityManager.Stub
         enforceNetworkStackPermission(mContext);
         return mCdmps;
     }
+
+    @Override
+    public IBinder getRoutingCoordinatorService() {
+        enforceNetworkStackPermission(mContext);
+        return mRoutingCoordinatorService;
+    }
 }
diff --git a/service/src/com/android/server/connectivity/RoutingCoordinatorService.java b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
new file mode 100644
index 0000000000000000000000000000000000000000..50e84d4d220209efc19ba63066afe6c6f2bb3b4d
--- /dev/null
+++ b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
@@ -0,0 +1,118 @@
+/*
+ * 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.connectivity;
+
+import static com.android.net.module.util.NetdUtils.toRouteInfoParcel;
+
+import android.annotation.NonNull;
+import android.content.Context;
+import android.net.INetd;
+import android.net.IRoutingCoordinator;
+import android.net.RouteInfo;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+
+/**
+ * Class to coordinate routing across multiple clients.
+ *
+ * At present this is just a wrapper for netd methods, but it will be used to host some more
+ * coordination logic in the near future. It can be used to pull up some of the routing logic
+ * from netd into Java land.
+ *
+ * Note that usage of this class is not thread-safe. Clients are responsible for their own
+ * synchronization.
+ */
+public class RoutingCoordinatorService extends IRoutingCoordinator.Stub {
+    private final INetd mNetd;
+
+    public RoutingCoordinatorService(@NonNull INetd netd) {
+        mNetd = netd;
+    }
+
+    /**
+     * Add a route for specific network
+     *
+     * @param netId the network to add the route to
+     * @param route the route to add
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void addRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkAddRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Remove a route for specific network
+     *
+     * @param netId the network to remove the route from
+     * @param route the route to remove
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void removeRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkRemoveRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Update a route for specific network
+     *
+     * @param netId the network to update the route for
+     * @param route parcelable with route information
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void updateRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkUpdateRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    @Override
+    public void addInterfaceToNetwork(final int netId, final String iface)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkAddInterface(netId, iface);
+    }
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    @Override
+    public void removeInterfaceFromNetwork(final int netId, final String iface)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkRemoveInterface(netId, iface);
+    }
+}
diff --git a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
index ea18d3794e68def6426f325d42ca6732df4ffd6f..d99eedcfa0731a3722d474ed4fd90419ab5ce03e 100644
--- a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
+++ b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
@@ -28,6 +28,7 @@ import android.net.INetd;
 import android.net.InterfaceConfigurationParcel;
 import android.net.IpPrefix;
 import android.net.RouteInfo;
+import android.net.RouteInfoParcel;
 import android.net.TetherConfigParcel;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -278,4 +279,38 @@ public class NetdUtils {
             throw new IllegalStateException(e);
         }
     }
+
+    /**
+     * Convert a RouteInfo into a RouteInfoParcel.
+     */
+    public static RouteInfoParcel toRouteInfoParcel(RouteInfo route) {
+        final String nextHop;
+
+        switch (route.getType()) {
+            case RouteInfo.RTN_UNICAST:
+                if (route.hasGateway()) {
+                    nextHop = route.getGateway().getHostAddress();
+                } else {
+                    nextHop = INetd.NEXTHOP_NONE;
+                }
+                break;
+            case RouteInfo.RTN_UNREACHABLE:
+                nextHop = INetd.NEXTHOP_UNREACHABLE;
+                break;
+            case RouteInfo.RTN_THROW:
+                nextHop = INetd.NEXTHOP_THROW;
+                break;
+            default:
+                nextHop = INetd.NEXTHOP_NONE;
+                break;
+        }
+
+        final RouteInfoParcel rip = new RouteInfoParcel();
+        rip.ifName = route.getInterface();
+        rip.destination = route.getDestination().toString();
+        rip.nextHop = nextHop;
+        rip.mtu = route.getMtu();
+
+        return rip;
+    }
 }
diff --git a/staticlibs/framework/com/android/net/module/util/SdkUtil.java b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
new file mode 100644
index 0000000000000000000000000000000000000000..5006ba989ad7c3398e4a028cf22fe1914a54ca0f
--- /dev/null
+++ b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
@@ -0,0 +1,49 @@
+/*
+ * 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.net.module.util;
+
+import android.annotation.Nullable;
+
+/**
+ * Utilities to deal with multiple SDKs in a single mainline module.
+ * @hide
+ */
+public class SdkUtil {
+    /**
+     * Holder class taking advantage of erasure to avoid reflection running into class not found
+     * exceptions.
+     *
+     * This is useful to store a reference to a class that might not be present at runtime when
+     * fields are examined through reflection. An example is the MessageUtils class, which tries
+     * to get all fields in a class and therefore will try to load any class for which there
+     * is a member. Another example would be arguments or return values of methods in tests,
+     * when the testing framework uses reflection to list methods and their arguments.
+     *
+     * In these cases, LateSdk<T> can be used to hide type T from reflection, since it's erased
+     * and it becomes a vanilla LateSdk in Java bytecode. The T still can't be instantiated at
+     * runtime of course, but runtime tests will avoid that.
+     *
+     * @param <T> The held type
+     * @hide
+     */
+    public static class LateSdk<T> {
+        @Nullable public final T value;
+        public LateSdk(@Nullable final T value) {
+            this.value = value;
+        }
+    }
+}