Merge "Add captive portal info to DhcpClient output"
diff --git a/apishim/29/com/android/networkstack/apishim/api29/NetworkInformationShimImpl.java b/apishim/29/com/android/networkstack/apishim/api29/NetworkInformationShimImpl.java
index cea38be..b24b473 100644
--- a/apishim/29/com/android/networkstack/apishim/api29/NetworkInformationShimImpl.java
+++ b/apishim/29/com/android/networkstack/apishim/api29/NetworkInformationShimImpl.java
@@ -22,7 +22,9 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
+import com.android.networkstack.apishim.CaptivePortalDataShim;
 import com.android.networkstack.apishim.NetworkInformationShim;
 
 /**
@@ -44,6 +46,14 @@
         return new NetworkInformationShimImpl();
     }
 
+    /**
+     * Indicates whether the shim can use APIs above the Q SDK.
+     */
+    @VisibleForTesting
+    public static boolean useApiAboveQ() {
+        return false;
+    }
+
     @Nullable
     @Override
     public Uri getCaptivePortalApiUrl(@Nullable LinkProperties lp) {
@@ -58,6 +68,12 @@
 
     @Nullable
     @Override
+    public CaptivePortalDataShim getCaptivePortalData(@Nullable LinkProperties lp) {
+        return null;
+    }
+
+    @Nullable
+    @Override
     public String getSSID(@Nullable NetworkCapabilities nc) {
         // Not supported on this API level
         return null;
diff --git a/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java b/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
index 10df01b..bee43b4 100644
--- a/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
+++ b/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
@@ -94,6 +94,11 @@
     }
 
     @Override
+    public Uri getVenueInfoUrl() {
+        return mData.getVenueInfoUrl();
+    }
+
+    @Override
     public void notifyChanged(INetworkMonitorCallbacks cb) throws RemoteException {
         cb.notifyCaptivePortalDataChanged(mData);
     }
diff --git a/apishim/30/com/android/networkstack/apishim/NetworkInformationShimImpl.java b/apishim/30/com/android/networkstack/apishim/NetworkInformationShimImpl.java
index 0056988..6466662 100644
--- a/apishim/30/com/android/networkstack/apishim/NetworkInformationShimImpl.java
+++ b/apishim/30/com/android/networkstack/apishim/NetworkInformationShimImpl.java
@@ -23,6 +23,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
 /**
  * Compatibility implementation of {@link NetworkInformationShim}.
@@ -35,12 +36,20 @@
      * Get a new instance of {@link NetworkInformationShim}.
      */
     public static NetworkInformationShim newInstance() {
-        if (!ShimUtils.isReleaseOrDevelopmentApiAbove(Build.VERSION_CODES.Q)) {
+        if (!useApiAboveQ()) {
             return com.android.networkstack.apishim.api29.NetworkInformationShimImpl.newInstance();
         }
         return new NetworkInformationShimImpl();
     }
 
+    /**
+     * Indicates whether the shim can use APIs above the Q SDK.
+     */
+    @VisibleForTesting
+    public static boolean useApiAboveQ() {
+        return ShimUtils.isReleaseOrDevelopmentApiAbove(Build.VERSION_CODES.Q);
+    }
+
     @Nullable
     @Override
     public Uri getCaptivePortalApiUrl(@Nullable LinkProperties lp) {
@@ -55,6 +64,13 @@
 
     @Nullable
     @Override
+    public CaptivePortalDataShim getCaptivePortalData(@Nullable LinkProperties lp) {
+        if (lp == null || lp.getCaptivePortalData() == null) return null;
+        return new CaptivePortalDataShimImpl(lp.getCaptivePortalData());
+    }
+
+    @Nullable
+    @Override
     public String getSSID(@Nullable NetworkCapabilities nc) {
         if (nc == null) return null;
         return nc.getSSID();
diff --git a/apishim/common/com/android/networkstack/apishim/CaptivePortalDataShim.java b/apishim/common/com/android/networkstack/apishim/CaptivePortalDataShim.java
index e460155..9718ced 100644
--- a/apishim/common/com/android/networkstack/apishim/CaptivePortalDataShim.java
+++ b/apishim/common/com/android/networkstack/apishim/CaptivePortalDataShim.java
@@ -35,6 +35,11 @@
     Uri getUserPortalUrl();
 
     /**
+     * @see android.net.CaptivePortalData#getVenueInfoUrl()
+     */
+    Uri getVenueInfoUrl();
+
+    /**
      * @see INetworkMonitorCallbacks#notifyCaptivePortalDataChanged(android.net.CaptivePortalData)
      */
     void notifyChanged(INetworkMonitorCallbacks cb) throws RemoteException;
diff --git a/apishim/common/com/android/networkstack/apishim/NetworkInformationShim.java b/apishim/common/com/android/networkstack/apishim/NetworkInformationShim.java
index 15a2a70..c266043 100644
--- a/apishim/common/com/android/networkstack/apishim/NetworkInformationShim.java
+++ b/apishim/common/com/android/networkstack/apishim/NetworkInformationShim.java
@@ -40,6 +40,12 @@
     void setCaptivePortalApiUrl(@NonNull LinkProperties lp, @Nullable Uri url);
 
     /**
+     * @see LinkProperties#getCaptivePortalData()
+     */
+    @Nullable
+    CaptivePortalDataShim getCaptivePortalData(@Nullable LinkProperties lp);
+
+    /**
      * @see NetworkCapabilities#getSSID()
      */
     @Nullable
diff --git a/common/moduleutils/src/android/net/util/InterfaceParams.java b/common/moduleutils/src/android/net/util/InterfaceParams.java
index 3ba02b5..7e05a8d 100644
--- a/common/moduleutils/src/android/net/util/InterfaceParams.java
+++ b/common/moduleutils/src/android/net/util/InterfaceParams.java
@@ -38,6 +38,7 @@
 public class InterfaceParams {
     public final String name;
     public final int index;
+    public final boolean hasMacAddress;
     public final MacAddress macAddr;
     public final int defaultMtu;
 
@@ -69,7 +70,8 @@
         checkArgument((index > 0), "invalid interface index");
         this.name = name;
         this.index = index;
-        this.macAddr = (macAddr != null) ? macAddr : MacAddress.fromBytes(new byte[] {
+        this.hasMacAddress = (macAddr != null);
+        this.macAddr = hasMacAddress ? macAddr : MacAddress.fromBytes(new byte[] {
                 0x02, 0x00, 0x00, 0x00, 0x00, 0x00 });
         this.defaultMtu = (defaultMtu > IPV6_MIN_MTU) ? defaultMtu : IPV6_MIN_MTU;
     }
diff --git a/res/drawable/icon_wifi.xml b/res/drawable/icon_wifi.xml
new file mode 100644
index 0000000..7e13d49
--- /dev/null
+++ b/res/drawable/icon_wifi.xml
@@ -0,0 +1,27 @@
+<!--
+Copyright (C) 2020 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="26.0dp"
+        android:height="24.0dp"
+        android:viewportWidth="26.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#4DFFFFFF"
+        android:pathData="M19.1,14l-3.4,0l0,-1.5c0,-1.8 0.8,-2.8 1.5,-3.4C18.1,8.3 19.200001,8 20.6,8c1.2,0 2.3,0.3 3.1,0.8l1.9,-2.3C25.1,6.1 20.299999,2.1 13,2.1S0.9,6.1 0.4,6.5L13,22l0,0l0,0l0,0l0,0l6.5,-8.1L19.1,14z"/>
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M19.5,17.799999c0,-0.8 0.1,-1.3 0.2,-1.6c0.2,-0.3 0.5,-0.7 1.1,-1.2c0.4,-0.4 0.7,-0.8 1,-1.1s0.4,-0.8 0.4,-1.2c0,-0.5 -0.1,-0.9 -0.4,-1.2c-0.3,-0.3 -0.7,-0.4 -1.2,-0.4c-0.4,0 -0.8,0.1 -1.1,0.3c-0.3,0.2 -0.4,0.6 -0.4,1.1l-1.9,0c0,-1 0.3,-1.7 1,-2.2c0.6,-0.5 1.5,-0.8 2.5,-0.8c1.1,0 2,0.3 2.6,0.8c0.6,0.5 0.9,1.3 0.9,2.3c0,0.7 -0.2,1.3 -0.6,1.8c-0.4,0.6 -0.9,1.1 -1.5,1.6c-0.3,0.3 -0.5,0.5 -0.6,0.7c-0.1,0.2 -0.1,0.6 -0.1,1L19.5,17.700001zM21.4,21l-1.9,0l0,-1.8l1.9,0L21.4,21z"/>
+</vector>
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644
index 0000000..e9900b8
--- /dev/null
+++ b/res/values/strings.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 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.
+-->
+<resources>
+    <!-- Notifications are shown after the user logs in to a captive portal network, to indicate
+         that the network should now have internet connectivity. This is the channel name of
+         the notification. [CHAR LIMIT=40] -->
+    <string name="notification_channel_name_connected">Captive portal authentication</string>
+    <!-- Notifications are shown after the user logs in to a captive portal network, to indicate
+         that the network should now have internet connectivity. This is the description of the
+         channel for these notifications. [CHAR LIMIT=300] -->
+    <string name="notification_channel_description_connected">Notifications shown when the device has successfully and authenticated to a captive portal network</string>
+
+    <!-- Notifications are shown when a user connects to a network that advertises a venue
+         information page, so that the user can access that page. This is the channel name of
+         the notification. [CHAR LIMIT=40] -->
+    <string name="notification_channel_name_network_venue_info">Network venue information</string>
+    <!-- Notifications are shown when a user connects to a network that advertises a venue
+         information page, so that the user can access that page. [CHAR LIMIT=300] -->
+    <string name="notification_channel_description_network_venue_info">Notifications shown to indicate the network has a venue information page</string>
+
+    <!-- Notifications are shown after the user logs in to a captive portal network, to indicate
+         that the network should now have internet connectivity. This is the title of the
+         notification, indicating that the device is connected to the network with the SSID given
+         as parameter. [CHAR LIMIT=50] -->
+    <string name="connected_to_ssid_param1">Connected to %1$s</string>
+
+    <!-- A notification is shown after the user logs in to a captive portal network, to indicate
+         that the network should now have internet connectivity. This is the title of the
+         notification, indicating that the device is connected to the network without SSID
+         information. [CHAR LIMIT=50] -->
+    <string name="connected">Connected</string>
+
+    <!-- Notifications are shown when a user connects to a network that advertises a venue
+         information page, so that the user can access that page. This is the message of
+         the notification. [CHAR LIMIT=50] -->
+    <string name="tap_for_info">Tap for venue information</string>
+</resources>
\ No newline at end of file
diff --git a/src/com/android/networkstack/NetworkStackNotifier.java b/src/com/android/networkstack/NetworkStackNotifier.java
new file mode 100644
index 0000000..98caa76
--- /dev/null
+++ b/src/com/android/networkstack/NetworkStackNotifier.java
@@ -0,0 +1,361 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.networkstack;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.networkstack.apishim.CaptivePortalDataShim;
+import com.android.networkstack.apishim.NetworkInformationShim;
+import com.android.networkstack.apishim.NetworkInformationShimImpl;
+
+import java.util.Hashtable;
+import java.util.function.Consumer;
+
+/**
+ * Displays notification related to connected networks.
+ */
+public class NetworkStackNotifier {
+    @VisibleForTesting
+    protected static final int MSG_DISMISS_CONNECTED = 1;
+
+    private final Context mContext;
+    private final Handler mHandler;
+    private final NotificationManager mNotificationManager;
+    private final Dependencies mDependencies;
+
+    @NonNull
+    private final Hashtable<Network, TrackedNetworkStatus> mNetworkStatus = new Hashtable<>();
+    @Nullable
+    private Network mDefaultNetwork;
+    @NonNull
+    private static final NetworkInformationShim NETWORK_INFO_SHIM =
+            NetworkInformationShimImpl.newInstance();
+
+    private static class TrackedNetworkStatus {
+        private boolean mValidatedNotificationPending;
+        private int mShownNotification = NOTE_NONE;
+        private LinkProperties mLinkProperties;
+        private NetworkCapabilities mNetworkCapabilities;
+
+        private boolean isValidated() {
+            if (mNetworkCapabilities == null) return false;
+            return mNetworkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
+        }
+
+        private boolean isWifiNetwork() {
+            if (mNetworkCapabilities == null) return false;
+            return mNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI);
+        }
+
+        @Nullable
+        private CaptivePortalDataShim getCaptivePortalData() {
+            return NETWORK_INFO_SHIM.getCaptivePortalData(mLinkProperties);
+        }
+
+        private String getSSID() {
+            return NETWORK_INFO_SHIM.getSSID(mNetworkCapabilities);
+        }
+    }
+
+    @VisibleForTesting
+    protected static final String CHANNEL_CONNECTED = "connected_note_loud";
+    @VisibleForTesting
+    protected static final String CHANNEL_VENUE_INFO = "connected_note";
+
+    private static final int NOTE_NONE = 0;
+    private static final int NOTE_CONNECTED = 1;
+    private static final int NOTE_VENUE_INFO = 2;
+
+    private static final int NOTE_ID_NETWORK_INFO = 1;
+
+    private static final int CONNECTED_NOTIFICATION_TIMEOUT_MS = 20 * 1000;
+
+    protected static class Dependencies {
+        public PendingIntent getActivityPendingIntent(Context context, Intent intent, int flags) {
+            return PendingIntent.getActivity(context, 0 /* requestCode */, intent, flags);
+        }
+    }
+
+    public NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper) {
+        this(context, looper, new Dependencies());
+    }
+
+    protected NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper,
+            @NonNull Dependencies dependencies) {
+        mContext = context;
+        mHandler = new NotifierHandler(looper);
+        mDependencies = dependencies;
+        mNotificationManager = getContextAsUser(mContext, UserHandle.ALL)
+                .getSystemService(NotificationManager.class);
+        final ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
+        cm.registerDefaultNetworkCallback(new DefaultNetworkCallback(), mHandler);
+        cm.registerNetworkCallback(
+                new NetworkRequest.Builder().build(),
+                new AllNetworksCallback(),
+                mHandler);
+
+        createNotificationChannel(CHANNEL_CONNECTED,
+                R.string.notification_channel_name_connected,
+                R.string.notification_channel_description_connected,
+                NotificationManager.IMPORTANCE_HIGH);
+        createNotificationChannel(CHANNEL_VENUE_INFO,
+                R.string.notification_channel_name_network_venue_info,
+                R.string.notification_channel_description_network_venue_info,
+                NotificationManager.IMPORTANCE_DEFAULT);
+    }
+
+    @VisibleForTesting
+    protected Handler getHandler() {
+        return mHandler;
+    }
+
+    private void createNotificationChannel(@NonNull String id, @StringRes int title,
+            @StringRes int description, int importance) {
+        final Resources resources = mContext.getResources();
+        NotificationChannel channel = new NotificationChannel(id,
+                resources.getString(title),
+                importance);
+        channel.setDescription(resources.getString(description));
+        mNotificationManager.createNotificationChannel(channel);
+    }
+
+    /**
+     * Notify the NetworkStackNotifier that the captive portal app was opened to show a login UI to
+     * the user, but the network has not validated yet. The notifier uses this information to show
+     * proper notifications once the network validates.
+     */
+    public void notifyCaptivePortalValidationPending(@NonNull Network network) {
+        mHandler.post(() -> setCaptivePortalValidationPending(network));
+    }
+
+    private class NotifierHandler extends Handler {
+        NotifierHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            switch(msg.what) {
+                case MSG_DISMISS_CONNECTED:
+                    final Network network = (Network) msg.obj;
+                    final TrackedNetworkStatus networkStatus = mNetworkStatus.get(network);
+                    if (networkStatus != null
+                            && networkStatus.mShownNotification == NOTE_CONNECTED) {
+                        dismissNotification(getNotificationTag(network), networkStatus);
+                    }
+                    break;
+            }
+        }
+    }
+
+    private void setCaptivePortalValidationPending(@NonNull Network network) {
+        updateNetworkStatus(network, status -> status.mValidatedNotificationPending = true);
+    }
+
+    private void updateNetworkStatus(@NonNull Network network,
+            @NonNull Consumer<TrackedNetworkStatus> mutator) {
+        final TrackedNetworkStatus status =
+                mNetworkStatus.computeIfAbsent(network, n -> new TrackedNetworkStatus());
+        mutator.accept(status);
+    }
+
+    private void updateNotifications(@NonNull Network network) {
+        final TrackedNetworkStatus networkStatus = mNetworkStatus.get(network);
+        // The required network attributes callbacks were not fired yet for this network
+        if (networkStatus == null) return;
+
+        final CaptivePortalDataShim capportData = networkStatus.getCaptivePortalData();
+        final boolean showVenueInfo = capportData != null && capportData.getVenueInfoUrl() != null
+                // Only show venue info on validated networks, to prevent misuse of the notification
+                // as an alternate login flow that uses the default browser (which would be broken
+                // if the device has mobile data).
+                && networkStatus.isValidated()
+                && isVenueInfoNotificationEnabled()
+                // Most browsers do not yet support opening a page on a non-default network, so the
+                // venue info link should not be shown if the network is not the default one.
+                && network.equals(mDefaultNetwork);
+        final boolean showValidated =
+                networkStatus.mValidatedNotificationPending && networkStatus.isValidated();
+        final String notificationTag = getNotificationTag(network);
+
+        final Resources res = mContext.getResources();
+        final Notification.Builder builder;
+        if (showVenueInfo) {
+            // Do not re-show the venue info notification even if the previous one had a different
+            // URL, to avoid potential abuse where APs could spam the notification with different
+            // URLs.
+            if (networkStatus.mShownNotification == NOTE_VENUE_INFO) return;
+
+            final Intent infoIntent = new Intent(Intent.ACTION_VIEW)
+                    .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                    .setData(capportData.getVenueInfoUrl())
+                    .putExtra(ConnectivityManager.EXTRA_NETWORK, network)
+                    // Use the network handle as identifier, as there should be only one ACTION_VIEW
+                    // pending intent per network.
+                    .setIdentifier(Long.toString(network.getNetworkHandle()));
+
+            // If the validated notification should be shown, use the high priority "connected"
+            // channel even if the notification contains venue info: the "venue info" notification
+            // then doubles as a "connected" notification.
+            final String channel = showValidated ? CHANNEL_CONNECTED : CHANNEL_VENUE_INFO;
+            builder = getNotificationBuilder(channel, networkStatus, res)
+                    .setContentText(res.getString(R.string.tap_for_info))
+                    .setContentIntent(mDependencies.getActivityPendingIntent(
+                            getContextAsUser(mContext, UserHandle.CURRENT),
+                            infoIntent, PendingIntent.FLAG_UPDATE_CURRENT));
+
+            networkStatus.mShownNotification = NOTE_VENUE_INFO;
+        } else if (showValidated) {
+            if (networkStatus.mShownNotification == NOTE_CONNECTED) return;
+
+            builder = getNotificationBuilder(CHANNEL_CONNECTED, networkStatus, res);
+            if (networkStatus.isWifiNetwork()) {
+                builder.setContentIntent(mDependencies.getActivityPendingIntent(
+                        getContextAsUser(mContext, UserHandle.CURRENT),
+                        new Intent(Settings.ACTION_WIFI_SETTINGS),
+                        PendingIntent.FLAG_UPDATE_CURRENT));
+            }
+
+            networkStatus.mShownNotification = NOTE_CONNECTED;
+        } else {
+            if (networkStatus.mShownNotification != NOTE_NONE
+                    // Don't dismiss the connected notification: it's generated as one-off and will
+                    // be dismissed after a timeout or if the network disconnects.
+                    && networkStatus.mShownNotification != NOTE_CONNECTED) {
+                dismissNotification(notificationTag, networkStatus);
+            }
+            return;
+        }
+
+        if (showValidated) {
+            networkStatus.mValidatedNotificationPending = false;
+        }
+        mNotificationManager.notify(notificationTag, NOTE_ID_NETWORK_INFO, builder.build());
+        mHandler.removeMessages(MSG_DISMISS_CONNECTED, network);
+        if (networkStatus.mShownNotification == NOTE_CONNECTED) {
+            mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_DISMISS_CONNECTED, network),
+                    CONNECTED_NOTIFICATION_TIMEOUT_MS);
+        }
+    }
+
+    private void dismissNotification(@NonNull String tag, @NonNull TrackedNetworkStatus status) {
+        mNotificationManager.cancel(tag, NOTE_ID_NETWORK_INFO);
+        status.mShownNotification = NOTE_NONE;
+    }
+
+    private Notification.Builder getNotificationBuilder(@NonNull String channelId,
+            @NonNull TrackedNetworkStatus networkStatus, @NonNull Resources res) {
+        return new Notification.Builder(mContext, channelId)
+                .setContentTitle(getConnectedNotificationTitle(res, networkStatus))
+                .setSmallIcon(R.drawable.icon_wifi);
+    }
+
+    /**
+     * Replacement for {@link Context#createContextAsUser(UserHandle, int)}, which is not available
+     * in API 29.
+     */
+    private static Context getContextAsUser(Context baseContext, UserHandle user) {
+        try {
+            return baseContext.createPackageContextAsUser(
+                    baseContext.getPackageName(), 0 /* flags */, user);
+        } catch (PackageManager.NameNotFoundException e) {
+            throw new IllegalStateException("NetworkStack own package not found", e);
+        }
+    }
+
+    private boolean isVenueInfoNotificationEnabled() {
+        return mNotificationManager.getNotificationChannel(CHANNEL_VENUE_INFO) != null;
+    }
+
+    private static String getConnectedNotificationTitle(@NonNull Resources res,
+            @NonNull TrackedNetworkStatus status) {
+        final String ssid = status.getSSID();
+        if (TextUtils.isEmpty(ssid)) {
+            return res.getString(R.string.connected);
+        }
+
+        return res.getString(R.string.connected_to_ssid_param1, ssid);
+    }
+
+    private static String getNotificationTag(@NonNull Network network) {
+        return Long.toString(network.getNetworkHandle());
+    }
+
+    private class DefaultNetworkCallback extends ConnectivityManager.NetworkCallback {
+        @Override
+        public void onAvailable(Network network) {
+            updateDefaultNetwork(network);
+        }
+
+        @Override
+        public void onLost(Network network) {
+            updateDefaultNetwork(null);
+        }
+
+        private void updateDefaultNetwork(@Nullable Network newNetwork) {
+            final Network oldDefault = mDefaultNetwork;
+            mDefaultNetwork = newNetwork;
+            if (oldDefault != null) updateNotifications(oldDefault);
+            if (newNetwork != null) updateNotifications(newNetwork);
+        }
+    }
+
+    private class AllNetworksCallback extends ConnectivityManager.NetworkCallback {
+        @Override
+        public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
+            updateNetworkStatus(network, status -> status.mLinkProperties = linkProperties);
+            updateNotifications(network);
+        }
+
+        @Override
+        public void onCapabilitiesChanged(@NonNull Network network,
+                @NonNull NetworkCapabilities networkCapabilities) {
+            updateNetworkStatus(network, s -> s.mNetworkCapabilities = networkCapabilities);
+            updateNotifications(network);
+        }
+
+        @Override
+        public void onLost(Network network) {
+            final TrackedNetworkStatus status = mNetworkStatus.remove(network);
+            if (status == null) return;
+            dismissNotification(getNotificationTag(network), status);
+        }
+    }
+}
diff --git a/src/com/android/server/NetworkStackService.java b/src/com/android/server/NetworkStackService.java
index 1f6631b..5ab2744 100644
--- a/src/com/android/server/NetworkStackService.java
+++ b/src/com/android/server/NetworkStackService.java
@@ -43,6 +43,8 @@
 import android.net.ip.IpClient;
 import android.net.shared.PrivateDnsConfig;
 import android.net.util.SharedLog;
+import android.os.Build;
+import android.os.HandlerThread;
 import android.os.IBinder;
 import android.os.RemoteException;
 import android.util.ArraySet;
@@ -53,6 +55,8 @@
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.util.IndentingPrintWriter;
+import com.android.networkstack.NetworkStackNotifier;
+import com.android.networkstack.apishim.ShimUtils;
 import com.android.server.connectivity.NetworkMonitor;
 import com.android.server.connectivity.ipmemorystore.IpMemoryStoreService;
 import com.android.server.util.PermissionUtil;
@@ -103,6 +107,11 @@
          * Get an instance of the IpMemoryStoreService.
          */
         IIpMemoryStore getIpMemoryStoreService();
+
+        /**
+         * Get an instance of the NetworkNotifier.
+         */
+        NetworkStackNotifier getNotifier();
     }
 
     /**
@@ -119,6 +128,8 @@
         @GuardedBy("mIpClients")
         private final ArrayList<WeakReference<IpClient>> mIpClients = new ArrayList<>();
         private final IpMemoryStoreService mIpMemoryStoreService;
+        @Nullable
+        private final NetworkStackNotifier mNotifier;
 
         private static final int MAX_VALIDATION_LOGS = 10;
         @GuardedBy("mValidationLogs")
@@ -164,6 +175,15 @@
                     (IBinder) context.getSystemService(Context.NETD_SERVICE));
             mObserverRegistry = new NetworkObserverRegistry();
             mIpMemoryStoreService = new IpMemoryStoreService(context);
+            // NetworkStackNotifier only shows notifications relevant for API level > Q
+            if (ShimUtils.isReleaseOrDevelopmentApiAbove(Build.VERSION_CODES.Q)) {
+                final HandlerThread notifierThread = new HandlerThread(
+                        NetworkStackNotifier.class.getSimpleName());
+                notifierThread.start();
+                mNotifier = new NetworkStackNotifier(context, notifierThread.getLooper());
+            } else {
+                mNotifier = null;
+            }
 
             int netdVersion;
             try {
@@ -220,7 +240,7 @@
             mPermChecker.enforceNetworkStackCallingPermission();
             updateSystemAidlVersion(cb.getInterfaceVersion());
             final SharedLog log = addValidationLogs(network, name);
-            final NetworkMonitor nm = new NetworkMonitor(mContext, cb, network, log);
+            final NetworkMonitor nm = new NetworkMonitor(mContext, cb, network, log, this);
             cb.onNetworkMonitorCreated(new NetworkMonitorConnector(nm, mPermChecker));
         }
 
@@ -250,6 +270,11 @@
         }
 
         @Override
+        public NetworkStackNotifier getNotifier() {
+            return mNotifier;
+        }
+
+        @Override
         public void fetchIpMemoryStore(@NonNull final IIpMemoryStoreCallbacks cb)
                 throws RemoteException {
             mPermChecker.enforceNetworkStackCallingPermission();
diff --git a/src/com/android/server/connectivity/NetworkMonitor.java b/src/com/android/server/connectivity/NetworkMonitor.java
index 18b8c77..e914a55 100644
--- a/src/com/android/server/connectivity/NetworkMonitor.java
+++ b/src/com/android/server/connectivity/NetworkMonitor.java
@@ -145,6 +145,7 @@
 import com.android.internal.util.State;
 import com.android.internal.util.StateMachine;
 import com.android.internal.util.TrafficStatsConstants;
+import com.android.networkstack.NetworkStackNotifier;
 import com.android.networkstack.R;
 import com.android.networkstack.apishim.CaptivePortalDataShim;
 import com.android.networkstack.apishim.CaptivePortalDataShimImpl;
@@ -155,6 +156,7 @@
 import com.android.networkstack.metrics.DataStallStatsUtils;
 import com.android.networkstack.netlink.TcpSocketTracker;
 import com.android.networkstack.util.DnsUtils;
+import com.android.server.NetworkStackService.NetworkStackServiceManager;
 
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -348,6 +350,8 @@
     private final TelephonyManager mTelephonyManager;
     private final WifiManager mWifiManager;
     private final ConnectivityManager mCm;
+    @Nullable
+    private final NetworkStackNotifier mNotifier;
     private final IpConnectivityLog mMetricsLog;
     private final Dependencies mDependencies;
     private final DataStallStatsUtils mDetectionStatsUtils;
@@ -431,8 +435,8 @@
     }
 
     public NetworkMonitor(Context context, INetworkMonitorCallbacks cb, Network network,
-            SharedLog validationLog) {
-        this(context, cb, network, new IpConnectivityLog(), validationLog,
+            SharedLog validationLog, @NonNull NetworkStackServiceManager serviceManager) {
+        this(context, cb, network, new IpConnectivityLog(), validationLog, serviceManager,
                 Dependencies.DEFAULT, new DataStallStatsUtils(),
                 getTcpSocketTrackerOrNull(context, network));
     }
@@ -440,8 +444,8 @@
     @VisibleForTesting
     public NetworkMonitor(Context context, INetworkMonitorCallbacks cb, Network network,
             IpConnectivityLog logger, SharedLog validationLogs,
-            Dependencies deps, DataStallStatsUtils detectionStatsUtils,
-            @Nullable TcpSocketTracker tst) {
+            @NonNull NetworkStackServiceManager serviceManager, Dependencies deps,
+            DataStallStatsUtils detectionStatsUtils, @Nullable TcpSocketTracker tst) {
         // Add suffix indicating which NetworkMonitor we're talking about.
         super(TAG + "/" + network.toString());
 
@@ -461,6 +465,7 @@
         mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
         mWifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
         mCm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+        mNotifier = serviceManager.getNotifier();
 
         // CHECKSTYLE:OFF IndentationCheck
         addState(mDefaultState);
@@ -962,6 +967,9 @@
                     }
                     appExtras.putString(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT,
                             mCaptivePortalUserAgent);
+                    if (mNotifier != null) {
+                        mNotifier.notifyCaptivePortalValidationPending(network);
+                    }
                     mCm.startCaptivePortalApp(network, appExtras);
                     return HANDLED;
                 default:
diff --git a/tests/integration/src/android/net/ip/IpClientIntegrationTest.java b/tests/integration/src/android/net/ip/IpClientIntegrationTest.java
index b3ca925..a60e32c 100644
--- a/tests/integration/src/android/net/ip/IpClientIntegrationTest.java
+++ b/tests/integration/src/android/net/ip/IpClientIntegrationTest.java
@@ -52,6 +52,7 @@
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
@@ -937,6 +938,24 @@
     }
 
     @Test
+    public void testInterfaceParams() throws Exception {
+        InterfaceParams params = InterfaceParams.getByName(mIfaceName);
+        assertNotNull(params);
+        assertEquals(mIfaceName, params.name);
+        assertTrue(params.index > 0);
+        assertNotNull(params.macAddr);
+        assertTrue(params.hasMacAddress);
+
+        // Sanity check.
+        params = InterfaceParams.getByName("lo");
+        assertNotNull(params);
+        assertEquals("lo", params.name);
+        assertTrue(params.index > 0);
+        assertNotNull(params.macAddr);
+        assertFalse(params.hasMacAddress);
+    }
+
+    @Test
     public void testDhcpInit() throws Exception {
         startIpClientProvisioning(false /* isDhcpLeaseCacheEnabled */,
                 false /* shouldReplyRapidCommitAck */, false /* isPreconnectionEnabled */,
diff --git a/tests/unit/src/android/net/util/InterfaceParamsTest.java b/tests/unit/src/android/net/util/InterfaceParamsTest.java
index 5a2b9c6..1be4368 100644
--- a/tests/unit/src/android/net/util/InterfaceParamsTest.java
+++ b/tests/unit/src/android/net/util/InterfaceParamsTest.java
@@ -17,6 +17,7 @@
 package android.net.util;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
@@ -49,6 +50,7 @@
         assertEquals("lo", ifParams.name);
         assertTrue(ifParams.index > 0);
         assertNotNull(ifParams.macAddr);
+        assertFalse(ifParams.hasMacAddress);
         assertTrue(ifParams.defaultMtu >= NetworkStackConstants.ETHER_MTU);
     }
 }
diff --git a/tests/unit/src/com/android/networkstack/NetworkStackNotifierTest.kt b/tests/unit/src/com/android/networkstack/NetworkStackNotifierTest.kt
new file mode 100644
index 0000000..352d185
--- /dev/null
+++ b/tests/unit/src/com/android/networkstack/NetworkStackNotifierTest.kt
@@ -0,0 +1,394 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.networkstack
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.NotificationManager.IMPORTANCE_DEFAULT
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.content.Context
+import android.content.Intent
+import android.content.res.Resources
+import android.net.CaptivePortalData
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.EXTRA_NETWORK
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.LinkProperties
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.Uri
+import android.os.Handler
+import android.os.UserHandle
+import android.provider.Settings
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.testing.TestableLooper.RunWithLooper
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.dx.mockito.inline.extended.ExtendedMockito.verify
+import com.android.networkstack.NetworkStackNotifier.CHANNEL_CONNECTED
+import com.android.networkstack.NetworkStackNotifier.CHANNEL_VENUE_INFO
+import com.android.networkstack.NetworkStackNotifier.Dependencies
+import com.android.networkstack.NetworkStackNotifier.MSG_DISMISS_CONNECTED
+import com.android.networkstack.apishim.NetworkInformationShimImpl
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito.any
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.MockitoAnnotations
+import kotlin.reflect.KClass
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+@RunWithLooper
+class NetworkStackNotifierTest {
+    @Mock
+    private lateinit var mContext: Context
+    @Mock
+    private lateinit var mCurrentUserContext: Context
+    @Mock
+    private lateinit var mAllUserContext: Context
+    @Mock
+    private lateinit var mDependencies: Dependencies
+    @Mock
+    private lateinit var mNm: NotificationManager
+    @Mock
+    private lateinit var mCm: ConnectivityManager
+    @Mock
+    private lateinit var mResources: Resources
+    @Mock
+    private lateinit var mPendingIntent: PendingIntent
+    @Captor
+    private lateinit var mNoteCaptor: ArgumentCaptor<Notification>
+    @Captor
+    private lateinit var mNoteIdCaptor: ArgumentCaptor<Int>
+    @Captor
+    private lateinit var mIntentCaptor: ArgumentCaptor<Intent>
+    private lateinit var mLooper: TestableLooper
+    private lateinit var mHandler: Handler
+    private lateinit var mNotifier: NetworkStackNotifier
+
+    private lateinit var mAllNetworksCb: NetworkCallback
+    private lateinit var mDefaultNetworkCb: NetworkCallback
+
+    private val TEST_NETWORK = Network(42)
+    private val TEST_NETWORK_TAG = TEST_NETWORK.networkHandle.toString()
+    private val TEST_SSID = "TestSsid"
+    private val EMPTY_CAPABILITIES = NetworkCapabilities()
+    private val VALIDATED_CAPABILITIES = NetworkCapabilities()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NET_CAPABILITY_VALIDATED)
+
+    private val TEST_CONNECTED_NO_SSID_TITLE = "Connected without SSID"
+    private val TEST_CONNECTED_SSID_TITLE = "Connected to TestSsid"
+
+    private val TEST_VENUE_INFO_URL = "https://testvenue.example.com/info"
+    private val EMPTY_CAPPORT_LP = LinkProperties()
+    private val TEST_CAPPORT_LP = LinkProperties().apply {
+        captivePortalData = CaptivePortalData.Builder()
+                .setCaptive(false)
+                .setVenueInfoUrl(Uri.parse(TEST_VENUE_INFO_URL))
+                .build()
+    }
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        mLooper = TestableLooper.get(this)
+        doReturn(mResources).`when`(mContext).resources
+        doReturn(TEST_CONNECTED_NO_SSID_TITLE).`when`(mResources).getString(R.string.connected)
+        doReturn(TEST_CONNECTED_SSID_TITLE).`when`(mResources).getString(
+                R.string.connected_to_ssid_param1, TEST_SSID)
+
+        // applicationInfo is used by Notification.Builder
+        val realContext = InstrumentationRegistry.getInstrumentation().context
+        doReturn(realContext.applicationInfo).`when`(mContext).applicationInfo
+        doReturn(realContext.packageName).`when`(mContext).packageName
+
+        doReturn(mCurrentUserContext).`when`(mContext).createPackageContextAsUser(
+                realContext.packageName, 0, UserHandle.CURRENT)
+        doReturn(mAllUserContext).`when`(mContext).createPackageContextAsUser(
+                realContext.packageName, 0, UserHandle.ALL)
+
+        mAllUserContext.mockService(Context.NOTIFICATION_SERVICE, NotificationManager::class, mNm)
+        mContext.mockService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class, mCm)
+
+        doReturn(NotificationChannel(CHANNEL_VENUE_INFO, "TestChannel", IMPORTANCE_DEFAULT))
+                .`when`(mNm).getNotificationChannel(CHANNEL_VENUE_INFO)
+
+        doReturn(mPendingIntent).`when`(mDependencies).getActivityPendingIntent(
+                any(), any(), anyInt())
+        mNotifier = NetworkStackNotifier(mContext, mLooper.looper, mDependencies)
+        mHandler = mNotifier.handler
+
+        val allNetworksCbCaptor = ArgumentCaptor.forClass(NetworkCallback::class.java)
+        verify(mCm).registerNetworkCallback(any() /* request */, allNetworksCbCaptor.capture(),
+                eq(mHandler))
+        mAllNetworksCb = allNetworksCbCaptor.value
+
+        val defaultNetworkCbCaptor = ArgumentCaptor.forClass(NetworkCallback::class.java)
+        verify(mCm).registerDefaultNetworkCallback(defaultNetworkCbCaptor.capture(), eq(mHandler))
+        mDefaultNetworkCb = defaultNetworkCbCaptor.value
+    }
+
+    private fun <T : Any> Context.mockService(name: String, clazz: KClass<T>, service: T) {
+        doReturn(service).`when`(this).getSystemService(name)
+        doReturn(name).`when`(this).getSystemServiceName(clazz.java)
+        doReturn(service).`when`(this).getSystemService(clazz.java)
+    }
+
+    @Test
+    fun testNoNotification() {
+        onCapabilitiesChanged(EMPTY_CAPABILITIES)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+
+        mLooper.processAllMessages()
+        verify(mNm, never()).notify(any(), anyInt(), any())
+    }
+
+    private fun verifyConnectedNotification() {
+        verify(mNm).notify(eq(TEST_NETWORK_TAG), mNoteIdCaptor.capture(), mNoteCaptor.capture())
+        val note = mNoteCaptor.value
+        assertEquals(mPendingIntent, note.contentIntent)
+        assertEquals(CHANNEL_CONNECTED, note.channelId)
+        verify(mDependencies).getActivityPendingIntent(
+                eq(mCurrentUserContext), mIntentCaptor.capture(), eq(FLAG_UPDATE_CURRENT))
+    }
+
+    private fun verifyDismissConnectedNotification(noteId: Int) {
+        assertTrue(mHandler.hasMessages(MSG_DISMISS_CONNECTED, TEST_NETWORK))
+        // Execute dismiss message now
+        mHandler.sendMessageAtFrontOfQueue(
+                mHandler.obtainMessage(MSG_DISMISS_CONNECTED, TEST_NETWORK))
+        mLooper.processMessages(1)
+        verify(mNm).cancel(TEST_NETWORK_TAG, noteId)
+    }
+
+    @Test
+    fun testConnectedNotification_NoSsid() {
+        onCapabilitiesChanged(EMPTY_CAPABILITIES)
+        mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+        mLooper.processAllMessages()
+
+        verifyConnectedNotification()
+        verify(mResources).getString(R.string.connected)
+        verifyWifiSettingsIntent(mIntentCaptor.value)
+        verifyDismissConnectedNotification(mNoteIdCaptor.value)
+    }
+
+    @Test
+    fun testConnectedNotification_WithSsid() {
+        // NetworkCapabilities#getSSID is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        val capabilities = NetworkCapabilities(VALIDATED_CAPABILITIES)
+                .setSSID(TEST_SSID)
+
+        onCapabilitiesChanged(EMPTY_CAPABILITIES)
+        mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK)
+        onCapabilitiesChanged(capabilities)
+        mLooper.processAllMessages()
+
+        verifyConnectedNotification()
+        verify(mResources).getString(R.string.connected_to_ssid_param1, TEST_SSID)
+        verifyWifiSettingsIntent(mIntentCaptor.value)
+        verifyDismissConnectedNotification(mNoteIdCaptor.value)
+    }
+
+    @Test
+    fun testConnectedNotification_WithNonWifiNetwork() {
+        // NetworkCapabilities#getSSID is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        val capabilities = NetworkCapabilities()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_VALIDATED)
+                .setSSID(TEST_SSID)
+
+        onCapabilitiesChanged(EMPTY_CAPABILITIES)
+        mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK)
+        onCapabilitiesChanged(capabilities)
+        mLooper.processAllMessages()
+
+        verify(mNm).notify(eq(TEST_NETWORK_TAG), mNoteIdCaptor.capture(), mNoteCaptor.capture())
+        val note = mNoteCaptor.value
+        assertNull(note.contentIntent)
+        assertEquals(CHANNEL_CONNECTED, note.channelId)
+        verify(mResources).getString(R.string.connected_to_ssid_param1, TEST_SSID)
+        verifyDismissConnectedNotification(mNoteIdCaptor.value)
+    }
+
+    @Test
+    fun testConnectedVenueInfoNotification() {
+        // Venue info (CaptivePortalData) is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK)
+        onLinkPropertiesChanged(TEST_CAPPORT_LP)
+        onDefaultNetworkAvailable(TEST_NETWORK)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+
+        mLooper.processAllMessages()
+
+        verify(mNm).notify(eq(TEST_NETWORK_TAG), mNoteIdCaptor.capture(), mNoteCaptor.capture())
+
+        verifyConnectedNotification()
+        verifyVenueInfoIntent(mIntentCaptor.value)
+        verify(mResources).getString(R.string.tap_for_info)
+
+        onDefaultNetworkLost(TEST_NETWORK)
+        mLooper.processAllMessages()
+        // Notification only shown on default network
+        verify(mNm).cancel(TEST_NETWORK_TAG, mNoteIdCaptor.value)
+    }
+
+    @Test
+    fun testConnectedVenueInfoNotification_VenueInfoDisabled() {
+        // Venue info (CaptivePortalData) is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        doReturn(null).`when`(mNm).getNotificationChannel(CHANNEL_VENUE_INFO)
+        mNotifier.notifyCaptivePortalValidationPending(TEST_NETWORK)
+        onLinkPropertiesChanged(TEST_CAPPORT_LP)
+        onDefaultNetworkAvailable(TEST_NETWORK)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+        mLooper.processAllMessages()
+
+        verifyConnectedNotification()
+        verifyWifiSettingsIntent(mIntentCaptor.value)
+        verify(mResources, never()).getString(R.string.tap_for_info)
+        verifyDismissConnectedNotification(mNoteIdCaptor.value)
+    }
+
+    @Test
+    fun testVenueInfoNotification() {
+        // Venue info (CaptivePortalData) is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        onLinkPropertiesChanged(TEST_CAPPORT_LP)
+        onDefaultNetworkAvailable(TEST_NETWORK)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+        mLooper.processAllMessages()
+
+        verify(mNm).notify(eq(TEST_NETWORK_TAG), mNoteIdCaptor.capture(), mNoteCaptor.capture())
+        verify(mDependencies).getActivityPendingIntent(
+                eq(mCurrentUserContext), mIntentCaptor.capture(), eq(FLAG_UPDATE_CURRENT))
+        verifyVenueInfoIntent(mIntentCaptor.value)
+
+        onLost(TEST_NETWORK)
+        mLooper.processAllMessages()
+        verify(mNm).cancel(TEST_NETWORK_TAG, mNoteIdCaptor.value)
+    }
+
+    @Test
+    fun testVenueInfoNotification_VenueInfoDisabled() {
+        // Venue info (CaptivePortalData) is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        doReturn(null).`when`(mNm).getNotificationChannel(CHANNEL_VENUE_INFO)
+        onLinkPropertiesChanged(TEST_CAPPORT_LP)
+        onDefaultNetworkAvailable(TEST_NETWORK)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+        mLooper.processAllMessages()
+
+        verify(mNm, never()).notify(any(), anyInt(), any())
+    }
+
+    @Test
+    fun testNonDefaultVenueInfoNotification() {
+        // Venue info (CaptivePortalData) is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        onLinkPropertiesChanged(TEST_CAPPORT_LP)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+        mLooper.processAllMessages()
+
+        verify(mNm, never()).notify(eq(TEST_NETWORK_TAG), anyInt(), any())
+    }
+
+    @Test
+    fun testEmptyCaptivePortalDataVenueInfoNotification() {
+        // Venue info (CaptivePortalData) is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        onLinkPropertiesChanged(EMPTY_CAPPORT_LP)
+        onCapabilitiesChanged(VALIDATED_CAPABILITIES)
+        mLooper.processAllMessages()
+
+        verify(mNm, never()).notify(eq(TEST_NETWORK_TAG), anyInt(), any())
+    }
+
+    @Test
+    fun testUnvalidatedNetworkVenueInfoNotification() {
+        // Venue info (CaptivePortalData) is not available for API <= Q
+        assumeTrue(NetworkInformationShimImpl.useApiAboveQ())
+        onLinkPropertiesChanged(TEST_CAPPORT_LP)
+        onCapabilitiesChanged(EMPTY_CAPABILITIES)
+        mLooper.processAllMessages()
+
+        verify(mNm, never()).notify(eq(TEST_NETWORK_TAG), anyInt(), any())
+    }
+
+    private fun verifyVenueInfoIntent(intent: Intent) {
+        assertEquals(Intent.ACTION_VIEW, intent.action)
+        assertEquals(Uri.parse(TEST_VENUE_INFO_URL), intent.data)
+        assertEquals<Network?>(TEST_NETWORK, intent.getParcelableExtra(EXTRA_NETWORK))
+    }
+
+    private fun verifyWifiSettingsIntent(intent: Intent) {
+        assertEquals(Settings.ACTION_WIFI_SETTINGS, intent.action)
+    }
+
+    private fun onDefaultNetworkAvailable(network: Network) {
+        mHandler.post {
+            mDefaultNetworkCb.onAvailable(network)
+        }
+    }
+
+    private fun onDefaultNetworkLost(network: Network) {
+        mHandler.post {
+            mDefaultNetworkCb.onLost(network)
+        }
+    }
+
+    private fun onCapabilitiesChanged(capabilities: NetworkCapabilities) {
+        mHandler.post {
+            mAllNetworksCb.onCapabilitiesChanged(TEST_NETWORK, capabilities)
+        }
+    }
+
+    private fun onLinkPropertiesChanged(lp: LinkProperties) {
+        mHandler.post {
+            mAllNetworksCb.onLinkPropertiesChanged(TEST_NETWORK, lp)
+        }
+    }
+
+    private fun onLost(network: Network) {
+        mHandler.post {
+            mAllNetworksCb.onLost(network)
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java b/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
index 64a8485..f61bb7e 100644
--- a/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
+++ b/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
@@ -124,12 +124,14 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.networkstack.NetworkStackNotifier;
 import com.android.networkstack.R;
 import com.android.networkstack.apishim.CaptivePortalDataShimImpl;
 import com.android.networkstack.apishim.ShimUtils;
 import com.android.networkstack.metrics.DataStallDetectionStats;
 import com.android.networkstack.metrics.DataStallStatsUtils;
 import com.android.networkstack.netlink.TcpSocketTracker;
+import com.android.server.NetworkStackService.NetworkStackServiceManager;
 import com.android.testutils.HandlerUtilsKt;
 
 import org.junit.After;
@@ -182,6 +184,8 @@
     private @Mock ConnectivityManager mCm;
     private @Mock TelephonyManager mTelephony;
     private @Mock WifiManager mWifi;
+    private @Mock NetworkStackServiceManager mServiceManager;
+    private @Mock NetworkStackNotifier mNotifier;
     private @Mock HttpURLConnection mHttpConnection;
     private @Mock HttpURLConnection mHttpsConnection;
     private @Mock HttpURLConnection mFallbackConnection;
@@ -410,6 +414,8 @@
         when(mContext.getSystemService(Context.WIFI_SERVICE)).thenReturn(mWifi);
         when(mContext.getResources()).thenReturn(mResources);
 
+        when(mServiceManager.getNotifier()).thenReturn(mNotifier);
+
         when(mResources.getString(anyInt())).thenReturn("");
         when(mResources.getStringArray(anyInt())).thenReturn(new String[0]);
 
@@ -513,7 +519,7 @@
         private final ConditionVariable mQuitCv = new ConditionVariable(false);
 
         WrappedNetworkMonitor() {
-            super(mContext, mCallbacks, mNetwork, mLogger, mValidationLogger,
+            super(mContext, mCallbacks, mNetwork, mLogger, mValidationLogger, mServiceManager,
                     mDependencies, mDataStallStatsUtils, mTst);
         }
 
@@ -1099,6 +1105,7 @@
         final ArgumentCaptor<Network> networkCaptor = ArgumentCaptor.forClass(Network.class);
         verify(mCm, timeout(HANDLER_TIMEOUT_MS).times(1))
                 .startCaptivePortalApp(networkCaptor.capture(), bundleCaptor.capture());
+        verify(mNotifier).notifyCaptivePortalValidationPending(networkCaptor.getValue());
         final Bundle bundle = bundleCaptor.getValue();
         final Network bundleNetwork = bundle.getParcelable(ConnectivityManager.EXTRA_NETWORK);
         assertEquals(TEST_NETID, bundleNetwork.netId);