Move NetworkMonitor to NetworkStack

Bug: b/112869080
Test: atest FrameworksNetTests NetworkStackTests
Change-Id: I1b8b6a3f4390adbabf92fb9e48da61c47b08b2ec
diff --git a/Android.bp b/Android.bp
index 4688848..2f7d599 100644
--- a/Android.bp
+++ b/Android.bp
@@ -21,10 +21,10 @@
     installable: true,
     srcs: [
         "src/**/*.java",
+        ":services-networkstack-shared-srcs",
     ],
     static_libs: [
         "dhcp-packet-lib",
-        "frameworks-net-shared-utils",
     ]
 }
 
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 8516d94..0b0f1ec 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -22,8 +22,11 @@
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+    <uses-permission android:name="android.permission.CONNECTIVITY_INTERNAL" />
+    <uses-permission android:name="android.permission.NETWORK_SETTINGS" />
     <!-- Launch captive portal app as specific user -->
     <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL" />
+    <uses-permission android:name="android.permission.NETWORK_STACK" />
     <application
         android:label="NetworkStack"
         android:defaultToDeviceProtectedStorage="true"
diff --git a/src/android/net/util/SharedLog.java b/src/android/net/util/SharedLog.java
index 74bc147..4fabf10 100644
--- a/src/android/net/util/SharedLog.java
+++ b/src/android/net/util/SharedLog.java
@@ -69,6 +69,10 @@
         mComponent = component;
     }
 
+    public String getTag() {
+        return mTag;
+    }
+
     /**
      * Create a SharedLog based on this log with an additional component prefix on each logged line.
      */
diff --git a/src/android/net/util/Stopwatch.java b/src/android/net/util/Stopwatch.java
new file mode 100644
index 0000000..c316699
--- /dev/null
+++ b/src/android/net/util/Stopwatch.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2016 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.util;
+
+import android.os.SystemClock;
+
+
+/**
+ * @hide
+ */
+public class Stopwatch {
+    private long mStartTimeMs;
+    private long mStopTimeMs;
+
+    public boolean isStarted() {
+        return (mStartTimeMs > 0);
+    }
+
+    public boolean isStopped() {
+        return (mStopTimeMs > 0);
+    }
+
+    public boolean isRunning() {
+        return (isStarted() && !isStopped());
+    }
+
+    /**
+     * Start the Stopwatch.
+     */
+    public Stopwatch start() {
+        if (!isStarted()) {
+            mStartTimeMs = SystemClock.elapsedRealtime();
+        }
+        return this;
+    }
+
+    /**
+     * Stop the Stopwatch.
+     * @return the total time recorded, in milliseconds, or 0 if not started.
+     */
+    public long stop() {
+        if (isRunning()) {
+            mStopTimeMs = SystemClock.elapsedRealtime();
+        }
+        // Return either the delta after having stopped, or 0.
+        return (mStopTimeMs - mStartTimeMs);
+    }
+
+    /**
+     * Return the total time recorded to date, in milliseconds.
+     * If the Stopwatch is not running, returns the same value as stop(),
+     * i.e. either the total time recorded before stopping or 0.
+     */
+    public long lap() {
+        if (isRunning()) {
+            return (SystemClock.elapsedRealtime() - mStartTimeMs);
+        } else {
+            return stop();
+        }
+    }
+
+    /**
+     * Reset the Stopwatch. It will be stopped when this method returns.
+     */
+    public void reset() {
+        mStartTimeMs = 0;
+        mStopTimeMs = 0;
+    }
+}
diff --git a/src/com/android/server/NetworkStackService.java b/src/com/android/server/NetworkStackService.java
index 7fea1e0..057012d 100644
--- a/src/com/android/server/NetworkStackService.java
+++ b/src/com/android/server/NetworkStackService.java
@@ -25,18 +25,31 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.app.Service;
+import android.content.Context;
 import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.INetworkMonitor;
+import android.net.INetworkMonitorCallbacks;
 import android.net.INetworkStackConnector;
+import android.net.Network;
+import android.net.NetworkRequest;
+import android.net.PrivateDnsConfigParcel;
 import android.net.dhcp.DhcpServer;
 import android.net.dhcp.DhcpServingParams;
 import android.net.dhcp.DhcpServingParamsParcel;
 import android.net.dhcp.IDhcpServerCallbacks;
+import android.net.shared.PrivateDnsConfig;
 import android.net.util.SharedLog;
 import android.os.IBinder;
 import android.os.RemoteException;
 
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.connectivity.NetworkMonitor;
+
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
+import java.util.ArrayDeque;
 
 /**
  * Android service used to start the network stack when bound to via an intent.
@@ -52,17 +65,41 @@
      * <p>On platforms where the network stack runs in the system server process, this method may
      * be called directly instead of obtaining the connector by binding to the service.
      */
-    public static IBinder makeConnector() {
-        return new NetworkStackConnector();
+    public static IBinder makeConnector(Context context) {
+        return new NetworkStackConnector(context);
     }
 
     @NonNull
     @Override
     public IBinder onBind(Intent intent) {
-        return makeConnector();
+        return makeConnector(this);
     }
 
     private static class NetworkStackConnector extends INetworkStackConnector.Stub {
+        private static final int NUM_VALIDATION_LOG_LINES = 20;
+        private final Context mContext;
+        private final ConnectivityManager mCm;
+
+        private static final int MAX_VALIDATION_LOGS = 10;
+        @GuardedBy("mValidationLogs")
+        private final ArrayDeque<SharedLog> mValidationLogs = new ArrayDeque<>(MAX_VALIDATION_LOGS);
+
+        private SharedLog addValidationLogs(Network network, String name) {
+            final SharedLog log = new SharedLog(NUM_VALIDATION_LOG_LINES, network + " - " + name);
+            synchronized (mValidationLogs) {
+                while (mValidationLogs.size() >= MAX_VALIDATION_LOGS) {
+                    mValidationLogs.removeLast();
+                }
+                mValidationLogs.addFirst(log);
+            }
+            return log;
+        }
+
+        NetworkStackConnector(Context context) {
+            mContext = context;
+            mCm = context.getSystemService(ConnectivityManager.class);
+        }
+
         @NonNull
         private final SharedLog mLog = new SharedLog(TAG);
 
@@ -89,11 +126,102 @@
         }
 
         @Override
+        public void makeNetworkMonitor(int netId, String name, INetworkMonitorCallbacks cb)
+                throws RemoteException {
+            final Network network = new Network(netId, false /* privateDnsBypass */);
+            final NetworkRequest defaultRequest = mCm.getDefaultRequest();
+            final SharedLog log = addValidationLogs(network, name);
+            final NetworkMonitor nm = new NetworkMonitor(
+                    mContext, cb, network, defaultRequest, log);
+            cb.onNetworkMonitorCreated(new NetworkMonitorImpl(nm));
+        }
+
+        @Override
         protected void dump(@NonNull FileDescriptor fd, @NonNull PrintWriter fout,
                 @Nullable String[] args) {
             checkNetworkStackCallingPermission();
-            fout.println("NetworkStack logs:");
-            mLog.dump(fd, fout, args);
+            final IndentingPrintWriter pw = new IndentingPrintWriter(fout, "  ");
+            pw.println("NetworkStack logs:");
+            mLog.dump(fd, pw, args);
+
+            pw.println();
+            pw.println("Validation logs (most recent first):");
+            synchronized (mValidationLogs) {
+                for (SharedLog p : mValidationLogs) {
+                    pw.println(p.getTag());
+                    pw.increaseIndent();
+                    p.dump(fd, pw, args);
+                    pw.decreaseIndent();
+                }
+            }
+        }
+    }
+
+    private static class NetworkMonitorImpl extends INetworkMonitor.Stub {
+        private final NetworkMonitor mNm;
+
+        NetworkMonitorImpl(NetworkMonitor nm) {
+            mNm = nm;
+        }
+
+        @Override
+        public void start() {
+            checkNetworkStackCallingPermission();
+            mNm.start();
+        }
+
+        @Override
+        public void launchCaptivePortalApp() {
+            checkNetworkStackCallingPermission();
+            mNm.launchCaptivePortalApp();
+        }
+
+        @Override
+        public void forceReevaluation(int uid) {
+            checkNetworkStackCallingPermission();
+            mNm.forceReevaluation(uid);
+        }
+
+        @Override
+        public void notifyPrivateDnsChanged(PrivateDnsConfigParcel config) {
+            checkNetworkStackCallingPermission();
+            mNm.notifyPrivateDnsSettingsChanged(PrivateDnsConfig.fromParcel(config));
+        }
+
+        @Override
+        public void notifyDnsResponse(int returnCode) {
+            checkNetworkStackCallingPermission();
+            mNm.notifyDnsResponse(returnCode);
+        }
+
+        @Override
+        public void notifySystemReady() {
+            checkNetworkStackCallingPermission();
+            mNm.notifySystemReady();
+        }
+
+        @Override
+        public void notifyNetworkConnected() {
+            checkNetworkStackCallingPermission();
+            mNm.notifyNetworkConnected();
+        }
+
+        @Override
+        public void notifyNetworkDisconnected() {
+            checkNetworkStackCallingPermission();
+            mNm.notifyNetworkDisconnected();
+        }
+
+        @Override
+        public void notifyLinkPropertiesChanged() {
+            checkNetworkStackCallingPermission();
+            mNm.notifyLinkPropertiesChanged();
+        }
+
+        @Override
+        public void notifyNetworkCapabilitiesChanged() {
+            checkNetworkStackCallingPermission();
+            mNm.notifyNetworkCapabilitiesChanged();
         }
     }
 }
diff --git a/src/com/android/server/connectivity/NetworkMonitor.java b/src/com/android/server/connectivity/NetworkMonitor.java
new file mode 100644
index 0000000..94ea1b9
--- /dev/null
+++ b/src/com/android/server/connectivity/NetworkMonitor.java
@@ -0,0 +1,1767 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity;
+
+import static android.net.CaptivePortal.APP_RETURN_DISMISSED;
+import static android.net.CaptivePortal.APP_RETURN_UNWANTED;
+import static android.net.CaptivePortal.APP_RETURN_WANTED_AS_IS;
+import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL_PROBE_SPEC;
+import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL;
+import static android.net.ConnectivityManager.TYPE_MOBILE;
+import static android.net.ConnectivityManager.TYPE_WIFI;
+import static android.net.INetworkMonitor.NETWORK_TEST_RESULT_INVALID;
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.net.metrics.ValidationProbeEvent.DNS_FAILURE;
+import static android.net.metrics.ValidationProbeEvent.DNS_SUCCESS;
+import static android.net.metrics.ValidationProbeEvent.PROBE_FALLBACK;
+import static android.net.metrics.ValidationProbeEvent.PROBE_PRIVDNS;
+
+import android.annotation.Nullable;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.CaptivePortal;
+import android.net.ConnectivityManager;
+import android.net.ICaptivePortal;
+import android.net.INetworkMonitor;
+import android.net.INetworkMonitorCallbacks;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.net.ProxyInfo;
+import android.net.TrafficStats;
+import android.net.Uri;
+import android.net.captiveportal.CaptivePortalProbeResult;
+import android.net.captiveportal.CaptivePortalProbeSpec;
+import android.net.metrics.IpConnectivityLog;
+import android.net.metrics.NetworkEvent;
+import android.net.metrics.ValidationProbeEvent;
+import android.net.shared.NetworkMonitorUtils;
+import android.net.shared.PrivateDnsConfig;
+import android.net.util.SharedLog;
+import android.net.util.Stopwatch;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.os.Message;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.telephony.CellIdentityCdma;
+import android.telephony.CellIdentityGsm;
+import android.telephony.CellIdentityLte;
+import android.telephony.CellIdentityWcdma;
+import android.telephony.CellInfo;
+import android.telephony.CellInfoCdma;
+import android.telephony.CellInfoGsm;
+import android.telephony.CellInfoLte;
+import android.telephony.CellInfoWcdma;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.ArrayUtils;
+import com.android.internal.util.Protocol;
+import com.android.internal.util.RingBufferIndices;
+import com.android.internal.util.State;
+import com.android.internal.util.StateMachine;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.InetAddress;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * {@hide}
+ */
+public class NetworkMonitor extends StateMachine {
+    private static final String TAG = NetworkMonitor.class.getSimpleName();
+    private static final boolean DBG  = true;
+    private static final boolean VDBG = false;
+    private static final boolean VDBG_STALL = Log.isLoggable(TAG, Log.DEBUG);
+    // Default configuration values for captive portal detection probes.
+    // TODO: append a random length parameter to the default HTTPS url.
+    // TODO: randomize browser version ids in the default User-Agent String.
+    private static final String DEFAULT_HTTPS_URL = "https://www.google.com/generate_204";
+    private static final String DEFAULT_FALLBACK_URL  = "http://www.google.com/gen_204";
+    private static final String DEFAULT_OTHER_FALLBACK_URLS =
+            "http://play.googleapis.com/generate_204";
+    private static final String DEFAULT_USER_AGENT    = "Mozilla/5.0 (X11; Linux x86_64) "
+                                                      + "AppleWebKit/537.36 (KHTML, like Gecko) "
+                                                      + "Chrome/60.0.3112.32 Safari/537.36";
+
+    private static final int SOCKET_TIMEOUT_MS = 10000;
+    private static final int PROBE_TIMEOUT_MS  = 3000;
+
+    // Default configuration values for data stall detection.
+    private static final int DEFAULT_CONSECUTIVE_DNS_TIMEOUT_THRESHOLD = 5;
+    private static final int DEFAULT_DATA_STALL_MIN_EVALUATE_TIME_MS = 60 * 1000;
+    private static final int DEFAULT_DATA_STALL_VALID_DNS_TIME_THRESHOLD_MS = 30 * 60 * 1000;
+
+    private static final int DATA_STALL_EVALUATION_TYPE_DNS = 1;
+    private static final int DEFAULT_DATA_STALL_EVALUATION_TYPES =
+            (1 << DATA_STALL_EVALUATION_TYPE_DNS);
+
+    enum EvaluationResult {
+        VALIDATED(true),
+        CAPTIVE_PORTAL(false);
+        final boolean mIsValidated;
+        EvaluationResult(boolean isValidated) {
+            this.mIsValidated = isValidated;
+        }
+    }
+
+    enum ValidationStage {
+        FIRST_VALIDATION(true),
+        REVALIDATION(false);
+        final boolean mIsFirstValidation;
+        ValidationStage(boolean isFirstValidation) {
+            this.mIsFirstValidation = isFirstValidation;
+        }
+    }
+
+    private static final int BASE = Protocol.BASE_NETWORK_MONITOR;
+    /**
+     * ConnectivityService has sent a notification to indicate that network has connected.
+     * Initiates Network Validation.
+     */
+    private static final int CMD_NETWORK_CONNECTED = BASE + 1;
+
+    /**
+     * Message to self indicating it's time to evaluate a network's connectivity.
+     * arg1 = Token to ignore old messages.
+     */
+    private static final int CMD_REEVALUATE = BASE + 6;
+
+    /**
+     * ConnectivityService has sent a notification to indicate that network has disconnected.
+     */
+    private static final int CMD_NETWORK_DISCONNECTED = BASE + 7;
+
+    /**
+     * Force evaluation even if it has succeeded in the past.
+     * arg1 = UID responsible for requesting this reeval.  Will be billed for data.
+     */
+    private static final int CMD_FORCE_REEVALUATION = BASE + 8;
+
+    /**
+     * Message to self indicating captive portal app finished.
+     * arg1 = one of: APP_RETURN_DISMISSED,
+     *                APP_RETURN_UNWANTED,
+     *                APP_RETURN_WANTED_AS_IS
+     * obj = mCaptivePortalLoggedInResponseToken as String
+     */
+    private static final int CMD_CAPTIVE_PORTAL_APP_FINISHED = BASE + 9;
+
+    /**
+     * Message indicating sign-in app should be launched.
+     * Sent by mLaunchCaptivePortalAppBroadcastReceiver when the
+     * user touches the sign in notification, or sent by
+     * ConnectivityService when the user touches the "sign into
+     * network" button in the wifi access point detail page.
+     */
+    private static final int CMD_LAUNCH_CAPTIVE_PORTAL_APP = BASE + 11;
+
+    /**
+     * Retest network to see if captive portal is still in place.
+     * arg1 = UID responsible for requesting this reeval.  Will be billed for data.
+     *        0 indicates self-initiated, so nobody to blame.
+     */
+    private static final int CMD_CAPTIVE_PORTAL_RECHECK = BASE + 12;
+
+    /**
+     * ConnectivityService notifies NetworkMonitor of settings changes to
+     * Private DNS. If a DNS resolution is required, e.g. for DNS-over-TLS in
+     * strict mode, then an event is sent back to ConnectivityService with the
+     * result of the resolution attempt.
+     *
+     * A separate message is used to trigger (re)evaluation of the Private DNS
+     * configuration, so that the message can be handled as needed in different
+     * states, including being ignored until after an ongoing captive portal
+     * validation phase is completed.
+     */
+    private static final int CMD_PRIVATE_DNS_SETTINGS_CHANGED = BASE + 13;
+    private static final int CMD_EVALUATE_PRIVATE_DNS = BASE + 15;
+
+    /**
+     * Message to self indicating captive portal detection is completed.
+     * obj = CaptivePortalProbeResult for detection result;
+     */
+    public static final int CMD_PROBE_COMPLETE = BASE + 16;
+
+    /**
+     * ConnectivityService notifies NetworkMonitor of DNS query responses event.
+     * arg1 = returncode in OnDnsEvent which indicates the response code for the DNS query.
+     */
+    public static final int EVENT_DNS_NOTIFICATION = BASE + 17;
+
+    // Start mReevaluateDelayMs at this value and double.
+    private static final int INITIAL_REEVALUATE_DELAY_MS = 1000;
+    private static final int MAX_REEVALUATE_DELAY_MS = 10 * 60 * 1000;
+    // Before network has been evaluated this many times, ignore repeated reevaluate requests.
+    private static final int IGNORE_REEVALUATE_ATTEMPTS = 5;
+    private int mReevaluateToken = 0;
+    private static final int NO_UID = 0;
+    private static final int INVALID_UID = -1;
+    private int mUidResponsibleForReeval = INVALID_UID;
+    // Stop blaming UID that requested re-evaluation after this many attempts.
+    private static final int BLAME_FOR_EVALUATION_ATTEMPTS = 5;
+    // Delay between reevaluations once a captive portal has been found.
+    private static final int CAPTIVE_PORTAL_REEVALUATE_DELAY_MS = 10 * 60 * 1000;
+
+    private String mPrivateDnsProviderHostname = "";
+
+    private final Context mContext;
+    private final INetworkMonitorCallbacks mCallback;
+    private final Network mNetwork;
+    private final Network mNonPrivateDnsBypassNetwork;
+    private final int mNetId;
+    private final TelephonyManager mTelephonyManager;
+    private final WifiManager mWifiManager;
+    private final ConnectivityManager mCm;
+    private final NetworkRequest mDefaultRequest;
+    private final IpConnectivityLog mMetricsLog;
+    private final Dependencies mDependencies;
+
+    // Configuration values for captive portal detection probes.
+    private final String mCaptivePortalUserAgent;
+    private final URL mCaptivePortalHttpsUrl;
+    private final URL mCaptivePortalHttpUrl;
+    private final URL[] mCaptivePortalFallbackUrls;
+    @Nullable
+    private final CaptivePortalProbeSpec[] mCaptivePortalFallbackSpecs;
+
+    private NetworkCapabilities mNetworkCapabilities;
+    private LinkProperties mLinkProperties;
+
+    @VisibleForTesting
+    protected boolean mIsCaptivePortalCheckEnabled;
+
+    private boolean mUseHttps;
+    // The total number of captive portal detection attempts for this NetworkMonitor instance.
+    private int mValidations = 0;
+
+    // Set if the user explicitly selected "Do not use this network" in captive portal sign-in app.
+    private boolean mUserDoesNotWant = false;
+    // Avoids surfacing "Sign in to network" notification.
+    private boolean mDontDisplaySigninNotification = false;
+
+    private volatile boolean mSystemReady = false;
+
+    private final State mDefaultState = new DefaultState();
+    private final State mValidatedState = new ValidatedState();
+    private final State mMaybeNotifyState = new MaybeNotifyState();
+    private final State mEvaluatingState = new EvaluatingState();
+    private final State mCaptivePortalState = new CaptivePortalState();
+    private final State mEvaluatingPrivateDnsState = new EvaluatingPrivateDnsState();
+    private final State mProbingState = new ProbingState();
+    private final State mWaitingForNextProbeState = new WaitingForNextProbeState();
+
+    private CustomIntentReceiver mLaunchCaptivePortalAppBroadcastReceiver = null;
+
+    private final SharedLog mValidationLogs;
+
+    private final Stopwatch mEvaluationTimer = new Stopwatch();
+
+    // This variable is set before transitioning to the mCaptivePortalState.
+    private CaptivePortalProbeResult mLastPortalProbeResult = CaptivePortalProbeResult.FAILED;
+
+    // Random generator to select fallback URL index
+    private final Random mRandom;
+    private int mNextFallbackUrlIndex = 0;
+
+
+    private int mReevaluateDelayMs = INITIAL_REEVALUATE_DELAY_MS;
+    private int mEvaluateAttempts = 0;
+    private volatile int mProbeToken = 0;
+    private final int mConsecutiveDnsTimeoutThreshold;
+    private final int mDataStallMinEvaluateTime;
+    private final int mDataStallValidDnsTimeThreshold;
+    private final int mDataStallEvaluationType;
+    private final DnsStallDetector mDnsStallDetector;
+    private long mLastProbeTime;
+
+    public NetworkMonitor(Context context, INetworkMonitorCallbacks cb, Network network,
+            NetworkRequest defaultRequest, SharedLog validationLog) {
+        this(context, cb, network, defaultRequest, new IpConnectivityLog(), validationLog,
+                Dependencies.DEFAULT);
+    }
+
+    @VisibleForTesting
+    protected NetworkMonitor(Context context, INetworkMonitorCallbacks cb, Network network,
+            NetworkRequest defaultRequest, IpConnectivityLog logger, SharedLog validationLogs,
+            Dependencies deps) {
+        // Add suffix indicating which NetworkMonitor we're talking about.
+        super(TAG + "/" + network.netId);
+
+        // Logs with a tag of the form given just above, e.g.
+        //     <timestamp>   862  2402 D NetworkMonitor/NetworkAgentInfo [WIFI () - 100]: ...
+        setDbg(VDBG);
+
+        mContext = context;
+        mMetricsLog = logger;
+        mValidationLogs = validationLogs;
+        mCallback = cb;
+        mDependencies = deps;
+        mNonPrivateDnsBypassNetwork = network;
+        mNetwork = deps.getPrivateDnsBypassNetwork(network);
+        mNetId = mNetwork.netId;
+        mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+        mWifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
+        mCm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+        mDefaultRequest = defaultRequest;
+
+        // CHECKSTYLE:OFF IndentationCheck
+        addState(mDefaultState);
+        addState(mMaybeNotifyState, mDefaultState);
+            addState(mEvaluatingState, mMaybeNotifyState);
+                addState(mProbingState, mEvaluatingState);
+                addState(mWaitingForNextProbeState, mEvaluatingState);
+            addState(mCaptivePortalState, mMaybeNotifyState);
+        addState(mEvaluatingPrivateDnsState, mDefaultState);
+        addState(mValidatedState, mDefaultState);
+        setInitialState(mDefaultState);
+        // CHECKSTYLE:ON IndentationCheck
+
+        mIsCaptivePortalCheckEnabled = getIsCaptivePortalCheckEnabled();
+        mUseHttps = getUseHttpsValidation();
+        mCaptivePortalUserAgent = getCaptivePortalUserAgent();
+        mCaptivePortalHttpsUrl = makeURL(getCaptivePortalServerHttpsUrl());
+        mCaptivePortalHttpUrl = makeURL(deps.getCaptivePortalServerHttpUrl(context));
+        mCaptivePortalFallbackUrls = makeCaptivePortalFallbackUrls();
+        mCaptivePortalFallbackSpecs = makeCaptivePortalFallbackProbeSpecs();
+        mRandom = deps.getRandom();
+        // TODO: Evaluate to move data stall configuration to a specific class.
+        mConsecutiveDnsTimeoutThreshold = getConsecutiveDnsTimeoutThreshold();
+        mDnsStallDetector = new DnsStallDetector(mConsecutiveDnsTimeoutThreshold);
+        mDataStallMinEvaluateTime = getDataStallMinEvaluateTime();
+        mDataStallValidDnsTimeThreshold = getDataStallValidDnsTimeThreshold();
+        mDataStallEvaluationType = getDataStallEvalutionType();
+
+        // mLinkProperties and mNetworkCapbilities must never be null or we will NPE.
+        // Provide empty objects in case we are started and the network disconnects before
+        // we can ever fetch them.
+        // TODO: Delete ASAP.
+        mLinkProperties = new LinkProperties();
+        mNetworkCapabilities = new NetworkCapabilities();
+        mNetworkCapabilities.clearAll();
+    }
+
+    /**
+     * Request the NetworkMonitor to reevaluate the network.
+     */
+    public void forceReevaluation(int responsibleUid) {
+        sendMessage(CMD_FORCE_REEVALUATION, responsibleUid, 0);
+    }
+
+    /**
+     * Send a notification to NetworkMonitor indicating that there was a DNS query response event.
+     * @param returnCode the DNS return code of the response.
+     */
+    public void notifyDnsResponse(int returnCode) {
+        sendMessage(EVENT_DNS_NOTIFICATION, returnCode);
+    }
+
+    /**
+     * Send a notification to NetworkMonitor indicating that private DNS settings have changed.
+     * @param newCfg The new private DNS configuration.
+     */
+    public void notifyPrivateDnsSettingsChanged(PrivateDnsConfig newCfg) {
+        // Cancel any outstanding resolutions.
+        removeMessages(CMD_PRIVATE_DNS_SETTINGS_CHANGED);
+        // Send the update to the proper thread.
+        sendMessage(CMD_PRIVATE_DNS_SETTINGS_CHANGED, newCfg);
+    }
+
+    /**
+     * Send a notification to NetworkMonitor indicating that the system is ready.
+     */
+    public void notifySystemReady() {
+        // No need to run on the handler thread: mSystemReady is volatile and read only once on the
+        // isCaptivePortal() thread.
+        mSystemReady = true;
+    }
+
+    /**
+     * Send a notification to NetworkMonitor indicating that the network is now connected.
+     */
+    public void notifyNetworkConnected() {
+        sendMessage(CMD_NETWORK_CONNECTED);
+    }
+
+    /**
+     * Send a notification to NetworkMonitor indicating that the network is now disconnected.
+     */
+    public void notifyNetworkDisconnected() {
+        sendMessage(CMD_NETWORK_DISCONNECTED);
+    }
+
+    /**
+     * Send a notification to NetworkMonitor indicating that link properties have changed.
+     */
+    public void notifyLinkPropertiesChanged() {
+        getHandler().post(() -> {
+            updateLinkProperties();
+        });
+    }
+
+    private void updateLinkProperties() {
+        final LinkProperties lp = mCm.getLinkProperties(mNetwork);
+        // If null, we should soon get a message that the network was disconnected, and will stop.
+        if (lp != null) {
+            // TODO: send LinkProperties parceled in notifyLinkPropertiesChanged() and start().
+            mLinkProperties = lp;
+        }
+    }
+
+    /**
+     * Send a notification to NetworkMonitor indicating that network capabilities have changed.
+     */
+    public void notifyNetworkCapabilitiesChanged() {
+        getHandler().post(() -> {
+            updateNetworkCapabilities();
+        });
+    }
+
+    private void updateNetworkCapabilities() {
+        final NetworkCapabilities nc = mCm.getNetworkCapabilities(mNetwork);
+        // If null, we should soon get a message that the network was disconnected, and will stop.
+        if (nc != null) {
+            // TODO: send NetworkCapabilities parceled in notifyNetworkCapsChanged() and start().
+            mNetworkCapabilities = nc;
+        }
+    }
+
+    /**
+     * Request the captive portal application to be launched.
+     */
+    public void launchCaptivePortalApp() {
+        sendMessage(CMD_LAUNCH_CAPTIVE_PORTAL_APP);
+    }
+
+    @Override
+    protected void log(String s) {
+        if (DBG) Log.d(TAG + "/" + mNetwork.netId, s);
+    }
+
+    private void validationLog(int probeType, Object url, String msg) {
+        String probeName = ValidationProbeEvent.getProbeName(probeType);
+        validationLog(String.format("%s %s %s", probeName, url, msg));
+    }
+
+    private void validationLog(String s) {
+        if (DBG) log(s);
+        mValidationLogs.log(s);
+    }
+
+    private ValidationStage validationStage() {
+        return 0 == mValidations ? ValidationStage.FIRST_VALIDATION : ValidationStage.REVALIDATION;
+    }
+
+    private boolean isValidationRequired() {
+        return NetworkMonitorUtils.isValidationRequired(
+                mDefaultRequest.networkCapabilities, mNetworkCapabilities);
+    }
+
+
+    private void notifyNetworkTested(int result, @Nullable String redirectUrl) {
+        try {
+            mCallback.notifyNetworkTested(result, redirectUrl);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Error sending network test result", e);
+        }
+    }
+
+    private void showProvisioningNotification(String action) {
+        try {
+            mCallback.showProvisioningNotification(action);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Error showing provisioning notification", e);
+        }
+    }
+
+    private void hideProvisioningNotification() {
+        try {
+            mCallback.hideProvisioningNotification();
+        } catch (RemoteException e) {
+            Log.e(TAG, "Error hiding provisioning notification", e);
+        }
+    }
+
+    // DefaultState is the parent of all States.  It exists only to handle CMD_* messages but
+    // does not entail any real state (hence no enter() or exit() routines).
+    private class DefaultState extends State {
+        @Override
+        public void enter() {
+            // TODO: have those passed parceled in start() and remove this
+            updateLinkProperties();
+            updateNetworkCapabilities();
+        }
+
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case CMD_NETWORK_CONNECTED:
+                    logNetworkEvent(NetworkEvent.NETWORK_CONNECTED);
+                    transitionTo(mEvaluatingState);
+                    return HANDLED;
+                case CMD_NETWORK_DISCONNECTED:
+                    logNetworkEvent(NetworkEvent.NETWORK_DISCONNECTED);
+                    if (mLaunchCaptivePortalAppBroadcastReceiver != null) {
+                        mContext.unregisterReceiver(mLaunchCaptivePortalAppBroadcastReceiver);
+                        mLaunchCaptivePortalAppBroadcastReceiver = null;
+                    }
+                    quit();
+                    return HANDLED;
+                case CMD_FORCE_REEVALUATION:
+                case CMD_CAPTIVE_PORTAL_RECHECK:
+                    log("Forcing reevaluation for UID " + message.arg1);
+                    mUidResponsibleForReeval = message.arg1;
+                    transitionTo(mEvaluatingState);
+                    return HANDLED;
+                case CMD_CAPTIVE_PORTAL_APP_FINISHED:
+                    log("CaptivePortal App responded with " + message.arg1);
+
+                    // If the user has seen and acted on a captive portal notification, and the
+                    // captive portal app is now closed, disable HTTPS probes. This avoids the
+                    // following pathological situation:
+                    //
+                    // 1. HTTP probe returns a captive portal, HTTPS probe fails or times out.
+                    // 2. User opens the app and logs into the captive portal.
+                    // 3. HTTP starts working, but HTTPS still doesn't work for some other reason -
+                    //    perhaps due to the network blocking HTTPS?
+                    //
+                    // In this case, we'll fail to validate the network even after the app is
+                    // dismissed. There is now no way to use this network, because the app is now
+                    // gone, so the user cannot select "Use this network as is".
+                    mUseHttps = false;
+
+                    switch (message.arg1) {
+                        case APP_RETURN_DISMISSED:
+                            sendMessage(CMD_FORCE_REEVALUATION, NO_UID, 0);
+                            break;
+                        case APP_RETURN_WANTED_AS_IS:
+                            mDontDisplaySigninNotification = true;
+                            // TODO: Distinguish this from a network that actually validates.
+                            // Displaying the "x" on the system UI icon may still be a good idea.
+                            transitionTo(mEvaluatingPrivateDnsState);
+                            break;
+                        case APP_RETURN_UNWANTED:
+                            mDontDisplaySigninNotification = true;
+                            mUserDoesNotWant = true;
+                            notifyNetworkTested(NETWORK_TEST_RESULT_INVALID, null);
+                            // TODO: Should teardown network.
+                            mUidResponsibleForReeval = 0;
+                            transitionTo(mEvaluatingState);
+                            break;
+                    }
+                    return HANDLED;
+                case CMD_PRIVATE_DNS_SETTINGS_CHANGED: {
+                    final PrivateDnsConfig cfg = (PrivateDnsConfig) message.obj;
+                    if (!isValidationRequired() || cfg == null || !cfg.inStrictMode()) {
+                        // No DNS resolution required.
+                        //
+                        // We don't force any validation in opportunistic mode
+                        // here. Opportunistic mode nameservers are validated
+                        // separately within netd.
+                        //
+                        // Reset Private DNS settings state.
+                        mPrivateDnsProviderHostname = "";
+                        break;
+                    }
+
+                    mPrivateDnsProviderHostname = cfg.hostname;
+
+                    // DNS resolutions via Private DNS strict mode block for a
+                    // few seconds (~4.2) checking for any IP addresses to
+                    // arrive and validate. Initiating a (re)evaluation now
+                    // should not significantly alter the validation outcome.
+                    //
+                    // No matter what: enqueue a validation request; one of
+                    // three things can happen with this request:
+                    //     [1] ignored (EvaluatingState or CaptivePortalState)
+                    //     [2] transition to EvaluatingPrivateDnsState
+                    //         (DefaultState and ValidatedState)
+                    //     [3] handled (EvaluatingPrivateDnsState)
+                    //
+                    // The Private DNS configuration to be evaluated will:
+                    //     [1] be skipped (not in strict mode), or
+                    //     [2] validate (huzzah), or
+                    //     [3] encounter some problem (invalid hostname,
+                    //         no resolved IP addresses, IPs unreachable,
+                    //         port 853 unreachable, port 853 is not running a
+                    //         DNS-over-TLS server, et cetera).
+                    sendMessage(CMD_EVALUATE_PRIVATE_DNS);
+                    break;
+                }
+                case EVENT_DNS_NOTIFICATION:
+                    mDnsStallDetector.accumulateConsecutiveDnsTimeoutCount(message.arg1);
+                    break;
+                default:
+                    break;
+            }
+            return HANDLED;
+        }
+    }
+
+    // Being in the ValidatedState State indicates a Network is:
+    // - Successfully validated, or
+    // - Wanted "as is" by the user, or
+    // - Does not satisfy the default NetworkRequest and so validation has been skipped.
+    private class ValidatedState extends State {
+        @Override
+        public void enter() {
+            maybeLogEvaluationResult(
+                    networkEventType(validationStage(), EvaluationResult.VALIDATED));
+            notifyNetworkTested(INetworkMonitor.NETWORK_TEST_RESULT_VALID, null);
+            mValidations++;
+        }
+
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case CMD_NETWORK_CONNECTED:
+                    transitionTo(mValidatedState);
+                    break;
+                case CMD_EVALUATE_PRIVATE_DNS:
+                    transitionTo(mEvaluatingPrivateDnsState);
+                    break;
+                case EVENT_DNS_NOTIFICATION:
+                    mDnsStallDetector.accumulateConsecutiveDnsTimeoutCount(message.arg1);
+                    if (isDataStall()) {
+                        validationLog("Suspecting data stall, reevaluate");
+                        transitionTo(mEvaluatingState);
+                    }
+                    break;
+                default:
+                    return NOT_HANDLED;
+            }
+            return HANDLED;
+        }
+    }
+
+    // Being in the MaybeNotifyState State indicates the user may have been notified that sign-in
+    // is required.  This State takes care to clear the notification upon exit from the State.
+    private class MaybeNotifyState extends State {
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case CMD_LAUNCH_CAPTIVE_PORTAL_APP:
+                    final Intent intent = new Intent(
+                            ConnectivityManager.ACTION_CAPTIVE_PORTAL_SIGN_IN);
+                    // OneAddressPerFamilyNetwork is not parcelable across processes.
+                    intent.putExtra(ConnectivityManager.EXTRA_NETWORK, new Network(mNetwork));
+                    intent.putExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL,
+                            new CaptivePortal(new ICaptivePortal.Stub() {
+                                @Override
+                                public void appResponse(int response) {
+                                    if (response == APP_RETURN_WANTED_AS_IS) {
+                                        mContext.enforceCallingPermission(
+                                                android.Manifest.permission.CONNECTIVITY_INTERNAL,
+                                                "CaptivePortal");
+                                    }
+                                    sendMessage(CMD_CAPTIVE_PORTAL_APP_FINISHED, response);
+                                }
+                            }));
+                    final CaptivePortalProbeResult probeRes = mLastPortalProbeResult;
+                    intent.putExtra(EXTRA_CAPTIVE_PORTAL_URL, probeRes.detectUrl);
+                    if (probeRes.probeSpec != null) {
+                        final String encodedSpec = probeRes.probeSpec.getEncodedSpec();
+                        intent.putExtra(EXTRA_CAPTIVE_PORTAL_PROBE_SPEC, encodedSpec);
+                    }
+                    intent.putExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT,
+                            mCaptivePortalUserAgent);
+                    intent.setFlags(
+                            Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT | Intent.FLAG_ACTIVITY_NEW_TASK);
+                    mContext.startActivityAsUser(intent, UserHandle.CURRENT);
+                    return HANDLED;
+                default:
+                    return NOT_HANDLED;
+            }
+        }
+
+        @Override
+        public void exit() {
+            hideProvisioningNotification();
+        }
+    }
+
+    // Being in the EvaluatingState State indicates the Network is being evaluated for internet
+    // connectivity, or that the user has indicated that this network is unwanted.
+    private class EvaluatingState extends State {
+        @Override
+        public void enter() {
+            // If we have already started to track time spent in EvaluatingState
+            // don't reset the timer due simply to, say, commands or events that
+            // cause us to exit and re-enter EvaluatingState.
+            if (!mEvaluationTimer.isStarted()) {
+                mEvaluationTimer.start();
+            }
+            sendMessage(CMD_REEVALUATE, ++mReevaluateToken, 0);
+            if (mUidResponsibleForReeval != INVALID_UID) {
+                TrafficStats.setThreadStatsUid(mUidResponsibleForReeval);
+                mUidResponsibleForReeval = INVALID_UID;
+            }
+            mReevaluateDelayMs = INITIAL_REEVALUATE_DELAY_MS;
+            mEvaluateAttempts = 0;
+        }
+
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case CMD_REEVALUATE:
+                    if (message.arg1 != mReevaluateToken || mUserDoesNotWant) {
+                        return HANDLED;
+                    }
+                    // Don't bother validating networks that don't satisfy the default request.
+                    // This includes:
+                    //  - VPNs which can be considered explicitly desired by the user and the
+                    //    user's desire trumps whether the network validates.
+                    //  - Networks that don't provide Internet access.  It's unclear how to
+                    //    validate such networks.
+                    //  - Untrusted networks.  It's unsafe to prompt the user to sign-in to
+                    //    such networks and the user didn't express interest in connecting to
+                    //    such networks (an app did) so the user may be unhappily surprised when
+                    //    asked to sign-in to a network they didn't want to connect to in the
+                    //    first place.  Validation could be done to adjust the network scores
+                    //    however these networks are app-requested and may not be intended for
+                    //    general usage, in which case general validation may not be an accurate
+                    //    measure of the network's quality.  Only the app knows how to evaluate
+                    //    the network so don't bother validating here.  Furthermore sending HTTP
+                    //    packets over the network may be undesirable, for example an extremely
+                    //    expensive metered network, or unwanted leaking of the User Agent string.
+                    if (!isValidationRequired()) {
+                        validationLog("Network would not satisfy default request, not validating");
+                        transitionTo(mValidatedState);
+                        return HANDLED;
+                    }
+                    mEvaluateAttempts++;
+
+                    transitionTo(mProbingState);
+                    return HANDLED;
+                case CMD_FORCE_REEVALUATION:
+                    // Before IGNORE_REEVALUATE_ATTEMPTS attempts are made,
+                    // ignore any re-evaluation requests. After, restart the
+                    // evaluation process via EvaluatingState#enter.
+                    return (mEvaluateAttempts < IGNORE_REEVALUATE_ATTEMPTS) ? HANDLED : NOT_HANDLED;
+                default:
+                    return NOT_HANDLED;
+            }
+        }
+
+        @Override
+        public void exit() {
+            TrafficStats.clearThreadStatsUid();
+        }
+    }
+
+    // BroadcastReceiver that waits for a particular Intent and then posts a message.
+    private class CustomIntentReceiver extends BroadcastReceiver {
+        private final int mToken;
+        private final int mWhat;
+        private final String mAction;
+        CustomIntentReceiver(String action, int token, int what) {
+            mToken = token;
+            mWhat = what;
+            mAction = action + "_" + mNetId + "_" + token;
+            mContext.registerReceiver(this, new IntentFilter(mAction));
+        }
+        public PendingIntent getPendingIntent() {
+            final Intent intent = new Intent(mAction);
+            intent.setPackage(mContext.getPackageName());
+            return PendingIntent.getBroadcast(mContext, 0, intent, 0);
+        }
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (intent.getAction().equals(mAction)) sendMessage(obtainMessage(mWhat, mToken));
+        }
+    }
+
+    // Being in the CaptivePortalState State indicates a captive portal was detected and the user
+    // has been shown a notification to sign-in.
+    private class CaptivePortalState extends State {
+        private static final String ACTION_LAUNCH_CAPTIVE_PORTAL_APP =
+                "android.net.netmon.launchCaptivePortalApp";
+
+        @Override
+        public void enter() {
+            maybeLogEvaluationResult(
+                    networkEventType(validationStage(), EvaluationResult.CAPTIVE_PORTAL));
+            // Don't annoy user with sign-in notifications.
+            if (mDontDisplaySigninNotification) return;
+            // Create a CustomIntentReceiver that sends us a
+            // CMD_LAUNCH_CAPTIVE_PORTAL_APP message when the user
+            // touches the notification.
+            if (mLaunchCaptivePortalAppBroadcastReceiver == null) {
+                // Wait for result.
+                mLaunchCaptivePortalAppBroadcastReceiver = new CustomIntentReceiver(
+                        ACTION_LAUNCH_CAPTIVE_PORTAL_APP, new Random().nextInt(),
+                        CMD_LAUNCH_CAPTIVE_PORTAL_APP);
+            }
+            // Display the sign in notification.
+            showProvisioningNotification(mLaunchCaptivePortalAppBroadcastReceiver.mAction);
+            // Retest for captive portal occasionally.
+            sendMessageDelayed(CMD_CAPTIVE_PORTAL_RECHECK, 0 /* no UID */,
+                    CAPTIVE_PORTAL_REEVALUATE_DELAY_MS);
+            mValidations++;
+        }
+
+        @Override
+        public void exit() {
+            removeMessages(CMD_CAPTIVE_PORTAL_RECHECK);
+        }
+    }
+
+    private class EvaluatingPrivateDnsState extends State {
+        private int mPrivateDnsReevalDelayMs;
+        private PrivateDnsConfig mPrivateDnsConfig;
+
+        @Override
+        public void enter() {
+            mPrivateDnsReevalDelayMs = INITIAL_REEVALUATE_DELAY_MS;
+            mPrivateDnsConfig = null;
+            sendMessage(CMD_EVALUATE_PRIVATE_DNS);
+        }
+
+        @Override
+        public boolean processMessage(Message msg) {
+            switch (msg.what) {
+                case CMD_EVALUATE_PRIVATE_DNS:
+                    if (inStrictMode()) {
+                        if (!isStrictModeHostnameResolved()) {
+                            resolveStrictModeHostname();
+
+                            if (isStrictModeHostnameResolved()) {
+                                notifyPrivateDnsConfigResolved();
+                            } else {
+                                handlePrivateDnsEvaluationFailure();
+                                break;
+                            }
+                        }
+
+                        // Look up a one-time hostname, to bypass caching.
+                        //
+                        // Note that this will race with ConnectivityService
+                        // code programming the DNS-over-TLS server IP addresses
+                        // into netd (if invoked, above). If netd doesn't know
+                        // the IP addresses yet, or if the connections to the IP
+                        // addresses haven't yet been validated, netd will block
+                        // for up to a few seconds before failing the lookup.
+                        if (!sendPrivateDnsProbe()) {
+                            handlePrivateDnsEvaluationFailure();
+                            break;
+                        }
+                    }
+
+                    // All good!
+                    transitionTo(mValidatedState);
+                    break;
+                default:
+                    return NOT_HANDLED;
+            }
+            return HANDLED;
+        }
+
+        private boolean inStrictMode() {
+            return !TextUtils.isEmpty(mPrivateDnsProviderHostname);
+        }
+
+        private boolean isStrictModeHostnameResolved() {
+            return (mPrivateDnsConfig != null)
+                    && mPrivateDnsConfig.hostname.equals(mPrivateDnsProviderHostname)
+                    && (mPrivateDnsConfig.ips.length > 0);
+        }
+
+        private void resolveStrictModeHostname() {
+            try {
+                // Do a blocking DNS resolution using the network-assigned nameservers.
+                final InetAddress[] ips = mNetwork.getAllByName(mPrivateDnsProviderHostname);
+                mPrivateDnsConfig = new PrivateDnsConfig(mPrivateDnsProviderHostname, ips);
+                validationLog("Strict mode hostname resolved: " + mPrivateDnsConfig);
+            } catch (UnknownHostException uhe) {
+                mPrivateDnsConfig = null;
+                validationLog("Strict mode hostname resolution failed: " + uhe.getMessage());
+            }
+        }
+
+        private void notifyPrivateDnsConfigResolved() {
+            try {
+                mCallback.notifyPrivateDnsConfigResolved(mPrivateDnsConfig.toParcel());
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error sending private DNS config resolved notification", e);
+            }
+        }
+
+        private void handlePrivateDnsEvaluationFailure() {
+            notifyNetworkTested(NETWORK_TEST_RESULT_INVALID, null);
+
+            // Queue up a re-evaluation with backoff.
+            //
+            // TODO: Consider abandoning this state after a few attempts and
+            // transitioning back to EvaluatingState, to perhaps give ourselves
+            // the opportunity to (re)detect a captive portal or something.
+            sendMessageDelayed(CMD_EVALUATE_PRIVATE_DNS, mPrivateDnsReevalDelayMs);
+            mPrivateDnsReevalDelayMs *= 2;
+            if (mPrivateDnsReevalDelayMs > MAX_REEVALUATE_DELAY_MS) {
+                mPrivateDnsReevalDelayMs = MAX_REEVALUATE_DELAY_MS;
+            }
+        }
+
+        private boolean sendPrivateDnsProbe() {
+            // q.v. system/netd/server/dns/DnsTlsTransport.cpp
+            final String oneTimeHostnameSuffix = "-dnsotls-ds.metric.gstatic.com";
+            final String host = UUID.randomUUID().toString().substring(0, 8)
+                    + oneTimeHostnameSuffix;
+            final Stopwatch watch = new Stopwatch().start();
+            try {
+                final InetAddress[] ips = mNonPrivateDnsBypassNetwork.getAllByName(host);
+                final long time = watch.stop();
+                final String strIps = Arrays.toString(ips);
+                final boolean success = (ips != null && ips.length > 0);
+                validationLog(PROBE_PRIVDNS, host, String.format("%dms: %s", time, strIps));
+                logValidationProbe(time, PROBE_PRIVDNS, success ? DNS_SUCCESS : DNS_FAILURE);
+                return success;
+            } catch (UnknownHostException uhe) {
+                final long time = watch.stop();
+                validationLog(PROBE_PRIVDNS, host,
+                        String.format("%dms - Error: %s", time, uhe.getMessage()));
+                logValidationProbe(time, PROBE_PRIVDNS, DNS_FAILURE);
+            }
+            return false;
+        }
+    }
+
+    private class ProbingState extends State {
+        private Thread mThread;
+
+        @Override
+        public void enter() {
+            if (mEvaluateAttempts >= BLAME_FOR_EVALUATION_ATTEMPTS) {
+                //Don't continue to blame UID forever.
+                TrafficStats.clearThreadStatsUid();
+            }
+
+            final int token = ++mProbeToken;
+            mThread = new Thread(() -> sendMessage(obtainMessage(CMD_PROBE_COMPLETE, token, 0,
+                    isCaptivePortal())));
+            mThread.start();
+        }
+
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case CMD_PROBE_COMPLETE:
+                    // Ensure that CMD_PROBE_COMPLETE from stale threads are ignored.
+                    if (message.arg1 != mProbeToken) {
+                        return HANDLED;
+                    }
+
+                    final CaptivePortalProbeResult probeResult =
+                            (CaptivePortalProbeResult) message.obj;
+                    mLastProbeTime = SystemClock.elapsedRealtime();
+                    if (probeResult.isSuccessful()) {
+                        // Transit EvaluatingPrivateDnsState to get to Validated
+                        // state (even if no Private DNS validation required).
+                        transitionTo(mEvaluatingPrivateDnsState);
+                    } else if (probeResult.isPortal()) {
+                        notifyNetworkTested(NETWORK_TEST_RESULT_INVALID, probeResult.redirectUrl);
+                        mLastPortalProbeResult = probeResult;
+                        transitionTo(mCaptivePortalState);
+                    } else {
+                        logNetworkEvent(NetworkEvent.NETWORK_VALIDATION_FAILED);
+                        notifyNetworkTested(NETWORK_TEST_RESULT_INVALID, probeResult.redirectUrl);
+                        transitionTo(mWaitingForNextProbeState);
+                    }
+                    return HANDLED;
+                case EVENT_DNS_NOTIFICATION:
+                    // Leave the event to DefaultState to record correct dns timestamp.
+                    return NOT_HANDLED;
+                default:
+                    // Wait for probe result and defer events to next state by default.
+                    deferMessage(message);
+                    return HANDLED;
+            }
+        }
+
+        @Override
+        public void exit() {
+            if (mThread.isAlive()) {
+                mThread.interrupt();
+            }
+            mThread = null;
+        }
+    }
+
+    // Being in the WaitingForNextProbeState indicates that evaluating probes failed and state is
+    // transited from ProbingState. This ensures that the state machine is only in ProbingState
+    // while a probe is in progress, not while waiting to perform the next probe. That allows
+    // ProbingState to defer most messages until the probe is complete, which keeps the code simple
+    // and matches the pre-Q behaviour where probes were a blocking operation performed on the state
+    // machine thread.
+    private class WaitingForNextProbeState extends State {
+        @Override
+        public void enter() {
+            scheduleNextProbe();
+        }
+
+        private void scheduleNextProbe() {
+            final Message msg = obtainMessage(CMD_REEVALUATE, ++mReevaluateToken, 0);
+            sendMessageDelayed(msg, mReevaluateDelayMs);
+            mReevaluateDelayMs *= 2;
+            if (mReevaluateDelayMs > MAX_REEVALUATE_DELAY_MS) {
+                mReevaluateDelayMs = MAX_REEVALUATE_DELAY_MS;
+            }
+        }
+
+        @Override
+        public boolean processMessage(Message message) {
+            return NOT_HANDLED;
+        }
+    }
+
+    // Limits the list of IP addresses returned by getAllByName or tried by openConnection to at
+    // most one per address family. This ensures we only wait up to 20 seconds for TCP connections
+    // to complete, regardless of how many IP addresses a host has.
+    private static class OneAddressPerFamilyNetwork extends Network {
+        OneAddressPerFamilyNetwork(Network network) {
+            // Always bypass Private DNS.
+            super(network.getPrivateDnsBypassingCopy());
+        }
+
+        @Override
+        public InetAddress[] getAllByName(String host) throws UnknownHostException {
+            final List<InetAddress> addrs = Arrays.asList(super.getAllByName(host));
+
+            // Ensure the address family of the first address is tried first.
+            LinkedHashMap<Class, InetAddress> addressByFamily = new LinkedHashMap<>();
+            addressByFamily.put(addrs.get(0).getClass(), addrs.get(0));
+            Collections.shuffle(addrs);
+
+            for (InetAddress addr : addrs) {
+                addressByFamily.put(addr.getClass(), addr);
+            }
+
+            return addressByFamily.values().toArray(new InetAddress[addressByFamily.size()]);
+        }
+    }
+
+    private boolean getIsCaptivePortalCheckEnabled() {
+        String symbol = Settings.Global.CAPTIVE_PORTAL_MODE;
+        int defaultValue = Settings.Global.CAPTIVE_PORTAL_MODE_PROMPT;
+        int mode = mDependencies.getSetting(mContext, symbol, defaultValue);
+        return mode != Settings.Global.CAPTIVE_PORTAL_MODE_IGNORE;
+    }
+
+    private boolean getUseHttpsValidation() {
+        return mDependencies.getSetting(mContext, Settings.Global.CAPTIVE_PORTAL_USE_HTTPS, 1) == 1;
+    }
+
+    private boolean getWifiScansAlwaysAvailableDisabled() {
+        return mDependencies.getSetting(
+                mContext, Settings.Global.WIFI_SCAN_ALWAYS_AVAILABLE, 0) == 0;
+    }
+
+    private String getCaptivePortalServerHttpsUrl() {
+        return mDependencies.getSetting(mContext,
+                Settings.Global.CAPTIVE_PORTAL_HTTPS_URL, DEFAULT_HTTPS_URL);
+    }
+
+    private int getConsecutiveDnsTimeoutThreshold() {
+        return mDependencies.getSetting(mContext,
+                Settings.Global.DATA_STALL_CONSECUTIVE_DNS_TIMEOUT_THRESHOLD,
+                DEFAULT_CONSECUTIVE_DNS_TIMEOUT_THRESHOLD);
+    }
+
+    private int getDataStallMinEvaluateTime() {
+        return mDependencies.getSetting(mContext,
+                Settings.Global.DATA_STALL_MIN_EVALUATE_INTERVAL,
+                DEFAULT_DATA_STALL_MIN_EVALUATE_TIME_MS);
+    }
+
+    private int getDataStallValidDnsTimeThreshold() {
+        return mDependencies.getSetting(mContext,
+                Settings.Global.DATA_STALL_VALID_DNS_TIME_THRESHOLD,
+                DEFAULT_DATA_STALL_VALID_DNS_TIME_THRESHOLD_MS);
+    }
+
+    private int getDataStallEvalutionType() {
+        return mDependencies.getSetting(mContext, Settings.Global.DATA_STALL_EVALUATION_TYPE,
+                DEFAULT_DATA_STALL_EVALUATION_TYPES);
+    }
+
+    private URL[] makeCaptivePortalFallbackUrls() {
+        try {
+            String separator = ",";
+            String firstUrl = mDependencies.getSetting(mContext,
+                    Settings.Global.CAPTIVE_PORTAL_FALLBACK_URL, DEFAULT_FALLBACK_URL);
+            String joinedUrls = firstUrl + separator + mDependencies.getSetting(mContext,
+                    Settings.Global.CAPTIVE_PORTAL_OTHER_FALLBACK_URLS,
+                    DEFAULT_OTHER_FALLBACK_URLS);
+            List<URL> urls = new ArrayList<>();
+            for (String s : joinedUrls.split(separator)) {
+                URL u = makeURL(s);
+                if (u == null) {
+                    continue;
+                }
+                urls.add(u);
+            }
+            if (urls.isEmpty()) {
+                Log.e(TAG, String.format("could not create any url from %s", joinedUrls));
+            }
+            return urls.toArray(new URL[urls.size()]);
+        } catch (Exception e) {
+            // Don't let a misconfiguration bootloop the system.
+            Log.e(TAG, "Error parsing configured fallback URLs", e);
+            return new URL[0];
+        }
+    }
+
+    private CaptivePortalProbeSpec[] makeCaptivePortalFallbackProbeSpecs() {
+        try {
+            final String settingsValue = mDependencies.getSetting(
+                    mContext, Settings.Global.CAPTIVE_PORTAL_FALLBACK_PROBE_SPECS, null);
+            // Probe specs only used if configured in settings
+            if (TextUtils.isEmpty(settingsValue)) {
+                return null;
+            }
+
+            return CaptivePortalProbeSpec.parseCaptivePortalProbeSpecs(settingsValue);
+        } catch (Exception e) {
+            // Don't let a misconfiguration bootloop the system.
+            Log.e(TAG, "Error parsing configured fallback probe specs", e);
+            return null;
+        }
+    }
+
+    private String getCaptivePortalUserAgent() {
+        return mDependencies.getSetting(mContext,
+                Settings.Global.CAPTIVE_PORTAL_USER_AGENT, DEFAULT_USER_AGENT);
+    }
+
+    private URL nextFallbackUrl() {
+        if (mCaptivePortalFallbackUrls.length == 0) {
+            return null;
+        }
+        int idx = Math.abs(mNextFallbackUrlIndex) % mCaptivePortalFallbackUrls.length;
+        mNextFallbackUrlIndex += mRandom.nextInt(); // randomly change url without memory.
+        return mCaptivePortalFallbackUrls[idx];
+    }
+
+    private CaptivePortalProbeSpec nextFallbackSpec() {
+        if (ArrayUtils.isEmpty(mCaptivePortalFallbackSpecs)) {
+            return null;
+        }
+        // Randomly change spec without memory. Also randomize the first attempt.
+        final int idx = Math.abs(mRandom.nextInt()) % mCaptivePortalFallbackSpecs.length;
+        return mCaptivePortalFallbackSpecs[idx];
+    }
+
+    @VisibleForTesting
+    protected CaptivePortalProbeResult isCaptivePortal() {
+        if (!mIsCaptivePortalCheckEnabled) {
+            validationLog("Validation disabled.");
+            return CaptivePortalProbeResult.SUCCESS;
+        }
+
+        URL pacUrl = null;
+        URL httpsUrl = mCaptivePortalHttpsUrl;
+        URL httpUrl = mCaptivePortalHttpUrl;
+
+        // On networks with a PAC instead of fetching a URL that should result in a 204
+        // response, we instead simply fetch the PAC script.  This is done for a few reasons:
+        // 1. At present our PAC code does not yet handle multiple PACs on multiple networks
+        //    until something like https://android-review.googlesource.com/#/c/115180/ lands.
+        //    Network.openConnection() will ignore network-specific PACs and instead fetch
+        //    using NO_PROXY.  If a PAC is in place, the only fetch we know will succeed with
+        //    NO_PROXY is the fetch of the PAC itself.
+        // 2. To proxy the generate_204 fetch through a PAC would require a number of things
+        //    happen before the fetch can commence, namely:
+        //        a) the PAC script be fetched
+        //        b) a PAC script resolver service be fired up and resolve the captive portal
+        //           server.
+        //    Network validation could be delayed until these prerequisities are satisifed or
+        //    could simply be left to race them.  Neither is an optimal solution.
+        // 3. PAC scripts are sometimes used to block or restrict Internet access and may in
+        //    fact block fetching of the generate_204 URL which would lead to false negative
+        //    results for network validation.
+        final ProxyInfo proxyInfo = mLinkProperties.getHttpProxy();
+        if (proxyInfo != null && !Uri.EMPTY.equals(proxyInfo.getPacFileUrl())) {
+            pacUrl = makeURL(proxyInfo.getPacFileUrl().toString());
+            if (pacUrl == null) {
+                return CaptivePortalProbeResult.FAILED;
+            }
+        }
+
+        if ((pacUrl == null) && (httpUrl == null || httpsUrl == null)) {
+            return CaptivePortalProbeResult.FAILED;
+        }
+
+        long startTime = SystemClock.elapsedRealtime();
+
+        final CaptivePortalProbeResult result;
+        if (pacUrl != null) {
+            result = sendDnsAndHttpProbes(null, pacUrl, ValidationProbeEvent.PROBE_PAC);
+        } else if (mUseHttps) {
+            result = sendParallelHttpProbes(proxyInfo, httpsUrl, httpUrl);
+        } else {
+            result = sendDnsAndHttpProbes(proxyInfo, httpUrl, ValidationProbeEvent.PROBE_HTTP);
+        }
+
+        long endTime = SystemClock.elapsedRealtime();
+
+        sendNetworkConditionsBroadcast(true /* response received */,
+                result.isPortal() /* isCaptivePortal */,
+                startTime, endTime);
+
+        log("isCaptivePortal: isSuccessful()=" + result.isSuccessful()
+                + " isPortal()=" + result.isPortal()
+                + " RedirectUrl=" + result.redirectUrl
+                + " Time=" + (endTime - startTime) + "ms");
+
+        return result;
+    }
+
+    /**
+     * Do a DNS resolution and URL fetch on a known web server to see if we get the data we expect.
+     * @return a CaptivePortalProbeResult inferred from the HTTP response.
+     */
+    private CaptivePortalProbeResult sendDnsAndHttpProbes(ProxyInfo proxy, URL url, int probeType) {
+        // Pre-resolve the captive portal server host so we can log it.
+        // Only do this if HttpURLConnection is about to, to avoid any potentially
+        // unnecessary resolution.
+        final String host = (proxy != null) ? proxy.getHost() : url.getHost();
+        sendDnsProbe(host);
+        return sendHttpProbe(url, probeType, null);
+    }
+
+    /** Do a DNS resolution of the given server. */
+    private void sendDnsProbe(String host) {
+        if (TextUtils.isEmpty(host)) {
+            return;
+        }
+
+        final String name = ValidationProbeEvent.getProbeName(ValidationProbeEvent.PROBE_DNS);
+        final Stopwatch watch = new Stopwatch().start();
+        int result;
+        String connectInfo;
+        try {
+            InetAddress[] addresses = mNetwork.getAllByName(host);
+            StringBuffer buffer = new StringBuffer();
+            for (InetAddress address : addresses) {
+                buffer.append(',').append(address.getHostAddress());
+            }
+            result = ValidationProbeEvent.DNS_SUCCESS;
+            connectInfo = "OK " + buffer.substring(1);
+        } catch (UnknownHostException e) {
+            result = ValidationProbeEvent.DNS_FAILURE;
+            connectInfo = "FAIL";
+        }
+        final long latency = watch.stop();
+        validationLog(ValidationProbeEvent.PROBE_DNS, host,
+                String.format("%dms %s", latency, connectInfo));
+        logValidationProbe(latency, ValidationProbeEvent.PROBE_DNS, result);
+    }
+
+    /**
+     * Do a URL fetch on a known web server to see if we get the data we expect.
+     * @return a CaptivePortalProbeResult inferred from the HTTP response.
+     */
+    @VisibleForTesting
+    protected CaptivePortalProbeResult sendHttpProbe(URL url, int probeType,
+            @Nullable CaptivePortalProbeSpec probeSpec) {
+        HttpURLConnection urlConnection = null;
+        int httpResponseCode = CaptivePortalProbeResult.FAILED_CODE;
+        String redirectUrl = null;
+        final Stopwatch probeTimer = new Stopwatch().start();
+        final int oldTag = TrafficStats.getAndSetThreadStatsTag(TrafficStats.TAG_SYSTEM_PROBE);
+        try {
+            urlConnection = (HttpURLConnection) mNetwork.openConnection(url);
+            urlConnection.setInstanceFollowRedirects(probeType == ValidationProbeEvent.PROBE_PAC);
+            urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS);
+            urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS);
+            urlConnection.setUseCaches(false);
+            if (mCaptivePortalUserAgent != null) {
+                urlConnection.setRequestProperty("User-Agent", mCaptivePortalUserAgent);
+            }
+            // cannot read request header after connection
+            String requestHeader = urlConnection.getRequestProperties().toString();
+
+            // Time how long it takes to get a response to our request
+            long requestTimestamp = SystemClock.elapsedRealtime();
+
+            httpResponseCode = urlConnection.getResponseCode();
+            redirectUrl = urlConnection.getHeaderField("location");
+
+            // Time how long it takes to get a response to our request
+            long responseTimestamp = SystemClock.elapsedRealtime();
+
+            validationLog(probeType, url, "time=" + (responseTimestamp - requestTimestamp) + "ms"
+                    + " ret=" + httpResponseCode
+                    + " request=" + requestHeader
+                    + " headers=" + urlConnection.getHeaderFields());
+            // NOTE: We may want to consider an "HTTP/1.0 204" response to be a captive
+            // portal.  The only example of this seen so far was a captive portal.  For
+            // the time being go with prior behavior of assuming it's not a captive
+            // portal.  If it is considered a captive portal, a different sign-in URL
+            // is needed (i.e. can't browse a 204).  This could be the result of an HTTP
+            // proxy server.
+            if (httpResponseCode == 200) {
+                if (probeType == ValidationProbeEvent.PROBE_PAC) {
+                    validationLog(
+                            probeType, url, "PAC fetch 200 response interpreted as 204 response.");
+                    httpResponseCode = CaptivePortalProbeResult.SUCCESS_CODE;
+                } else if (urlConnection.getContentLengthLong() == 0) {
+                    // Consider 200 response with "Content-length=0" to not be a captive portal.
+                    // There's no point in considering this a captive portal as the user cannot
+                    // sign-in to an empty page. Probably the result of a broken transparent proxy.
+                    // See http://b/9972012.
+                    validationLog(probeType, url,
+                            "200 response with Content-length=0 interpreted as 204 response.");
+                    httpResponseCode = CaptivePortalProbeResult.SUCCESS_CODE;
+                } else if (urlConnection.getContentLengthLong() == -1) {
+                    // When no Content-length (default value == -1), attempt to read a byte from the
+                    // response. Do not use available() as it is unreliable. See http://b/33498325.
+                    if (urlConnection.getInputStream().read() == -1) {
+                        validationLog(
+                                probeType, url, "Empty 200 response interpreted as 204 response.");
+                        httpResponseCode = CaptivePortalProbeResult.SUCCESS_CODE;
+                    }
+                }
+            }
+        } catch (IOException e) {
+            validationLog(probeType, url, "Probe failed with exception " + e);
+            if (httpResponseCode == CaptivePortalProbeResult.FAILED_CODE) {
+                // TODO: Ping gateway and DNS server and log results.
+            }
+        } finally {
+            if (urlConnection != null) {
+                urlConnection.disconnect();
+            }
+            TrafficStats.setThreadStatsTag(oldTag);
+        }
+        logValidationProbe(probeTimer.stop(), probeType, httpResponseCode);
+
+        if (probeSpec == null) {
+            return new CaptivePortalProbeResult(httpResponseCode, redirectUrl, url.toString());
+        } else {
+            return probeSpec.getResult(httpResponseCode, redirectUrl);
+        }
+    }
+
+    private CaptivePortalProbeResult sendParallelHttpProbes(
+            ProxyInfo proxy, URL httpsUrl, URL httpUrl) {
+        // Number of probes to wait for. If a probe completes with a conclusive answer
+        // it shortcuts the latch immediately by forcing the count to 0.
+        final CountDownLatch latch = new CountDownLatch(2);
+
+        final class ProbeThread extends Thread {
+            private final boolean mIsHttps;
+            private volatile CaptivePortalProbeResult mResult = CaptivePortalProbeResult.FAILED;
+
+            ProbeThread(boolean isHttps) {
+                mIsHttps = isHttps;
+            }
+
+            public CaptivePortalProbeResult result() {
+                return mResult;
+            }
+
+            @Override
+            public void run() {
+                if (mIsHttps) {
+                    mResult =
+                            sendDnsAndHttpProbes(proxy, httpsUrl, ValidationProbeEvent.PROBE_HTTPS);
+                } else {
+                    mResult = sendDnsAndHttpProbes(proxy, httpUrl, ValidationProbeEvent.PROBE_HTTP);
+                }
+                if ((mIsHttps && mResult.isSuccessful()) || (!mIsHttps && mResult.isPortal())) {
+                    // Stop waiting immediately if https succeeds or if http finds a portal.
+                    while (latch.getCount() > 0) {
+                        latch.countDown();
+                    }
+                }
+                // Signal this probe has completed.
+                latch.countDown();
+            }
+        }
+
+        final ProbeThread httpsProbe = new ProbeThread(true);
+        final ProbeThread httpProbe = new ProbeThread(false);
+
+        try {
+            httpsProbe.start();
+            httpProbe.start();
+            latch.await(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            validationLog("Error: probes wait interrupted!");
+            return CaptivePortalProbeResult.FAILED;
+        }
+
+        final CaptivePortalProbeResult httpsResult = httpsProbe.result();
+        final CaptivePortalProbeResult httpResult = httpProbe.result();
+
+        // Look for a conclusive probe result first.
+        if (httpResult.isPortal()) {
+            return httpResult;
+        }
+        // httpsResult.isPortal() is not expected, but check it nonetheless.
+        if (httpsResult.isPortal() || httpsResult.isSuccessful()) {
+            return httpsResult;
+        }
+        // If a fallback method exists, use it to retry portal detection.
+        // If we have new-style probe specs, use those. Otherwise, use the fallback URLs.
+        final CaptivePortalProbeSpec probeSpec = nextFallbackSpec();
+        final URL fallbackUrl = (probeSpec != null) ? probeSpec.getUrl() : nextFallbackUrl();
+        if (fallbackUrl != null) {
+            CaptivePortalProbeResult result = sendHttpProbe(fallbackUrl, PROBE_FALLBACK, probeSpec);
+            if (result.isPortal()) {
+                return result;
+            }
+        }
+        // Otherwise wait until http and https probes completes and use their results.
+        try {
+            httpProbe.join();
+            if (httpProbe.result().isPortal()) {
+                return httpProbe.result();
+            }
+            httpsProbe.join();
+            return httpsProbe.result();
+        } catch (InterruptedException e) {
+            validationLog("Error: http or https probe wait interrupted!");
+            return CaptivePortalProbeResult.FAILED;
+        }
+    }
+
+    private URL makeURL(String url) {
+        if (url != null) {
+            try {
+                return new URL(url);
+            } catch (MalformedURLException e) {
+                validationLog("Bad URL: " + url);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * @param responseReceived - whether or not we received a valid HTTP response to our request.
+     * If false, isCaptivePortal and responseTimestampMs are ignored
+     * TODO: This should be moved to the transports.  The latency could be passed to the transports
+     * along with the captive portal result.  Currently the TYPE_MOBILE broadcasts appear unused so
+     * perhaps this could just be added to the WiFi transport only.
+     */
+    private void sendNetworkConditionsBroadcast(boolean responseReceived, boolean isCaptivePortal,
+            long requestTimestampMs, long responseTimestampMs) {
+        if (getWifiScansAlwaysAvailableDisabled()) {
+            return;
+        }
+
+        if (!mSystemReady) {
+            return;
+        }
+
+        Intent latencyBroadcast =
+                new Intent(NetworkMonitorUtils.ACTION_NETWORK_CONDITIONS_MEASURED);
+        if (mNetworkCapabilities.hasTransport(TRANSPORT_WIFI)) {
+            WifiInfo currentWifiInfo = mWifiManager.getConnectionInfo();
+            if (currentWifiInfo != null) {
+                // NOTE: getSSID()'s behavior changed in API 17; before that, SSIDs were not
+                // surrounded by double quotation marks (thus violating the Javadoc), but this
+                // was changed to match the Javadoc in API 17. Since clients may have started
+                // sanitizing the output of this method since API 17 was released, we should
+                // not change it here as it would become impossible to tell whether the SSID is
+                // simply being surrounded by quotes due to the API, or whether those quotes
+                // are actually part of the SSID.
+                latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_SSID,
+                        currentWifiInfo.getSSID());
+                latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_BSSID,
+                        currentWifiInfo.getBSSID());
+            } else {
+                if (VDBG) logw("network info is TYPE_WIFI but no ConnectionInfo found");
+                return;
+            }
+            latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_CONNECTIVITY_TYPE, TYPE_WIFI);
+        } else if (mNetworkCapabilities.hasTransport(TRANSPORT_CELLULAR)) {
+            latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_NETWORK_TYPE,
+                    mTelephonyManager.getNetworkType());
+            List<CellInfo> info = mTelephonyManager.getAllCellInfo();
+            if (info == null) return;
+            int numRegisteredCellInfo = 0;
+            for (CellInfo cellInfo : info) {
+                if (cellInfo.isRegistered()) {
+                    numRegisteredCellInfo++;
+                    if (numRegisteredCellInfo > 1) {
+                        if (VDBG) {
+                            logw("more than one registered CellInfo."
+                                    + " Can't tell which is active.  Bailing.");
+                        }
+                        return;
+                    }
+                    if (cellInfo instanceof CellInfoCdma) {
+                        CellIdentityCdma cellId = ((CellInfoCdma) cellInfo).getCellIdentity();
+                        latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_CELL_ID, cellId);
+                    } else if (cellInfo instanceof CellInfoGsm) {
+                        CellIdentityGsm cellId = ((CellInfoGsm) cellInfo).getCellIdentity();
+                        latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_CELL_ID, cellId);
+                    } else if (cellInfo instanceof CellInfoLte) {
+                        CellIdentityLte cellId = ((CellInfoLte) cellInfo).getCellIdentity();
+                        latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_CELL_ID, cellId);
+                    } else if (cellInfo instanceof CellInfoWcdma) {
+                        CellIdentityWcdma cellId = ((CellInfoWcdma) cellInfo).getCellIdentity();
+                        latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_CELL_ID, cellId);
+                    } else {
+                        if (VDBG) logw("Registered cellinfo is unrecognized");
+                        return;
+                    }
+                }
+            }
+            latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_CONNECTIVITY_TYPE, TYPE_MOBILE);
+        } else {
+            return;
+        }
+        latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_RESPONSE_RECEIVED,
+                responseReceived);
+        latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_REQUEST_TIMESTAMP_MS,
+                requestTimestampMs);
+
+        if (responseReceived) {
+            latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_IS_CAPTIVE_PORTAL,
+                    isCaptivePortal);
+            latencyBroadcast.putExtra(NetworkMonitorUtils.EXTRA_RESPONSE_TIMESTAMP_MS,
+                    responseTimestampMs);
+        }
+        mContext.sendBroadcastAsUser(latencyBroadcast, UserHandle.CURRENT,
+                NetworkMonitorUtils.PERMISSION_ACCESS_NETWORK_CONDITIONS);
+    }
+
+    private void logNetworkEvent(int evtype) {
+        int[] transports = mNetworkCapabilities.getTransportTypes();
+        mMetricsLog.log(mNetId, transports, new NetworkEvent(evtype));
+    }
+
+    private int networkEventType(ValidationStage s, EvaluationResult r) {
+        if (s.mIsFirstValidation) {
+            if (r.mIsValidated) {
+                return NetworkEvent.NETWORK_FIRST_VALIDATION_SUCCESS;
+            } else {
+                return NetworkEvent.NETWORK_FIRST_VALIDATION_PORTAL_FOUND;
+            }
+        } else {
+            if (r.mIsValidated) {
+                return NetworkEvent.NETWORK_REVALIDATION_SUCCESS;
+            } else {
+                return NetworkEvent.NETWORK_REVALIDATION_PORTAL_FOUND;
+            }
+        }
+    }
+
+    private void maybeLogEvaluationResult(int evtype) {
+        if (mEvaluationTimer.isRunning()) {
+            int[] transports = mNetworkCapabilities.getTransportTypes();
+            mMetricsLog.log(mNetId, transports, new NetworkEvent(evtype, mEvaluationTimer.stop()));
+            mEvaluationTimer.reset();
+        }
+    }
+
+    private void logValidationProbe(long durationMs, int probeType, int probeResult) {
+        int[] transports = mNetworkCapabilities.getTransportTypes();
+        boolean isFirstValidation = validationStage().mIsFirstValidation;
+        ValidationProbeEvent ev = new ValidationProbeEvent();
+        ev.probeType = ValidationProbeEvent.makeProbeType(probeType, isFirstValidation);
+        ev.returnCode = probeResult;
+        ev.durationMs = durationMs;
+        mMetricsLog.log(mNetId, transports, ev);
+    }
+
+    @VisibleForTesting
+    static class Dependencies {
+        public Network getPrivateDnsBypassNetwork(Network network) {
+            return new OneAddressPerFamilyNetwork(network);
+        }
+
+        public Random getRandom() {
+            return new Random();
+        }
+
+        /**
+         * Get the captive portal server HTTP URL that is configured on the device.
+         */
+        public String getCaptivePortalServerHttpUrl(Context context) {
+            return NetworkMonitorUtils.getCaptivePortalServerHttpUrl(context);
+        }
+
+        /**
+         * Get the value of a global integer setting.
+         * @param symbol Name of the setting
+         * @param defaultValue Value to return if the setting is not defined.
+         */
+        public int getSetting(Context context, String symbol, int defaultValue) {
+            return Settings.Global.getInt(context.getContentResolver(), symbol, defaultValue);
+        }
+
+        /**
+         * Get the value of a global String setting.
+         * @param symbol Name of the setting
+         * @param defaultValue Value to return if the setting is not defined.
+         */
+        public String getSetting(Context context, String symbol, String defaultValue) {
+            final String value = Settings.Global.getString(context.getContentResolver(), symbol);
+            return value != null ? value : defaultValue;
+        }
+
+        public static final Dependencies DEFAULT = new Dependencies();
+    }
+
+    /**
+     * Methods in this class perform no locking because all accesses are performed on the state
+     * machine's thread. Need to consider the thread safety if it ever could be accessed outside the
+     * state machine.
+     */
+    @VisibleForTesting
+    protected class DnsStallDetector {
+        private static final int DEFAULT_DNS_LOG_SIZE = 50;
+        private int mConsecutiveTimeoutCount = 0;
+        private int mSize;
+        final DnsResult[] mDnsEvents;
+        final RingBufferIndices mResultIndices;
+
+        DnsStallDetector(int size) {
+            mSize = Math.max(DEFAULT_DNS_LOG_SIZE, size);
+            mDnsEvents = new DnsResult[mSize];
+            mResultIndices = new RingBufferIndices(mSize);
+        }
+
+        @VisibleForTesting
+        protected void accumulateConsecutiveDnsTimeoutCount(int code) {
+            final DnsResult result = new DnsResult(code);
+            mDnsEvents[mResultIndices.add()] = result;
+            if (result.isTimeout()) {
+                mConsecutiveTimeoutCount++;
+            } else {
+                // Keep the event in mDnsEvents without clearing it so that there are logs to do the
+                // simulation and analysis.
+                mConsecutiveTimeoutCount = 0;
+            }
+        }
+
+        private boolean isDataStallSuspected(int timeoutCountThreshold, int validTime) {
+            if (timeoutCountThreshold <= 0) {
+                Log.wtf(TAG, "Timeout count threshold should be larger than 0.");
+                return false;
+            }
+
+            // Check if the consecutive timeout count reach the threshold or not.
+            if (mConsecutiveTimeoutCount < timeoutCountThreshold) {
+                return false;
+            }
+
+            // Check if the target dns event index is valid or not.
+            final int firstConsecutiveTimeoutIndex =
+                    mResultIndices.indexOf(mResultIndices.size() - timeoutCountThreshold);
+
+            // If the dns timeout events happened long time ago, the events are meaningless for
+            // data stall evaluation. Thus, check if the first consecutive timeout dns event
+            // considered in the evaluation happened in defined threshold time.
+            final long now = SystemClock.elapsedRealtime();
+            final long firstTimeoutTime = now - mDnsEvents[firstConsecutiveTimeoutIndex].mTimeStamp;
+            return (firstTimeoutTime < validTime);
+        }
+
+        int getConsecutiveTimeoutCount() {
+            return mConsecutiveTimeoutCount;
+        }
+    }
+
+    private static class DnsResult {
+        // TODO: Need to move the DNS return code definition to a specific class once unify DNS
+        // response code is done.
+        private static final int RETURN_CODE_DNS_TIMEOUT = 255;
+
+        private final long mTimeStamp;
+        private final int mReturnCode;
+
+        DnsResult(int code) {
+            mTimeStamp = SystemClock.elapsedRealtime();
+            mReturnCode = code;
+        }
+
+        private boolean isTimeout() {
+            return mReturnCode == RETURN_CODE_DNS_TIMEOUT;
+        }
+    }
+
+
+    @VisibleForTesting
+    protected DnsStallDetector getDnsStallDetector() {
+        return mDnsStallDetector;
+    }
+
+    private boolean dataStallEvaluateTypeEnabled(int type) {
+        return (mDataStallEvaluationType & (1 << type)) != 0;
+    }
+
+    @VisibleForTesting
+    protected long getLastProbeTime() {
+        return mLastProbeTime;
+    }
+
+    @VisibleForTesting
+    protected boolean isDataStall() {
+        boolean result = false;
+        // Reevaluation will generate traffic. Thus, set a minimal reevaluation timer to limit the
+        // possible traffic cost in metered network.
+        if (mNetworkCapabilities.isMetered()
+                && (SystemClock.elapsedRealtime() - getLastProbeTime()
+                < mDataStallMinEvaluateTime)) {
+            return false;
+        }
+
+        // Check dns signal. Suspect it may be a data stall if both :
+        // 1. The number of consecutive DNS query timeouts > mConsecutiveDnsTimeoutThreshold.
+        // 2. Those consecutive DNS queries happened in the last mValidDataStallDnsTimeThreshold ms.
+        if (dataStallEvaluateTypeEnabled(DATA_STALL_EVALUATION_TYPE_DNS)) {
+            if (mDnsStallDetector.isDataStallSuspected(mConsecutiveDnsTimeoutThreshold,
+                    mDataStallValidDnsTimeThreshold)) {
+                result = true;
+                logNetworkEvent(NetworkEvent.NETWORK_CONSECUTIVE_DNS_TIMEOUT_FOUND);
+            }
+        }
+
+        if (VDBG_STALL) {
+            log("isDataStall: result=" + result + ", consecutive dns timeout count="
+                    + mDnsStallDetector.getConsecutiveTimeoutCount());
+        }
+
+        return result;
+    }
+}
diff --git a/tests/src/com/android/server/connectivity/NetworkMonitorTest.java b/tests/src/com/android/server/connectivity/NetworkMonitorTest.java
new file mode 100644
index 0000000..d31fa77
--- /dev/null
+++ b/tests/src/com/android/server/connectivity/NetworkMonitorTest.java
@@ -0,0 +1,604 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity;
+
+import static android.net.ConnectivityManager.ACTION_CAPTIVE_PORTAL_SIGN_IN;
+import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL;
+import static android.net.INetworkMonitor.NETWORK_TEST_RESULT_INVALID;
+import static android.net.INetworkMonitor.NETWORK_TEST_RESULT_VALID;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.CaptivePortal;
+import android.net.ConnectivityManager;
+import android.net.INetworkMonitorCallbacks;
+import android.net.InetAddresses;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo;
+import android.net.NetworkRequest;
+import android.net.captiveportal.CaptivePortalProbeResult;
+import android.net.metrics.IpConnectivityLog;
+import android.net.util.SharedLog;
+import android.net.wifi.WifiManager;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.SystemClock;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+import android.telephony.TelephonyManager;
+import android.util.ArrayMap;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.InetAddress;
+import java.net.URL;
+import java.util.Random;
+
+import javax.net.ssl.SSLHandshakeException;
+
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class NetworkMonitorTest {
+    private static final String LOCATION_HEADER = "location";
+
+    private @Mock Context mContext;
+    private @Mock IpConnectivityLog mLogger;
+    private @Mock SharedLog mValidationLogger;
+    private @Mock NetworkInfo mNetworkInfo;
+    private @Mock ConnectivityManager mCm;
+    private @Mock TelephonyManager mTelephony;
+    private @Mock WifiManager mWifi;
+    private @Mock HttpURLConnection mHttpConnection;
+    private @Mock HttpURLConnection mHttpsConnection;
+    private @Mock HttpURLConnection mFallbackConnection;
+    private @Mock HttpURLConnection mOtherFallbackConnection;
+    private @Mock Random mRandom;
+    private @Mock NetworkMonitor.Dependencies mDependencies;
+    private @Mock INetworkMonitorCallbacks mCallbacks;
+    private @Spy Network mNetwork = new Network(TEST_NETID);
+    private NetworkRequest mRequest;
+
+    private static final int TEST_NETID = 4242;
+
+    private static final String TEST_HTTP_URL = "http://www.google.com/gen_204";
+    private static final String TEST_HTTPS_URL = "https://www.google.com/gen_204";
+    private static final String TEST_FALLBACK_URL = "http://fallback.google.com/gen_204";
+    private static final String TEST_OTHER_FALLBACK_URL = "http://otherfallback.google.com/gen_204";
+
+    private static final int DATA_STALL_EVALUATION_TYPE_DNS = 1;
+    private static final int RETURN_CODE_DNS_SUCCESS = 0;
+    private static final int RETURN_CODE_DNS_TIMEOUT = 255;
+
+    private static final int HANDLER_TIMEOUT_MS = 1000;
+
+    private static final LinkProperties TEST_LINKPROPERTIES = new LinkProperties();
+
+    private static final NetworkCapabilities METERED_CAPABILITIES = new NetworkCapabilities()
+            .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET);
+
+    private static final NetworkCapabilities NOT_METERED_CAPABILITIES = new NetworkCapabilities()
+            .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
+
+    private static final NetworkCapabilities NO_INTERNET_CAPABILITIES = new NetworkCapabilities()
+            .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR);
+
+    @Before
+    public void setUp() throws IOException {
+        MockitoAnnotations.initMocks(this);
+        when(mDependencies.getPrivateDnsBypassNetwork(any())).thenReturn(mNetwork);
+        when(mDependencies.getRandom()).thenReturn(mRandom);
+        when(mDependencies.getSetting(any(), eq(Settings.Global.CAPTIVE_PORTAL_MODE), anyInt()))
+                .thenReturn(Settings.Global.CAPTIVE_PORTAL_MODE_PROMPT);
+        when(mDependencies.getSetting(any(), eq(Settings.Global.CAPTIVE_PORTAL_USE_HTTPS),
+                anyInt())).thenReturn(1);
+        when(mDependencies.getCaptivePortalServerHttpUrl(any())).thenReturn(TEST_HTTP_URL);
+        when(mDependencies.getSetting(any(), eq(Settings.Global.CAPTIVE_PORTAL_HTTPS_URL),
+                anyString())).thenReturn(TEST_HTTPS_URL);
+        doReturn(mNetwork).when(mNetwork).getPrivateDnsBypassingCopy();
+
+        when(mContext.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(mCm);
+        when(mContext.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mTelephony);
+        when(mContext.getSystemService(Context.WIFI_SERVICE)).thenReturn(mWifi);
+
+        when(mNetworkInfo.getType()).thenReturn(ConnectivityManager.TYPE_WIFI);
+        setFallbackUrl(TEST_FALLBACK_URL);
+        setOtherFallbackUrls(TEST_OTHER_FALLBACK_URL);
+        setFallbackSpecs(null); // Test with no fallback spec by default
+        when(mRandom.nextInt()).thenReturn(0);
+
+        doAnswer((invocation) -> {
+            URL url = invocation.getArgument(0);
+            switch(url.toString()) {
+                case TEST_HTTP_URL:
+                    return mHttpConnection;
+                case TEST_HTTPS_URL:
+                    return mHttpsConnection;
+                case TEST_FALLBACK_URL:
+                    return mFallbackConnection;
+                case TEST_OTHER_FALLBACK_URL:
+                    return mOtherFallbackConnection;
+                default:
+                    fail("URL not mocked: " + url.toString());
+                    return null;
+            }
+        }).when(mNetwork).openConnection(any());
+        when(mHttpConnection.getRequestProperties()).thenReturn(new ArrayMap<>());
+        when(mHttpsConnection.getRequestProperties()).thenReturn(new ArrayMap<>());
+        doReturn(new InetAddress[] {
+                InetAddresses.parseNumericAddress("192.168.0.0")
+        }).when(mNetwork).getAllByName(any());
+
+        mRequest = new NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .addCapability(NET_CAPABILITY_NOT_RESTRICTED)
+                .build();
+        // Default values. Individual tests can override these.
+        when(mCm.getLinkProperties(any())).thenReturn(TEST_LINKPROPERTIES);
+        when(mCm.getNetworkCapabilities(any())).thenReturn(METERED_CAPABILITIES);
+
+        setMinDataStallEvaluateInterval(500);
+        setDataStallEvaluationType(1 << DATA_STALL_EVALUATION_TYPE_DNS);
+        setValidDataStallDnsTimeThreshold(500);
+        setConsecutiveDnsTimeoutThreshold(5);
+    }
+
+    private class WrappedNetworkMonitor extends NetworkMonitor {
+        private long mProbeTime = 0;
+
+        WrappedNetworkMonitor(Context context, Network network, NetworkRequest defaultRequest,
+                IpConnectivityLog logger, Dependencies deps) {
+                super(context, mCallbacks, network, defaultRequest, logger,
+                        new SharedLog("test_nm"), deps);
+        }
+
+        @Override
+        protected long getLastProbeTime() {
+            return mProbeTime;
+        }
+
+        protected void setLastProbeTime(long time) {
+            mProbeTime = time;
+        }
+    }
+
+    private WrappedNetworkMonitor makeMeteredWrappedNetworkMonitor() {
+        final WrappedNetworkMonitor nm = new WrappedNetworkMonitor(
+                mContext, mNetwork, mRequest, mLogger, mDependencies);
+        when(mCm.getNetworkCapabilities(any())).thenReturn(METERED_CAPABILITIES);
+        nm.start();
+        waitForIdle(nm.getHandler());
+        return nm;
+    }
+
+    private WrappedNetworkMonitor makeNotMeteredWrappedNetworkMonitor() {
+        final WrappedNetworkMonitor nm = new WrappedNetworkMonitor(
+                mContext, mNetwork, mRequest, mLogger, mDependencies);
+        when(mCm.getNetworkCapabilities(any())).thenReturn(NOT_METERED_CAPABILITIES);
+        nm.start();
+        waitForIdle(nm.getHandler());
+        return nm;
+    }
+
+    private NetworkMonitor makeMonitor() {
+        final NetworkMonitor nm = new NetworkMonitor(
+                mContext, mCallbacks, mNetwork, mRequest, mLogger, mValidationLogger,
+                mDependencies);
+        nm.start();
+        waitForIdle(nm.getHandler());
+        return nm;
+    }
+
+    private void waitForIdle(Handler handler) {
+        final ConditionVariable cv = new ConditionVariable(false);
+        handler.post(cv::open);
+        if (!cv.block(HANDLER_TIMEOUT_MS)) {
+            fail("Timed out waiting for handler");
+        }
+    }
+
+    @Test
+    public void testIsCaptivePortal_HttpProbeIsPortal() throws IOException {
+        setSslException(mHttpsConnection);
+        setPortal302(mHttpConnection);
+
+        assertPortal(makeMonitor().isCaptivePortal());
+    }
+
+    @Test
+    public void testIsCaptivePortal_HttpsProbeIsNotPortal() throws IOException {
+        setStatus(mHttpsConnection, 204);
+        setStatus(mHttpConnection, 500);
+
+        assertNotPortal(makeMonitor().isCaptivePortal());
+    }
+
+    @Test
+    public void testIsCaptivePortal_HttpsProbeFailedHttpSuccessNotUsed() throws IOException {
+        setSslException(mHttpsConnection);
+        // Even if HTTP returns a 204, do not use the result unless HTTPS succeeded
+        setStatus(mHttpConnection, 204);
+        setStatus(mFallbackConnection, 500);
+
+        assertFailed(makeMonitor().isCaptivePortal());
+    }
+
+    @Test
+    public void testIsCaptivePortal_FallbackProbeIsPortal() throws IOException {
+        setSslException(mHttpsConnection);
+        setStatus(mHttpConnection, 500);
+        setPortal302(mFallbackConnection);
+
+        assertPortal(makeMonitor().isCaptivePortal());
+    }
+
+    @Test
+    public void testIsCaptivePortal_FallbackProbeIsNotPortal() throws IOException {
+        setSslException(mHttpsConnection);
+        setStatus(mHttpConnection, 500);
+        setStatus(mFallbackConnection, 204);
+
+        // Fallback probe did not see portal, HTTPS failed -> inconclusive
+        assertFailed(makeMonitor().isCaptivePortal());
+    }
+
+    @Test
+    public void testIsCaptivePortal_OtherFallbackProbeIsPortal() throws IOException {
+        // Set all fallback probes but one to invalid URLs to verify they are being skipped
+        setFallbackUrl(TEST_FALLBACK_URL);
+        setOtherFallbackUrls(TEST_FALLBACK_URL + "," + TEST_OTHER_FALLBACK_URL);
+
+        setSslException(mHttpsConnection);
+        setStatus(mHttpConnection, 500);
+        setStatus(mFallbackConnection, 500);
+        setPortal302(mOtherFallbackConnection);
+
+        // TEST_OTHER_FALLBACK_URL is third
+        when(mRandom.nextInt()).thenReturn(2);
+
+        final NetworkMonitor monitor = makeMonitor();
+
+        // First check always uses the first fallback URL: inconclusive
+        assertFailed(monitor.isCaptivePortal());
+        verify(mFallbackConnection, times(1)).getResponseCode();
+        verify(mOtherFallbackConnection, never()).getResponseCode();
+
+        // Second check uses the URL chosen by Random
+        assertPortal(monitor.isCaptivePortal());
+        verify(mOtherFallbackConnection, times(1)).getResponseCode();
+    }
+
+    @Test
+    public void testIsCaptivePortal_AllProbesFailed() throws IOException {
+        setSslException(mHttpsConnection);
+        setStatus(mHttpConnection, 500);
+        setStatus(mFallbackConnection, 404);
+
+        assertFailed(makeMonitor().isCaptivePortal());
+        verify(mFallbackConnection, times(1)).getResponseCode();
+        verify(mOtherFallbackConnection, never()).getResponseCode();
+    }
+
+    @Test
+    public void testIsCaptivePortal_InvalidUrlSkipped() throws IOException {
+        setFallbackUrl("invalid");
+        setOtherFallbackUrls("otherinvalid," + TEST_OTHER_FALLBACK_URL + ",yetanotherinvalid");
+
+        setSslException(mHttpsConnection);
+        setStatus(mHttpConnection, 500);
+        setPortal302(mOtherFallbackConnection);
+
+        assertPortal(makeMonitor().isCaptivePortal());
+        verify(mOtherFallbackConnection, times(1)).getResponseCode();
+        verify(mFallbackConnection, never()).getResponseCode();
+    }
+
+    private void setupFallbackSpec() throws IOException {
+        setFallbackSpecs("http://example.com@@/@@204@@/@@"
+                + "@@,@@"
+                + TEST_OTHER_FALLBACK_URL + "@@/@@30[12]@@/@@https://(www\\.)?google.com/?.*");
+
+        setSslException(mHttpsConnection);
+        setStatus(mHttpConnection, 500);
+
+        // Use the 2nd fallback spec
+        when(mRandom.nextInt()).thenReturn(1);
+    }
+
+    @Test
+    public void testIsCaptivePortal_FallbackSpecIsNotPortal() throws IOException {
+        setupFallbackSpec();
+        set302(mOtherFallbackConnection, "https://www.google.com/test?q=3");
+
+        // HTTPS failed, fallback spec did not see a portal -> inconclusive
+        assertFailed(makeMonitor().isCaptivePortal());
+        verify(mOtherFallbackConnection, times(1)).getResponseCode();
+        verify(mFallbackConnection, never()).getResponseCode();
+    }
+
+    @Test
+    public void testIsCaptivePortal_FallbackSpecIsPortal() throws IOException {
+        setupFallbackSpec();
+        set302(mOtherFallbackConnection, "http://login.portal.example.com");
+
+        assertPortal(makeMonitor().isCaptivePortal());
+    }
+
+    @Test
+    public void testIsCaptivePortal_IgnorePortals() throws IOException {
+        setCaptivePortalMode(Settings.Global.CAPTIVE_PORTAL_MODE_IGNORE);
+        setSslException(mHttpsConnection);
+        setPortal302(mHttpConnection);
+
+        assertNotPortal(makeMonitor().isCaptivePortal());
+    }
+
+    @Test
+    public void testIsDataStall_EvaluationDisabled() {
+        setDataStallEvaluationType(0);
+        WrappedNetworkMonitor wrappedMonitor = makeMeteredWrappedNetworkMonitor();
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 100);
+        assertFalse(wrappedMonitor.isDataStall());
+    }
+
+    @Test
+    public void testIsDataStall_EvaluationDnsOnNotMeteredNetwork() {
+        WrappedNetworkMonitor wrappedMonitor = makeNotMeteredWrappedNetworkMonitor();
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 100);
+        makeDnsTimeoutEvent(wrappedMonitor, 5);
+        assertTrue(wrappedMonitor.isDataStall());
+    }
+
+    @Test
+    public void testIsDataStall_EvaluationDnsOnMeteredNetwork() {
+        WrappedNetworkMonitor wrappedMonitor = makeMeteredWrappedNetworkMonitor();
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 100);
+        assertFalse(wrappedMonitor.isDataStall());
+
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 1000);
+        makeDnsTimeoutEvent(wrappedMonitor, 5);
+        assertTrue(wrappedMonitor.isDataStall());
+    }
+
+    @Test
+    public void testIsDataStall_EvaluationDnsWithDnsTimeoutCount() {
+        WrappedNetworkMonitor wrappedMonitor = makeMeteredWrappedNetworkMonitor();
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 1000);
+        makeDnsTimeoutEvent(wrappedMonitor, 3);
+        assertFalse(wrappedMonitor.isDataStall());
+        // Reset consecutive timeout counts.
+        makeDnsSuccessEvent(wrappedMonitor, 1);
+        makeDnsTimeoutEvent(wrappedMonitor, 2);
+        assertFalse(wrappedMonitor.isDataStall());
+
+        makeDnsTimeoutEvent(wrappedMonitor, 3);
+        assertTrue(wrappedMonitor.isDataStall());
+
+        // Set the value to larger than the default dns log size.
+        setConsecutiveDnsTimeoutThreshold(51);
+        wrappedMonitor = makeMeteredWrappedNetworkMonitor();
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 1000);
+        makeDnsTimeoutEvent(wrappedMonitor, 50);
+        assertFalse(wrappedMonitor.isDataStall());
+
+        makeDnsTimeoutEvent(wrappedMonitor, 1);
+        assertTrue(wrappedMonitor.isDataStall());
+    }
+
+    @Test
+    public void testIsDataStall_EvaluationDnsWithDnsTimeThreshold() {
+        // Test dns events happened in valid dns time threshold.
+        WrappedNetworkMonitor wrappedMonitor = makeMeteredWrappedNetworkMonitor();
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 100);
+        makeDnsTimeoutEvent(wrappedMonitor, 5);
+        assertFalse(wrappedMonitor.isDataStall());
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 1000);
+        assertTrue(wrappedMonitor.isDataStall());
+
+        // Test dns events happened before valid dns time threshold.
+        setValidDataStallDnsTimeThreshold(0);
+        wrappedMonitor = makeMeteredWrappedNetworkMonitor();
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 100);
+        makeDnsTimeoutEvent(wrappedMonitor, 5);
+        assertFalse(wrappedMonitor.isDataStall());
+        wrappedMonitor.setLastProbeTime(SystemClock.elapsedRealtime() - 1000);
+        assertFalse(wrappedMonitor.isDataStall());
+    }
+
+    @Test
+    public void testBrokenNetworkNotValidated() throws Exception {
+        setSslException(mHttpsConnection);
+        setStatus(mHttpConnection, 500);
+        setStatus(mFallbackConnection, 404);
+        when(mCm.getNetworkCapabilities(any())).thenReturn(METERED_CAPABILITIES);
+
+        final NetworkMonitor nm = makeMonitor();
+        nm.notifyNetworkConnected();
+
+        verify(mCallbacks, timeout(HANDLER_TIMEOUT_MS).times(1))
+                .notifyNetworkTested(NETWORK_TEST_RESULT_INVALID, null);
+    }
+
+    @Test
+    public void testNoInternetCapabilityValidated() throws Exception {
+        when(mCm.getNetworkCapabilities(any())).thenReturn(NO_INTERNET_CAPABILITIES);
+
+        final NetworkMonitor nm = makeMonitor();
+        nm.notifyNetworkConnected();
+
+        verify(mCallbacks, timeout(HANDLER_TIMEOUT_MS).times(1))
+                .notifyNetworkTested(NETWORK_TEST_RESULT_VALID, null);
+        verify(mNetwork, never()).openConnection(any());
+    }
+
+    @Test
+    public void testLaunchCaptivePortalApp() throws Exception {
+        setSslException(mHttpsConnection);
+        setPortal302(mHttpConnection);
+
+        final NetworkMonitor nm = makeMonitor();
+        nm.notifyNetworkConnected();
+
+        verify(mCallbacks, timeout(HANDLER_TIMEOUT_MS).times(1))
+                .showProvisioningNotification(any());
+
+        // Check that startCaptivePortalApp sends the expected intent.
+        nm.launchCaptivePortalApp();
+
+        final ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
+        verify(mContext, timeout(HANDLER_TIMEOUT_MS).times(1))
+                .startActivityAsUser(intentCaptor.capture(), eq(UserHandle.CURRENT));
+        final Intent intent = intentCaptor.getValue();
+        assertEquals(ACTION_CAPTIVE_PORTAL_SIGN_IN, intent.getAction());
+        final Network network = intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK);
+        assertEquals(TEST_NETID, network.netId);
+
+        // Have the app report that the captive portal is dismissed, and check that we revalidate.
+        setStatus(mHttpsConnection, 204);
+        setStatus(mHttpConnection, 204);
+        final CaptivePortal captivePortal = intent.getParcelableExtra(EXTRA_CAPTIVE_PORTAL);
+        captivePortal.reportCaptivePortalDismissed();
+        verify(mCallbacks, timeout(HANDLER_TIMEOUT_MS).times(1))
+                .notifyNetworkTested(NETWORK_TEST_RESULT_VALID, null);
+    }
+
+    private void makeDnsTimeoutEvent(WrappedNetworkMonitor wrappedMonitor, int count) {
+        for (int i = 0; i < count; i++) {
+            wrappedMonitor.getDnsStallDetector().accumulateConsecutiveDnsTimeoutCount(
+                    RETURN_CODE_DNS_TIMEOUT);
+        }
+    }
+
+    private void makeDnsSuccessEvent(WrappedNetworkMonitor wrappedMonitor, int count) {
+        for (int i = 0; i < count; i++) {
+            wrappedMonitor.getDnsStallDetector().accumulateConsecutiveDnsTimeoutCount(
+                    RETURN_CODE_DNS_SUCCESS);
+        }
+    }
+
+    private void setDataStallEvaluationType(int type) {
+        when(mDependencies.getSetting(any(),
+            eq(Settings.Global.DATA_STALL_EVALUATION_TYPE), anyInt())).thenReturn(type);
+    }
+
+    private void setMinDataStallEvaluateInterval(int time) {
+        when(mDependencies.getSetting(any(),
+            eq(Settings.Global.DATA_STALL_MIN_EVALUATE_INTERVAL), anyInt())).thenReturn(time);
+    }
+
+    private void setValidDataStallDnsTimeThreshold(int time) {
+        when(mDependencies.getSetting(any(),
+            eq(Settings.Global.DATA_STALL_VALID_DNS_TIME_THRESHOLD), anyInt())).thenReturn(time);
+    }
+
+    private void setConsecutiveDnsTimeoutThreshold(int num) {
+        when(mDependencies.getSetting(any(),
+            eq(Settings.Global.DATA_STALL_CONSECUTIVE_DNS_TIMEOUT_THRESHOLD), anyInt()))
+            .thenReturn(num);
+    }
+
+    private void setFallbackUrl(String url) {
+        when(mDependencies.getSetting(any(),
+                eq(Settings.Global.CAPTIVE_PORTAL_FALLBACK_URL), any())).thenReturn(url);
+    }
+
+    private void setOtherFallbackUrls(String urls) {
+        when(mDependencies.getSetting(any(),
+                eq(Settings.Global.CAPTIVE_PORTAL_OTHER_FALLBACK_URLS), any())).thenReturn(urls);
+    }
+
+    private void setFallbackSpecs(String specs) {
+        when(mDependencies.getSetting(any(),
+                eq(Settings.Global.CAPTIVE_PORTAL_FALLBACK_PROBE_SPECS), any())).thenReturn(specs);
+    }
+
+    private void setCaptivePortalMode(int mode) {
+        when(mDependencies.getSetting(any(),
+                eq(Settings.Global.CAPTIVE_PORTAL_MODE), anyInt())).thenReturn(mode);
+    }
+
+    private void assertPortal(CaptivePortalProbeResult result) {
+        assertTrue(result.isPortal());
+        assertFalse(result.isFailed());
+        assertFalse(result.isSuccessful());
+    }
+
+    private void assertNotPortal(CaptivePortalProbeResult result) {
+        assertFalse(result.isPortal());
+        assertFalse(result.isFailed());
+        assertTrue(result.isSuccessful());
+    }
+
+    private void assertFailed(CaptivePortalProbeResult result) {
+        assertFalse(result.isPortal());
+        assertTrue(result.isFailed());
+        assertFalse(result.isSuccessful());
+    }
+
+    private void setSslException(HttpURLConnection connection) throws IOException {
+        doThrow(new SSLHandshakeException("Invalid cert")).when(connection).getResponseCode();
+    }
+
+    private void set302(HttpURLConnection connection, String location) throws IOException {
+        setStatus(connection, 302);
+        doReturn(location).when(connection).getHeaderField(LOCATION_HEADER);
+    }
+
+    private void setPortal302(HttpURLConnection connection) throws IOException {
+        set302(connection, "http://login.example.com");
+    }
+
+    private void setStatus(HttpURLConnection connection, int status) throws IOException {
+        doReturn(status).when(connection).getResponseCode();
+    }
+}
+