Add LocalNetworkConfig

This doesn't do anything at this patchset, but the whole
pipe from the agent to ConnectivityService is built.

LocalNetworkInfo will be the name of the public information
sent in callbacks to clients.

Test: CSLocalAgentTests
Change-Id: I70e133031ef3b0aaf6c3e59ccc2ad895c66d339c
diff --git a/framework/aidl-export/android/net/LocalNetworkConfig.aidl b/framework/aidl-export/android/net/LocalNetworkConfig.aidl
new file mode 100644
index 0000000..e2829a5
--- /dev/null
+++ b/framework/aidl-export/android/net/LocalNetworkConfig.aidl
@@ -0,0 +1,20 @@
+/**
+ *
+ * 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;
+
+@JavaOnlyStableParcelable parcelable LocalNetworkConfig;
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 2315521..915c20d 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -3811,11 +3811,28 @@
     @RequiresPermission(anyOf = {
             NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
             android.Manifest.permission.NETWORK_FACTORY})
-    public Network registerNetworkAgent(INetworkAgent na, NetworkInfo ni, LinkProperties lp,
-            NetworkCapabilities nc, @NonNull NetworkScore score, NetworkAgentConfig config,
-            int providerId) {
+    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+            @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @NonNull NetworkScore score, @NonNull NetworkAgentConfig config, int providerId) {
+        return registerNetworkAgent(na, ni, lp, nc, null /* localNetworkConfig */, score, config,
+                providerId);
+    }
+
+    /**
+     * @hide
+     * Register a NetworkAgent with ConnectivityService.
+     * @return Network corresponding to NetworkAgent.
+     */
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_FACTORY})
+    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+            @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, int providerId) {
         try {
-            return mService.registerNetworkAgent(na, ni, lp, nc, score, config, providerId);
+            return mService.registerNetworkAgent(na, ni, lp, nc, score, localNetworkConfig, config,
+                    providerId);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl
index ebe8bca..fe27773 100644
--- a/framework/src/android/net/IConnectivityManager.aidl
+++ b/framework/src/android/net/IConnectivityManager.aidl
@@ -27,6 +27,7 @@
 import android.net.IQosCallback;
 import android.net.ISocketKeepaliveCallback;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.Network;
 import android.net.NetworkAgentConfig;
 import android.net.NetworkCapabilities;
@@ -146,7 +147,8 @@
     void declareNetworkRequestUnfulfillable(in NetworkRequest request);
 
     Network registerNetworkAgent(in INetworkAgent na, in NetworkInfo ni, in LinkProperties lp,
-            in NetworkCapabilities nc, in NetworkScore score, in NetworkAgentConfig config,
+            in NetworkCapabilities nc, in NetworkScore score,
+            in LocalNetworkConfig localNetworkConfig, in NetworkAgentConfig config,
             in int factorySerialNumber);
 
     NetworkRequest requestNetwork(int uid, in NetworkCapabilities networkCapabilities, int reqType,
diff --git a/framework/src/android/net/INetworkAgentRegistry.aidl b/framework/src/android/net/INetworkAgentRegistry.aidl
index b375b7b..61b27b5 100644
--- a/framework/src/android/net/INetworkAgentRegistry.aidl
+++ b/framework/src/android/net/INetworkAgentRegistry.aidl
@@ -17,6 +17,7 @@
 
 import android.net.DscpPolicy;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkInfo;
@@ -34,6 +35,7 @@
     void sendLinkProperties(in LinkProperties lp);
     // TODO: consider replacing this by "markConnected()" and removing
     void sendNetworkInfo(in NetworkInfo info);
+    void sendLocalNetworkConfig(in LocalNetworkConfig config);
     void sendScore(in NetworkScore score);
     void sendExplicitlySelected(boolean explicitlySelected, boolean acceptPartial);
     void sendSocketKeepaliveEvent(int slot, int reason);
diff --git a/framework/src/android/net/LocalNetworkConfig.java b/framework/src/android/net/LocalNetworkConfig.java
new file mode 100644
index 0000000..fca7fd1
--- /dev/null
+++ b/framework/src/android/net/LocalNetworkConfig.java
@@ -0,0 +1,168 @@
+/*
+ * 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.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * A class to communicate configuration info about a local network through {@link NetworkAgent}.
+ * @hide
+ */
+// TODO : @SystemApi
+public final class LocalNetworkConfig implements Parcelable {
+    @Nullable
+    private final NetworkRequest mUpstreamSelector;
+
+    @NonNull
+    private final MulticastRoutingConfig mUpstreamMulticastRoutingConfig;
+
+    @NonNull
+    private final MulticastRoutingConfig mDownstreamMulticastRoutingConfig;
+
+    private LocalNetworkConfig(@Nullable final NetworkRequest upstreamSelector,
+            @Nullable final MulticastRoutingConfig upstreamConfig,
+            @Nullable final MulticastRoutingConfig downstreamConfig) {
+        mUpstreamSelector = upstreamSelector;
+        if (null != upstreamConfig) {
+            mUpstreamMulticastRoutingConfig = upstreamConfig;
+        } else {
+            mUpstreamMulticastRoutingConfig = MulticastRoutingConfig.CONFIG_FORWARD_NONE;
+        }
+        if (null != downstreamConfig) {
+            mDownstreamMulticastRoutingConfig = downstreamConfig;
+        } else {
+            mDownstreamMulticastRoutingConfig = MulticastRoutingConfig.CONFIG_FORWARD_NONE;
+        }
+    }
+
+    /**
+     * Get the request choosing which network traffic from this network is forwarded to and from.
+     *
+     * This may be null if the local network doesn't forward the traffic anywhere.
+     */
+    @Nullable
+    public NetworkRequest getUpstreamSelector() {
+        return mUpstreamSelector;
+    }
+
+    public @NonNull MulticastRoutingConfig getUpstreamMulticastRoutingConfig() {
+        return mUpstreamMulticastRoutingConfig;
+    }
+
+    public @NonNull MulticastRoutingConfig getDownstreamMulticastRoutingConfig() {
+        return mDownstreamMulticastRoutingConfig;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+        dest.writeParcelable(mUpstreamSelector, flags);
+        dest.writeParcelable(mUpstreamMulticastRoutingConfig, flags);
+        dest.writeParcelable(mDownstreamMulticastRoutingConfig, flags);
+    }
+
+    public static final @NonNull Creator<LocalNetworkConfig> CREATOR = new Creator<>() {
+        public LocalNetworkConfig createFromParcel(Parcel in) {
+            final NetworkRequest upstreamSelector = in.readParcelable(null);
+            final MulticastRoutingConfig upstreamConfig = in.readParcelable(null);
+            final MulticastRoutingConfig downstreamConfig = in.readParcelable(null);
+            return new LocalNetworkConfig(
+                    upstreamSelector, upstreamConfig, downstreamConfig);
+        }
+
+        @Override
+        public LocalNetworkConfig[] newArray(final int size) {
+            return new LocalNetworkConfig[size];
+        }
+    };
+
+
+    public static final class Builder {
+        @Nullable
+        NetworkRequest mUpstreamSelector;
+
+        @Nullable
+        MulticastRoutingConfig mUpstreamMulticastRoutingConfig;
+
+        @Nullable
+        MulticastRoutingConfig mDownstreamMulticastRoutingConfig;
+
+        /**
+         * Create a Builder
+         */
+        public Builder() {
+        }
+
+        /**
+         * Set to choose where this local network should forward its traffic to.
+         *
+         * The system will automatically choose the best network matching the request as an
+         * upstream, and set up forwarding between this local network and the chosen upstream.
+         * If no network matches the request, there is no upstream and the traffic is not forwarded.
+         * The caller can know when this changes by listening to link properties changes of
+         * this network with the {@link android.net.LinkProperties#getForwardedNetwork()} getter.
+         *
+         * Set this to null if the local network shouldn't be forwarded. Default is null.
+         */
+        @NonNull
+        public Builder setUpstreamSelector(@Nullable NetworkRequest upstreamSelector) {
+            mUpstreamSelector = upstreamSelector;
+            return this;
+        }
+
+        /**
+         * Set the upstream multicast routing config.
+         *
+         * If null, don't route multicast packets upstream. This is equivalent to a
+         * MulticastRoutingConfig in mode FORWARD_NONE. The default is null.
+         */
+        @NonNull
+        public Builder setUpstreamMulticastRoutingConfig(@Nullable MulticastRoutingConfig cfg) {
+            mUpstreamMulticastRoutingConfig = cfg;
+            return this;
+        }
+
+        /**
+         * Set the downstream multicast routing config.
+         *
+         * If null, don't route multicast packets downstream. This is equivalent to a
+         * MulticastRoutingConfig in mode FORWARD_NONE. The default is null.
+         */
+        @NonNull
+        public Builder setDownstreamMulticastRoutingConfig(@Nullable MulticastRoutingConfig cfg) {
+            mDownstreamMulticastRoutingConfig = cfg;
+            return this;
+        }
+
+        /**
+         * Build the LocalNetworkConfig object.
+         */
+        @NonNull
+        public LocalNetworkConfig build() {
+            return new LocalNetworkConfig(mUpstreamSelector,
+                    mUpstreamMulticastRoutingConfig,
+                    mDownstreamMulticastRoutingConfig);
+        }
+    }
+}
diff --git a/framework/src/android/net/MulticastRoutingConfig.java b/framework/src/android/net/MulticastRoutingConfig.java
new file mode 100644
index 0000000..ebd9fc5
--- /dev/null
+++ b/framework/src/android/net/MulticastRoutingConfig.java
@@ -0,0 +1,264 @@
+/*
+ * 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.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.ArraySet;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.Inet6Address;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * A class representing a configuration for multicast routing.
+ *
+ * Internal usage to Connectivity
+ * @hide
+ */
+// TODO : @SystemApi
+public class MulticastRoutingConfig implements Parcelable {
+    private static final String TAG = MulticastRoutingConfig.class.getSimpleName();
+
+    /** Do not forward any multicast packets. */
+    public static final int FORWARD_NONE = 0;
+    /**
+     * Forward only multicast packets with destination in the list of listening addresses.
+     * Ignore the min scope.
+     */
+    public static final int FORWARD_SELECTED = 1;
+    /**
+     * Forward all multicast packets with scope greater or equal than the min scope.
+     * Ignore the list of listening addresses.
+     */
+    public static final int FORWARD_WITH_MIN_SCOPE = 2;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "FORWARD_" }, value = {
+            FORWARD_NONE,
+            FORWARD_SELECTED,
+            FORWARD_WITH_MIN_SCOPE
+    })
+    public @interface MulticastForwardingMode {}
+
+    /**
+     * Not a multicast scope, for configurations that do not use the min scope.
+     */
+    public static final int MULTICAST_SCOPE_NONE = -1;
+
+    public static final MulticastRoutingConfig CONFIG_FORWARD_NONE =
+            new MulticastRoutingConfig(FORWARD_NONE, MULTICAST_SCOPE_NONE, null);
+
+    @MulticastForwardingMode
+    private final int mForwardingMode;
+
+    private final int mMinScope;
+
+    @NonNull
+    private final Set<Inet6Address> mListeningAddresses;
+
+    private MulticastRoutingConfig(@MulticastForwardingMode final int mode, final int scope,
+            @Nullable final Set<Inet6Address> addresses) {
+        mForwardingMode = mode;
+        mMinScope = scope;
+        if (null != addresses) {
+            mListeningAddresses = Collections.unmodifiableSet(new ArraySet<>(addresses));
+        } else {
+            mListeningAddresses = Collections.emptySet();
+        }
+    }
+
+    /**
+     * Returns the forwarding mode.
+     */
+    @MulticastForwardingMode
+    public int getForwardingMode() {
+        return mForwardingMode;
+    }
+
+    /**
+     * Returns the minimal group address scope that is allowed for forwarding.
+     * If the forwarding mode is not FORWARD_WITH_MIN_SCOPE, will be MULTICAST_SCOPE_NONE.
+     */
+    public int getMinScope() {
+        return mMinScope;
+    }
+
+    /**
+     * Returns the list of group addresses listened by the outgoing interface.
+     * The list will be empty if the forwarding mode is not FORWARD_SELECTED.
+     */
+    @NonNull
+    public Set<Inet6Address> getMulticastListeningAddresses() {
+        return mListeningAddresses;
+    }
+
+    private MulticastRoutingConfig(Parcel in) {
+        mForwardingMode = in.readInt();
+        mMinScope = in.readInt();
+        final int count = in.readInt();
+        final ArraySet<Inet6Address> listeningAddresses = new ArraySet<>(count);
+        final byte[] buffer = new byte[16]; // Size of an Inet6Address
+        for (int i = 0; i < count; ++i) {
+            in.readByteArray(buffer);
+            try {
+                listeningAddresses.add((Inet6Address) Inet6Address.getByAddress(buffer));
+            } catch (UnknownHostException e) {
+                Log.wtf(TAG, "Can't read inet6address : " + Arrays.toString(buffer));
+            }
+        }
+        mListeningAddresses = Collections.unmodifiableSet(listeningAddresses);
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(mForwardingMode);
+        dest.writeInt(mMinScope);
+        dest.writeInt(mListeningAddresses.size());
+        for (final Inet6Address addr : mListeningAddresses) {
+            dest.writeByteArray(addr.getAddress());
+        }
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final Creator<MulticastRoutingConfig> CREATOR = new Creator<>() {
+        @Override
+        public MulticastRoutingConfig createFromParcel(Parcel in) {
+            return new MulticastRoutingConfig(in);
+        }
+
+        @Override
+        public MulticastRoutingConfig[] newArray(int size) {
+            return new MulticastRoutingConfig[size];
+        }
+    };
+
+    public static class Builder {
+        @MulticastForwardingMode
+        private final int mForwardingMode;
+        private int mMinScope;
+        private final ArraySet<Inet6Address> mListeningAddresses;
+
+        private Builder(@MulticastForwardingMode final int mode, int scope) {
+            mForwardingMode = mode;
+            mMinScope = scope;
+            mListeningAddresses = new ArraySet<>();
+        }
+
+        /**
+         * Create a builder that forwards nothing.
+         * No properties can be set on such a builder.
+         */
+        public static Builder newBuilderForwardingNone() {
+            return new Builder(FORWARD_NONE, MULTICAST_SCOPE_NONE);
+        }
+
+        /**
+         * Create a builder that forwards packets above a certain scope
+         *
+         * The scope can be changed on this builder, but not the listening addresses.
+         * @param scope the initial scope
+         */
+        public static Builder newBuilderWithMinScope(final int scope) {
+            return new Builder(FORWARD_WITH_MIN_SCOPE, scope);
+        }
+
+        /**
+         * Create a builder that forwards a specified list of listening addresses.
+         *
+         * Addresses can be added and removed from this builder, but the scope can't be set.
+         */
+        public static Builder newBuilderWithListeningAddresses() {
+            return new Builder(FORWARD_SELECTED, MULTICAST_SCOPE_NONE);
+        }
+
+        /**
+         * Sets the minimum scope for this multicast routing config.
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_WITH_MIN_SCOPE mode.
+         * @return this builder
+         */
+        public Builder setMinimumScope(final int scope) {
+            if (FORWARD_WITH_MIN_SCOPE != mForwardingMode) {
+                throw new IllegalArgumentException("Can't set the scope on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            mMinScope = scope;
+            return this;
+        }
+
+        /**
+         * Add an address to the set of listening addresses.
+         *
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_SELECTED mode.
+         * If this address was already added, this is a no-op.
+         * @return this builder
+         */
+        public Builder addListeningAddress(@NonNull final Inet6Address address) {
+            if (FORWARD_SELECTED != mForwardingMode) {
+                throw new IllegalArgumentException("Can't add an address on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            // TODO : should we check that this is a multicast address ?
+            mListeningAddresses.add(address);
+            return this;
+        }
+
+        /**
+         * Remove an address from the set of listening addresses.
+         *
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_SELECTED mode.
+         * If this address was not added, or was already removed, this is a no-op.
+         * @return this builder
+         */
+        public Builder removeListeningAddress(@NonNull final Inet6Address address) {
+            if (FORWARD_SELECTED != mForwardingMode) {
+                throw new IllegalArgumentException("Can't remove an address on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            mListeningAddresses.remove(address);
+            return this;
+        }
+
+        /**
+         * Build the config.
+         */
+        public MulticastRoutingConfig build() {
+            return new MulticastRoutingConfig(mForwardingMode, mMinScope, mListeningAddresses);
+        }
+    }
+
+    private static String modeToString(@MulticastForwardingMode final int mode) {
+        switch (mode) {
+            case FORWARD_NONE: return "FORWARD_NONE";
+            case FORWARD_SELECTED: return "FORWARD_SELECTED";
+            case FORWARD_WITH_MIN_SCOPE: return "FORWARD_WITH_MIN_SCOPE";
+            default: return "unknown multicast routing mode " + mode;
+        }
+    }
+}
diff --git a/framework/src/android/net/NetworkAgent.java b/framework/src/android/net/NetworkAgent.java
index 177f7e3..4e9087c 100644
--- a/framework/src/android/net/NetworkAgent.java
+++ b/framework/src/android/net/NetworkAgent.java
@@ -151,7 +151,7 @@
 
     /**
      * Sent by the NetworkAgent to ConnectivityService to pass the current
-     * NetworkCapabilties.
+     * NetworkCapabilities.
      * obj = NetworkCapabilities
      * @hide
      */
@@ -443,6 +443,14 @@
     public static final int EVENT_UNREGISTER_AFTER_REPLACEMENT = BASE + 29;
 
     /**
+     * Sent by the NetworkAgent to ConnectivityService to pass the new value of the local
+     * network agent config.
+     * obj = {@code Pair<NetworkAgentInfo, LocalNetworkConfig>}
+     * @hide
+     */
+    public static final int EVENT_LOCAL_NETWORK_CONFIG_CHANGED = BASE + 30;
+
+    /**
      * DSCP policy was successfully added.
      */
     public static final int DSCP_POLICY_STATUS_SUCCESS = 0;
@@ -517,20 +525,47 @@
             @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
             @NonNull NetworkScore score, @NonNull NetworkAgentConfig config,
             @Nullable NetworkProvider provider) {
-        this(looper, context, logTag, nc, lp, score, config,
+        this(context, looper, logTag, nc, lp, null /* localNetworkConfig */, score, config,
+                provider);
+    }
+
+    /**
+     * Create a new network agent.
+     * @param context a {@link Context} to get system services from.
+     * @param looper the {@link Looper} on which to invoke the callbacks.
+     * @param logTag the tag for logs
+     * @param nc the initial {@link NetworkCapabilities} of this network. Update with
+     *           sendNetworkCapabilities.
+     * @param lp the initial {@link LinkProperties} of this network. Update with sendLinkProperties.
+     * @param localNetworkConfig the initial {@link LocalNetworkConfig} of this
+     *                                  network. Update with sendLocalNetworkConfig. Must be
+     *                                  non-null iff the nc have NET_CAPABILITY_LOCAL_NETWORK.
+     * @param score the initial score of this network. Update with sendNetworkScore.
+     * @param config an immutable {@link NetworkAgentConfig} for this agent.
+     * @param provider the {@link NetworkProvider} managing this agent.
+     * @hide
+     */
+    // TODO : expose
+    public NetworkAgent(@NonNull Context context, @NonNull Looper looper, @NonNull String logTag,
+            @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, @Nullable NetworkProvider provider) {
+        this(looper, context, logTag, nc, lp, localNetworkConfig, score, config,
                 provider == null ? NetworkProvider.ID_NONE : provider.getProviderId(),
                 getLegacyNetworkInfo(config));
     }
 
     private static class InitialConfiguration {
-        public final Context context;
-        public final NetworkCapabilities capabilities;
-        public final LinkProperties properties;
-        public final NetworkScore score;
-        public final NetworkAgentConfig config;
-        public final NetworkInfo info;
+        @NonNull public final Context context;
+        @NonNull public final NetworkCapabilities capabilities;
+        @NonNull public final LinkProperties properties;
+        @NonNull public final NetworkScore score;
+        @NonNull public final NetworkAgentConfig config;
+        @NonNull public final NetworkInfo info;
+        @Nullable public final LocalNetworkConfig localNetworkConfig;
         InitialConfiguration(@NonNull Context context, @NonNull NetworkCapabilities capabilities,
-                @NonNull LinkProperties properties, @NonNull NetworkScore score,
+                @NonNull LinkProperties properties,
+                @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
                 @NonNull NetworkAgentConfig config, @NonNull NetworkInfo info) {
             this.context = context;
             this.capabilities = capabilities;
@@ -538,14 +573,15 @@
             this.score = score;
             this.config = config;
             this.info = info;
+            this.localNetworkConfig = localNetworkConfig;
         }
     }
     private volatile InitialConfiguration mInitialConfiguration;
 
     private NetworkAgent(@NonNull Looper looper, @NonNull Context context, @NonNull String logTag,
             @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
-            @NonNull NetworkScore score, @NonNull NetworkAgentConfig config, int providerId,
-            @NonNull NetworkInfo ni) {
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, int providerId, @NonNull NetworkInfo ni) {
         mHandler = new NetworkAgentHandler(looper);
         LOG_TAG = logTag;
         mNetworkInfo = new NetworkInfo(ni);
@@ -556,7 +592,7 @@
 
         mInitialConfiguration = new InitialConfiguration(context,
                 new NetworkCapabilities(nc, NetworkCapabilities.REDACT_NONE),
-                new LinkProperties(lp), score, config, ni);
+                new LinkProperties(lp), localNetworkConfig, score, config, ni);
     }
 
     private class NetworkAgentHandler extends Handler {
@@ -723,7 +759,8 @@
             mNetwork = cm.registerNetworkAgent(new NetworkAgentBinder(mHandler),
                     new NetworkInfo(mInitialConfiguration.info),
                     mInitialConfiguration.properties, mInitialConfiguration.capabilities,
-                    mInitialConfiguration.score, mInitialConfiguration.config, providerId);
+                    mInitialConfiguration.localNetworkConfig, mInitialConfiguration.score,
+                    mInitialConfiguration.config, providerId);
             mInitialConfiguration = null; // All this memory can now be GC'd
         }
         return mNetwork;
@@ -1099,6 +1136,18 @@
     }
 
     /**
+     * Must be called by the agent when the network's {@link LocalNetworkConfig} changes.
+     * @param config the new LocalNetworkConfig
+     * @hide
+     */
+    public void sendLocalNetworkConfig(@NonNull LocalNetworkConfig config) {
+        Objects.requireNonNull(config);
+        // If the agent doesn't have NET_CAPABILITY_LOCAL_NETWORK, this will be ignored by
+        // ConnectivityService with a Log.wtf.
+        queueOrSendMessage(reg -> reg.sendLocalNetworkConfig(config));
+    }
+
+    /**
      * Must be called by the agent to update the score of this network.
      *
      * @param score the new score.
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index ba0cad3..a50404a 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -161,6 +161,7 @@
 import android.net.IpMemoryStore;
 import android.net.IpPrefix;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.MatchAllNetworkSpecifier;
 import android.net.NativeNetworkConfig;
 import android.net.NativeNetworkType;
@@ -1775,7 +1776,7 @@
         mNoServiceNetwork = new NetworkAgentInfo(null,
                 new Network(INetd.UNREACHABLE_NET_ID),
                 new NetworkInfo(TYPE_NONE, 0, "", ""),
-                new LinkProperties(), new NetworkCapabilities(),
+                new LinkProperties(), new NetworkCapabilities(), null /* localNetworkConfig */,
                 new NetworkScore.Builder().setLegacyInt(0).build(), mContext, null,
                 new NetworkAgentConfig(), this, null, null, 0, INVALID_UID,
                 mLingerDelayMs, mQosCallbackTracker, mDeps);
@@ -4123,6 +4124,11 @@
                     updateNetworkInfo(nai, info);
                     break;
                 }
+                case NetworkAgent.EVENT_LOCAL_NETWORK_CONFIG_CHANGED: {
+                    final LocalNetworkConfig config = (LocalNetworkConfig) arg.second;
+                    updateLocalNetworkConfig(nai, config);
+                    break;
+                }
                 case NetworkAgent.EVENT_NETWORK_SCORE_CHANGED: {
                     updateNetworkScore(nai, (NetworkScore) arg.second);
                     break;
@@ -8083,13 +8089,18 @@
      * @param networkCapabilities the initial capabilites of this network. They can be updated
      *         later : see {@link #updateCapabilities}.
      * @param initialScore the initial score of the network. See {@link NetworkAgentInfo#getScore}.
+     * @param localNetworkConfig config about this local network, or null if not a local network
      * @param networkAgentConfig metadata about the network. This is never updated.
      * @param providerId the ID of the provider owning this NetworkAgent.
      * @return the network created for this agent.
      */
-    public Network registerNetworkAgent(INetworkAgent na, NetworkInfo networkInfo,
-            LinkProperties linkProperties, NetworkCapabilities networkCapabilities,
-            @NonNull NetworkScore initialScore, NetworkAgentConfig networkAgentConfig,
+    public Network registerNetworkAgent(INetworkAgent na,
+            NetworkInfo networkInfo,
+            LinkProperties linkProperties,
+            NetworkCapabilities networkCapabilities,
+            @NonNull NetworkScore initialScore,
+            @Nullable LocalNetworkConfig localNetworkConfig,
+            NetworkAgentConfig networkAgentConfig,
             int providerId) {
         Objects.requireNonNull(networkInfo, "networkInfo must not be null");
         Objects.requireNonNull(linkProperties, "linkProperties must not be null");
@@ -8107,12 +8118,20 @@
             // Before U, netd doesn't support PHYSICAL_LOCAL networks so this can't work.
             throw new IllegalArgumentException("Local agents are not supported in this version");
         }
+        final boolean hasLocalNetworkConfig = null != localNetworkConfig;
+        if (hasLocalCap != hasLocalNetworkConfig) {
+            throw new IllegalArgumentException(null != localNetworkConfig
+                    ? "Only local network agents can have a LocalNetworkConfig"
+                    : "Local network agents must have a LocalNetworkConfig"
+            );
+        }
 
         final int uid = mDeps.getCallingUid();
         final long token = Binder.clearCallingIdentity();
         try {
             return registerNetworkAgentInternal(na, networkInfo, linkProperties,
-                    networkCapabilities, initialScore, networkAgentConfig, providerId, uid);
+                    networkCapabilities, initialScore, networkAgentConfig, localNetworkConfig,
+                    providerId, uid);
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -8120,7 +8139,8 @@
 
     private Network registerNetworkAgentInternal(INetworkAgent na, NetworkInfo networkInfo,
             LinkProperties linkProperties, NetworkCapabilities networkCapabilities,
-            NetworkScore currentScore, NetworkAgentConfig networkAgentConfig, int providerId,
+            NetworkScore currentScore, NetworkAgentConfig networkAgentConfig,
+            @Nullable LocalNetworkConfig localNetworkConfig, int providerId,
             int uid) {
 
         // Make a copy of the passed NI, LP, NC as the caller may hold a reference to them
@@ -8128,6 +8148,7 @@
         final NetworkInfo niCopy = new NetworkInfo(networkInfo);
         final NetworkCapabilities ncCopy = new NetworkCapabilities(networkCapabilities);
         final LinkProperties lpCopy = new LinkProperties(linkProperties);
+        // No need to copy |localNetworkConfiguration| as it is immutable.
 
         // At this point the capabilities/properties are untrusted and unverified, e.g. checks that
         // the capabilities' access UIDs comply with security limitations. They will be sanitized
@@ -8135,9 +8156,9 @@
         // because some of the checks must happen on the handler thread.
         final NetworkAgentInfo nai = new NetworkAgentInfo(na,
                 new Network(mNetIdManager.reserveNetId()), niCopy, lpCopy, ncCopy,
-                currentScore, mContext, mTrackerHandler, new NetworkAgentConfig(networkAgentConfig),
-                this, mNetd, mDnsResolver, providerId, uid, mLingerDelayMs,
-                mQosCallbackTracker, mDeps);
+                localNetworkConfig, currentScore, mContext, mTrackerHandler,
+                new NetworkAgentConfig(networkAgentConfig), this, mNetd, mDnsResolver, providerId,
+                uid, mLingerDelayMs, mQosCallbackTracker, mDeps);
 
         final String extraInfo = niCopy.getExtraInfo();
         final String name = TextUtils.isEmpty(extraInfo)
@@ -8872,6 +8893,16 @@
         updateCapabilities(nai.getScore(), nai, nai.networkCapabilities);
     }
 
+    private void updateLocalNetworkConfig(@NonNull final NetworkAgentInfo nai,
+            @NonNull final LocalNetworkConfig config) {
+        if (!nai.networkCapabilities.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+            Log.wtf(TAG, "Ignoring update of a local network info on non-local network " + nai);
+            return;
+        }
+        // TODO : actually apply the diff.
+        nai.localNetworkConfig = config;
+    }
+
     /**
      * Returns the interface which requires VPN isolation (ingress interface filtering).
      *
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index 8d0d711..b0ad978 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -35,6 +35,7 @@
 import android.net.INetworkAgentRegistry;
 import android.net.INetworkMonitor;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.NattKeepalivePacketData;
 import android.net.Network;
 import android.net.NetworkAgent;
@@ -173,6 +174,7 @@
     // TODO: make this private with a getter.
     @NonNull public NetworkCapabilities networkCapabilities;
     @NonNull public final NetworkAgentConfig networkAgentConfig;
+    @Nullable public LocalNetworkConfig localNetworkConfig;
 
     // Underlying networks declared by the agent.
     // The networks in this list might be declared by a VPN using setUnderlyingNetworks and are
@@ -609,6 +611,7 @@
 
     public NetworkAgentInfo(INetworkAgent na, Network net, NetworkInfo info,
             @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @Nullable LocalNetworkConfig localNetworkConfig,
             @NonNull NetworkScore score, Context context,
             Handler handler, NetworkAgentConfig config, ConnectivityService connService, INetd netd,
             IDnsResolver dnsResolver, int factorySerialNumber, int creatorUid,
@@ -626,6 +629,7 @@
         networkInfo = info;
         linkProperties = lp;
         networkCapabilities = nc;
+        this.localNetworkConfig = localNetworkConfig;
         networkAgentConfig = config;
         mConnService = connService;
         mConnServiceDeps = deps;
@@ -905,6 +909,12 @@
         }
 
         @Override
+        public void sendLocalNetworkConfig(@NonNull final LocalNetworkConfig config) {
+            mHandler.obtainMessage(NetworkAgent.EVENT_LOCAL_NETWORK_CONFIG_CHANGED,
+                    new Pair<>(NetworkAgentInfo.this, config)).sendToTarget();
+        }
+
+        @Override
         public void sendScore(@NonNull final NetworkScore score) {
             mHandler.obtainMessage(NetworkAgent.EVENT_NETWORK_SCORE_CHANGED,
                     new Pair<>(NetworkAgentInfo.this, score)).sendToTarget();
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 5937655..392cba9 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -704,6 +704,7 @@
                 argThat<NetworkInfo> { it.detailedState == NetworkInfo.DetailedState.CONNECTING },
                 any(LinkProperties::class.java),
                 any(NetworkCapabilities::class.java),
+                any(), // LocalNetworkConfig TODO : specify when it's public
                 any(NetworkScore::class.java),
                 any(NetworkAgentConfig::class.java),
                 eq(NetworkProvider.ID_NONE))
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index 16f0c44..04fc9ef 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -12747,7 +12747,8 @@
 
     private NetworkAgentInfo fakeNai(NetworkCapabilities nc, NetworkInfo networkInfo) {
         return new NetworkAgentInfo(null, new Network(NET_ID), networkInfo, new LinkProperties(),
-                nc, new NetworkScore.Builder().setLegacyInt(0).build(),
+                nc, null /* localNetworkConfig */,
+                new NetworkScore.Builder().setLegacyInt(0).build(),
                 mServiceContext, null, new NetworkAgentConfig(), mService, null, null, 0,
                 INVALID_UID, TEST_LINGER_DELAY_MS, mQosCallbackTracker,
                 new ConnectivityService.Dependencies());
diff --git a/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java b/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
index e6c0c83..07883ff 100644
--- a/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
@@ -372,9 +372,10 @@
         caps.addCapability(0);
         caps.addTransportType(transport);
         NetworkAgentInfo nai = new NetworkAgentInfo(null, new Network(netId), info,
-                new LinkProperties(), caps, new NetworkScore.Builder().setLegacyInt(50).build(),
-                mCtx, null, new NetworkAgentConfig.Builder().build(), mConnService, mNetd,
-                mDnsResolver, NetworkProvider.ID_NONE, Binder.getCallingUid(), TEST_LINGER_DELAY_MS,
+                new LinkProperties(), caps, null /* localNetworkConfiguration */,
+                new NetworkScore.Builder().setLegacyInt(50).build(), mCtx, null,
+                new NetworkAgentConfig.Builder().build(), mConnService, mNetd, mDnsResolver,
+                NetworkProvider.ID_NONE, Binder.getCallingUid(), TEST_LINGER_DELAY_MS,
                 mQosCallbackTracker, new ConnectivityService.Dependencies());
         if (setEverValidated) {
             // As tests in this class deal with testing lingering, most tests are interested
diff --git a/tests/unit/java/com/android/server/connectivity/VpnTest.java b/tests/unit/java/com/android/server/connectivity/VpnTest.java
index d674767..478ff61 100644
--- a/tests/unit/java/com/android/server/connectivity/VpnTest.java
+++ b/tests/unit/java/com/android/server/connectivity/VpnTest.java
@@ -3004,8 +3004,15 @@
         profile.mppe = useMppe;
 
         doReturn(new Network[] { new Network(101) }).when(mConnectivityManager).getAllNetworks();
-        doReturn(new Network(102)).when(mConnectivityManager).registerNetworkAgent(any(), any(),
-                any(), any(), any(), any(), anyInt());
+        doReturn(new Network(102)).when(mConnectivityManager).registerNetworkAgent(
+                any(), // INetworkAgent
+                any(), // NetworkInfo
+                any(), // LinkProperties
+                any(), // NetworkCapabilities
+                any(), // LocalNetworkConfig
+                any(), // NetworkScore
+                any(), // NetworkAgentConfig
+                anyInt()); // provider ID
 
         final Vpn vpn = startLegacyVpn(createVpn(PRIMARY_USER.id), profile);
         final TestDeps deps = (TestDeps) vpn.mDeps;
@@ -3027,8 +3034,15 @@
                 assertEquals("nomppe", mtpdArgs[argsPrefix.length]);
             }
 
-            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(any(), any(),
-                    any(), any(), any(), any(), anyInt());
+            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(
+                    any(), // INetworkAgent
+                    any(), // NetworkInfo
+                    any(), // LinkProperties
+                    any(), // NetworkCapabilities
+                    any(), // LocalNetworkConfig
+                    any(), // NetworkScore
+                    any(), // NetworkAgentConfig
+                    anyInt()); // provider ID
         }, () -> { // Cleanup
                 vpn.mVpnRunner.exitVpnRunner();
                 deps.getStateFile().delete(); // set to delete on exit, but this deletes it earlier
@@ -3053,7 +3067,7 @@
             .thenReturn(new Network[] { new Network(101) });
 
         when(mConnectivityManager.registerNetworkAgent(any(), any(), any(), any(),
-                any(), any(), anyInt())).thenAnswer(invocation -> {
+                any(), any(), any(), anyInt())).thenAnswer(invocation -> {
                     // The runner has registered an agent and is now ready.
                     legacyRunnerReady.open();
                     return new Network(102);
@@ -3079,7 +3093,7 @@
             ArgumentCaptor<NetworkCapabilities> ncCaptor =
                     ArgumentCaptor.forClass(NetworkCapabilities.class);
             verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(any(), any(),
-                    lpCaptor.capture(), ncCaptor.capture(), any(), any(), anyInt());
+                    lpCaptor.capture(), ncCaptor.capture(), any(), any(), any(), anyInt());
 
             // In this test the expected address is always v4 so /32.
             // Note that the interface needs to be specified because RouteInfo objects stored in
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
index 86426c2..6220e76 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server
 
+import android.net.LocalNetworkConfig
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
@@ -45,8 +46,9 @@
                 .build()
         val keepConnectedAgent = Agent(nc = nc, score = FromS(NetworkScore.Builder()
                 .setKeepConnectedReason(KEEP_CONNECTED_DOWNSTREAM_NETWORK)
-                .build()))
-        val dontKeepConnectedAgent = Agent(nc = nc)
+                .build()),
+                lnc = LocalNetworkConfig.Builder().build())
+        val dontKeepConnectedAgent = Agent(nc = nc, lnc = LocalNetworkConfig.Builder().build())
         doTestKeepConnected(keepConnectedAgent, dontKeepConnectedAgent)
     }
 
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
index 7914e04..cfc3a3d 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
@@ -18,6 +18,7 @@
 
 import android.content.pm.PackageManager.FEATURE_LEANBACK
 import android.net.INetd
+import android.net.LocalNetworkConfig
 import android.net.NativeNetworkConfig
 import android.net.NativeNetworkType
 import android.net.NetworkCapabilities
@@ -48,6 +49,8 @@
 private fun keepConnectedScore() =
         FromS(NetworkScore.Builder().setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST).build())
 
+private fun defaultLnc() = LocalNetworkConfig.Builder().build()
+
 @RunWith(DevSdkIgnoreRunner::class)
 @SmallTest
 @IgnoreUpTo(Build.VERSION_CODES.R)
@@ -93,9 +96,9 @@
             addCapability(NET_CAPABILITY_LOCAL_NETWORK)
         }.build()
         val localAgent = if (sdkLevel >= VERSION_V || sdkLevel == VERSION_U && isTv) {
-            Agent(nc = ncTemplate, score = keepConnectedScore())
+            Agent(nc = ncTemplate, score = keepConnectedScore(), lnc = defaultLnc())
         } else {
-            assertFailsWith<IllegalArgumentException> { Agent(nc = ncTemplate) }
+            assertFailsWith<IllegalArgumentException> { Agent(nc = ncTemplate, lnc = defaultLnc()) }
             netdInOrder.verify(netd, never()).networkCreate(any())
             return
         }
@@ -111,4 +114,18 @@
         localAgent.disconnect()
         netdInOrder.verify(netd, timeout(TIMEOUT_MS)).networkDestroy(localAgent.network.netId)
     }
+
+    @Test
+    fun testBadAgents() {
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder()
+                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                    .build(),
+                    lnc = null)
+        }
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder().build(),
+                    lnc = LocalNetworkConfig.Builder().build())
+        }
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
new file mode 100644
index 0000000..bd3efa9
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
@@ -0,0 +1,111 @@
+/*
+ * 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
+
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.LocalNetworkConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.RouteInfo
+import android.os.Build
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback
+import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.RecorderCallback.CallbackEntry.BlockedStatus
+import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertFailsWith
+
+private fun nc(transport: Int, vararg caps: Int) = NetworkCapabilities.Builder().apply {
+    addTransportType(transport)
+    caps.forEach {
+        addCapability(it)
+    }
+    // Useful capabilities for everybody
+    addCapability(NET_CAPABILITY_NOT_RESTRICTED)
+    addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+    addCapability(NET_CAPABILITY_NOT_ROAMING)
+    addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+}.build()
+
+private fun lp(iface: String) = LinkProperties().apply {
+    interfaceName = iface
+    addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
+    addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
+}
+
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class CSLocalAgentTests : CSTest() {
+    @Test
+    fun testBadAgents() {
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder()
+                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                    .build(),
+                    lnc = null)
+        }
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder().build(),
+                    lnc = LocalNetworkConfig.Builder().build())
+        }
+    }
+
+    @Test
+    fun testUpdateLocalAgentConfig() {
+        deps.setBuildSdk(VERSION_V)
+
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build(),
+                cb)
+
+        // Set up a local agent that should forward its traffic to the best DUN upstream.
+        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+                lp = lp("local0"),
+                lnc = LocalNetworkConfig.Builder().build(),
+        )
+        localAgent.connect()
+
+        cb.expect<Available>(localAgent.network)
+        cb.expect<CapabilitiesChanged>(localAgent.network)
+        cb.expect<LinkPropertiesChanged>(localAgent.network)
+        cb.expect<BlockedStatus>(localAgent.network)
+
+        val newLnc = LocalNetworkConfig.Builder()
+                .setUpstreamSelector(NetworkRequest.Builder()
+                        .addTransportType(TRANSPORT_WIFI)
+                        .build())
+                .build()
+        localAgent.sendLocalNetworkConfig(newLnc)
+
+        localAgent.disconnect()
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
index 1d0d5df..094ded3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -21,6 +21,7 @@
 import android.net.INetworkMonitor
 import android.net.INetworkMonitorCallbacks
 import android.net.LinkProperties
+import android.net.LocalNetworkConfig
 import android.net.Network
 import android.net.NetworkAgent
 import android.net.NetworkAgentConfig
@@ -69,6 +70,7 @@
         nac: NetworkAgentConfig,
         val nc: NetworkCapabilities,
         val lp: LinkProperties,
+        val lnc: LocalNetworkConfig?,
         val score: FromS<NetworkScore>,
         val provider: NetworkProvider?
 ) : TestableNetworkCallback.HasNetwork {
@@ -100,7 +102,7 @@
         // Create the actual agent. NetworkAgent is abstract, so make an anonymous subclass.
         if (deps.isAtLeastS()) {
             agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
-                    nc, lp, score.value, nac, provider) {}
+                    nc, lp, lnc, score.value, nac, provider) {}
         } else {
             agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
                     nc, lp, 50 /* score */, nac, provider) {}
@@ -165,4 +167,6 @@
         agent.unregister()
         cb.eventuallyExpect<Lost> { it.network == agent.network }
     }
+
+    fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index 2f78212..0ccbfc3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -27,6 +27,7 @@
 import android.net.INetd
 import android.net.InetAddresses
 import android.net.LinkProperties
+import android.net.LocalNetworkConfig
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
@@ -292,10 +293,11 @@
             nc: NetworkCapabilities = defaultNc(),
             nac: NetworkAgentConfig = emptyAgentConfig(nc.getLegacyType()),
             lp: LinkProperties = defaultLp(),
+            lnc: LocalNetworkConfig? = null,
             score: FromS<NetworkScore> = defaultScore(),
             provider: NetworkProvider? = null
-    ) = CSAgentWrapper(context, deps, csHandlerThread, networkStack, nac, nc, lp, score, provider)
-
+    ) = CSAgentWrapper(context, deps, csHandlerThread, networkStack,
+            nac, nc, lp, lnc, score, provider)
     fun Agent(vararg transports: Int, lp: LinkProperties = defaultLp()): CSAgentWrapper {
         val nc = NetworkCapabilities.Builder().apply {
             transports.forEach {