Snap for 6657150 from bf45240a2ac9dc17240bfff6596aee64e1d70a28 to rvc-release

Change-Id: I44acb557f9b07f858f3e24f2b79c6ae15f2d4a36
diff --git a/apishim/30/com/android/networkstack/apishim/api30/CaptivePortalDataShimImpl.java b/apishim/30/com/android/networkstack/apishim/api30/CaptivePortalDataShimImpl.java
index 0c75f27..5639386 100644
--- a/apishim/30/com/android/networkstack/apishim/api30/CaptivePortalDataShimImpl.java
+++ b/apishim/30/com/android/networkstack/apishim/api30/CaptivePortalDataShimImpl.java
@@ -93,6 +93,16 @@
     }
 
     @Override
+    public long getByteLimit() {
+        return mData.getByteLimit();
+    }
+
+    @Override
+    public long getExpiryTimeMillis() {
+        return mData.getExpiryTimeMillis();
+    }
+
+    @Override
     public Uri getUserPortalUrl() {
         return mData.getUserPortalUrl();
     }
diff --git a/apishim/common/com/android/networkstack/apishim/common/CaptivePortalDataShim.java b/apishim/common/com/android/networkstack/apishim/common/CaptivePortalDataShim.java
index fe99c13..a18ba49 100644
--- a/apishim/common/com/android/networkstack/apishim/common/CaptivePortalDataShim.java
+++ b/apishim/common/com/android/networkstack/apishim/common/CaptivePortalDataShim.java
@@ -30,6 +30,16 @@
     boolean isCaptive();
 
     /**
+     * @see android.net.CaptivePortalData#getByteLimit()
+     */
+    long getByteLimit();
+
+    /**
+     * @see android.net.CaptivePortalData#getExpiryTimeMillis()
+     */
+    long getExpiryTimeMillis();
+
+    /**
      * @see android.net.CaptivePortalData#getUserPortalUrl()
      */
     Uri getUserPortalUrl();
diff --git a/src/android/net/util/NetworkStackUtils.java b/src/android/net/util/NetworkStackUtils.java
index 19ca4b5..94de7c3 100755
--- a/src/android/net/util/NetworkStackUtils.java
+++ b/src/android/net/util/NetworkStackUtils.java
@@ -226,6 +226,15 @@
     public static final String DNS_PROBE_PRIVATE_IP_NO_INTERNET_VERSION =
             "dns_probe_private_ip_no_internet";
 
+    /**
+     * Experiment flag to enable validation metrics sent by NetworkMonitor.
+     *
+     * Metrics are sent by default. They can be disabled by setting the flag to a number greater
+     * than the APK version (for example 999999999).
+     * @see #isFeatureEnabled(Context, String, String, boolean)
+     */
+    public static final String VALIDATION_METRICS_VERSION = "validation_metrics_version";
+
     static {
         System.loadLibrary("networkstackutilsjni");
     }
@@ -348,6 +357,9 @@
      * {@link DeviceConfig} is enabled by comparing NetworkStack module version {@link NetworkStack}
      * with current version of property. If this property version is valid, the corresponding
      * experimental feature would be enabled, otherwise disabled.
+     *
+     * This is useful to ensure that if a module install is rolled back, flags are not left fully
+     * rolled out on a version where they have not been well tested.
      * @param context The global context information about an app environment.
      * @param namespace The namespace containing the property to look up.
      * @param name The name of the property to look up.
@@ -363,6 +375,9 @@
      * {@link DeviceConfig} is enabled by comparing NetworkStack module version {@link NetworkStack}
      * with current version of property. If this property version is valid, the corresponding
      * experimental feature would be enabled, otherwise disabled.
+     *
+     * This is useful to ensure that if a module install is rolled back, flags are not left fully
+     * rolled out on a version where they have not been well tested.
      * @param context The global context information about an app environment.
      * @param namespace The namespace containing the property to look up.
      * @param name The name of the property to look up.
diff --git a/src/com/android/networkstack/metrics/NetworkValidationMetrics.java b/src/com/android/networkstack/metrics/NetworkValidationMetrics.java
new file mode 100644
index 0000000..f27a939
--- /dev/null
+++ b/src/com/android/networkstack/metrics/NetworkValidationMetrics.java
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.networkstack.metrics;
+
+import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_LOWPAN;
+import static android.net.NetworkCapabilities.TRANSPORT_VPN;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI_AWARE;
+
+import static java.lang.System.currentTimeMillis;
+
+import android.net.INetworkMonitor;
+import android.net.NetworkCapabilities;
+import android.net.captiveportal.CaptivePortalProbeResult;
+import android.net.metrics.ValidationProbeEvent;
+import android.net.util.NetworkStackUtils;
+import android.net.util.Stopwatch;
+import android.stats.connectivity.ProbeResult;
+import android.stats.connectivity.ProbeType;
+import android.stats.connectivity.TransportType;
+import android.stats.connectivity.ValidationResult;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.networkstack.apishim.common.CaptivePortalDataShim;
+
+/**
+ * Class to record the network validation into statsd.
+ * 1. Fill in NetworkValidationReported proto.
+ * 2. Write the NetworkValidationReported proto into statsd.
+ * @hide
+ */
+
+public class NetworkValidationMetrics {
+    private final NetworkValidationReported.Builder mStatsBuilder =
+            NetworkValidationReported.newBuilder();
+    private final ProbeEvents.Builder mProbeEventsBuilder = ProbeEvents.newBuilder();
+    private final CapportApiData.Builder mCapportApiDataBuilder = CapportApiData.newBuilder();
+    private final Stopwatch mWatch = new Stopwatch();
+    private int mValidationIndex = 0;
+    // Define a maximum size that can store events.
+    public static final int MAX_PROBE_EVENTS_COUNT = 20;
+
+    /**
+     * Reset this NetworkValidationMetrics and start collecting timing and metrics.
+     *
+     * <p>This must be called when validation starts.
+     */
+    public void startCollection(@Nullable NetworkCapabilities nc) {
+        mStatsBuilder.clear();
+        mProbeEventsBuilder.clear();
+        mCapportApiDataBuilder.clear();
+        mWatch.restart();
+        mStatsBuilder.setTransportType(getTransportTypeFromNC(nc));
+        mValidationIndex++;
+    }
+
+    /**
+     * Returns the enum TransportType.
+     *
+     * <p>This method only supports a limited set of common transport type combinations that can be
+     * measured through metrics, and will return {@link TransportType#TT_UNKNOWN} for others. This
+     * ensures that, for example, metrics for a TRANSPORT_NEW_UNKNOWN | TRANSPORT_ETHERNET network
+     * cannot get aggregated with / compared with a "normal" TRANSPORT_ETHERNET network without
+     * noticing.
+     *
+     * @param nc Capabilities to extract transport type from.
+     * @return the TransportType which is defined in
+     * core/proto/android/stats/connectivity/network_stack.proto
+     */
+    @VisibleForTesting
+    public static TransportType getTransportTypeFromNC(@Nullable NetworkCapabilities nc) {
+        if (nc == null) return TransportType.TT_UNKNOWN;
+
+        final int trCount = nc.getTransportTypes().length;
+        boolean hasCellular = nc.hasTransport(TRANSPORT_CELLULAR);
+        boolean hasWifi = nc.hasTransport(TRANSPORT_WIFI);
+        boolean hasBT = nc.hasTransport(TRANSPORT_BLUETOOTH);
+        boolean hasEthernet = nc.hasTransport(TRANSPORT_ETHERNET);
+        boolean hasVpn = nc.hasTransport(TRANSPORT_VPN);
+        boolean hasWifiAware = nc.hasTransport(TRANSPORT_WIFI_AWARE);
+        boolean hasLopan = nc.hasTransport(TRANSPORT_LOWPAN);
+
+        // VPN networks are not subject to validation and should not see validation stats, but
+        // metrics could be added to measure private DNS probes only.
+        if (trCount == 3 && hasCellular && hasWifi && hasVpn) {
+            return TransportType.TT_WIFI_CELLULAR_VPN;
+        }
+
+        if (trCount == 2 && hasVpn) {
+            if (hasWifi) return TransportType.TT_WIFI_VPN;
+            if (hasCellular) return TransportType.TT_CELLULAR_VPN;
+            if (hasBT) return TransportType.TT_BLUETOOTH_VPN;
+            if (hasEthernet) return TransportType.TT_ETHERNET_VPN;
+        }
+
+        if (trCount == 1) {
+            if (hasWifi) return TransportType.TT_WIFI;
+            if (hasCellular) return TransportType.TT_CELLULAR;
+            if (hasBT) return TransportType.TT_BLUETOOTH;
+            if (hasEthernet) return TransportType.TT_ETHERNET;
+            if (hasWifiAware) return TransportType.TT_WIFI_AWARE;
+            if (hasLopan) return TransportType.TT_LOWPAN;
+            // TODO: consider having a TT_VPN for VPN-only transport
+        }
+
+        return TransportType.TT_UNKNOWN;
+    }
+
+    /**
+     * Map {@link ValidationProbeEvent} to {@link ProbeType}.
+     */
+    public static ProbeType probeTypeToEnum(final int probeType) {
+        switch(probeType) {
+            case ValidationProbeEvent.PROBE_DNS:
+                return ProbeType.PT_DNS;
+            case ValidationProbeEvent.PROBE_HTTP:
+                return ProbeType.PT_HTTP;
+            case ValidationProbeEvent.PROBE_HTTPS:
+                return ProbeType.PT_HTTPS;
+            case ValidationProbeEvent.PROBE_PAC:
+                return ProbeType.PT_PAC;
+            case ValidationProbeEvent.PROBE_FALLBACK:
+                return ProbeType.PT_FALLBACK;
+            case ValidationProbeEvent.PROBE_PRIVDNS:
+                return ProbeType.PT_PRIVDNS;
+            default:
+                return ProbeType.PT_UNKNOWN;
+        }
+    }
+
+    /**
+     * Map {@link CaptivePortalProbeResult} to {@link ProbeResult}.
+     */
+    public static ProbeResult httpProbeResultToEnum(final CaptivePortalProbeResult result) {
+        if (result == null) return ProbeResult.PR_UNKNOWN;
+
+        if (result.isSuccessful()) {
+            return ProbeResult.PR_SUCCESS;
+        } else if (result.isDnsPrivateIpResponse()) {
+            return ProbeResult.PR_PRIVATE_IP_DNS;
+        } else if (result.isFailed()) {
+            return ProbeResult.PR_FAILURE;
+        } else if (result.isPortal()) {
+            return ProbeResult.PR_PORTAL;
+        } else {
+            return ProbeResult.PR_UNKNOWN;
+        }
+    }
+
+    /**
+     * Map  validation result (as per INetworkMonitor) to {@link ValidationResult}.
+     */
+    @VisibleForTesting
+    public static ValidationResult validationResultToEnum(int result, String redirectUrl) {
+        // TODO: consider adding a VR_PARTIAL_SUCCESS field to track cases where users accepted
+        // partial connectivity
+        if ((result & INetworkMonitor.NETWORK_VALIDATION_RESULT_VALID) != 0) {
+            return ValidationResult.VR_SUCCESS;
+        } else if (redirectUrl != null) {
+            return ValidationResult.VR_PORTAL;
+        } else if ((result & INetworkMonitor.NETWORK_VALIDATION_RESULT_PARTIAL) != 0) {
+            return ValidationResult.VR_PARTIAL;
+        } else {
+            return ValidationResult.VR_FAILURE;
+        }
+    }
+
+    /**
+     * Add a network probe event to the metrics builder.
+     */
+    public void addProbeEvent(final ProbeType type, final long durationUs, final ProbeResult result,
+            @Nullable final CaptivePortalDataShim capportData) {
+        // When the number of ProbeEvents of mProbeEventsBuilder exceeds
+        // MAX_PROBE_EVENTS_COUNT, stop adding ProbeEvent.
+        // TODO: consider recording the total number of probes in a separate field to know how
+        // many probes are skipped.
+        if (mProbeEventsBuilder.getProbeEventCount() >= MAX_PROBE_EVENTS_COUNT) return;
+
+        int latencyUs = NetworkStackUtils.saturatedCast(durationUs);
+
+        final ProbeEvent.Builder probeEventBuilder = ProbeEvent.newBuilder()
+                .setLatencyMicros(latencyUs)
+                .setProbeType(type)
+                .setProbeResult(result);
+
+        if (capportData != null) {
+            final long secondsRemaining =
+                    (capportData.getExpiryTimeMillis() - currentTimeMillis()) / 1000;
+            mCapportApiDataBuilder
+                .setRemainingTtlSecs(NetworkStackUtils.saturatedCast(secondsRemaining))
+                // TODO: rename this field to setRemainingKBytes, or use a long
+                .setRemainingBytes(
+                        NetworkStackUtils.saturatedCast(capportData.getByteLimit() / 1000))
+                .setHasPortalUrl((capportData.getUserPortalUrl() != null))
+                .setHasVenueInfo((capportData.getVenueInfoUrl() != null));
+            probeEventBuilder.setCapportApiData(mCapportApiDataBuilder);
+        }
+
+        mProbeEventsBuilder.addProbeEvent(probeEventBuilder);
+    }
+
+    /**
+     * Write the network validation info to mStatsBuilder.
+     */
+    public void setValidationResult(int result, String redirectUrl) {
+        mStatsBuilder.setValidationResult(validationResultToEnum(result, redirectUrl));
+    }
+
+    /**
+     * Write the NetworkValidationReported proto to statsd.
+     *
+     * <p>This is a no-op if {@link #startCollection(NetworkCapabilities)} was not called since the
+     * last call to this method.
+     */
+    public NetworkValidationReported maybeStopCollectionAndSend() {
+        if (!mWatch.isStarted()) return null;
+        mStatsBuilder.setProbeEvents(mProbeEventsBuilder);
+        mStatsBuilder.setLatencyMicros(NetworkStackUtils.saturatedCast(mWatch.stop()));
+        mStatsBuilder.setValidationIndex(mValidationIndex);
+        // write a random value(0 ~ 999) for sampling.
+        mStatsBuilder.setRandomNumber((int) (Math.random() * 1000));
+        final NetworkValidationReported stats = mStatsBuilder.build();
+        final byte[] probeEvents = stats.getProbeEvents().toByteArray();
+
+        NetworkStackStatsLog.write(NetworkStackStatsLog.NETWORK_VALIDATION_REPORTED,
+                stats.getTransportType().getNumber(),
+                probeEvents,
+                stats.getValidationResult().getNumber(),
+                stats.getLatencyMicros(),
+                stats.getValidationIndex(),
+                stats.getRandomNumber());
+        mWatch.reset();
+        return stats;
+    }
+}
diff --git a/src/com/android/server/connectivity/NetworkMonitor.java b/src/com/android/server/connectivity/NetworkMonitor.java
index b6b1fff..40de26e 100755
--- a/src/com/android/server/connectivity/NetworkMonitor.java
+++ b/src/com/android/server/connectivity/NetworkMonitor.java
@@ -129,6 +129,8 @@
 import android.os.UserHandle;
 import android.provider.DeviceConfig;
 import android.provider.Settings;
+import android.stats.connectivity.ProbeResult;
+import android.stats.connectivity.ProbeType;
 import android.telephony.AccessNetworkConstants;
 import android.telephony.CellIdentityNr;
 import android.telephony.CellInfo;
@@ -155,6 +157,7 @@
 import androidx.annotation.StringRes;
 import androidx.annotation.VisibleForTesting;
 
+import com.android.internal.annotations.GuardedBy;
 import com.android.internal.util.RingBufferIndices;
 import com.android.internal.util.State;
 import com.android.internal.util.StateMachine;
@@ -168,6 +171,7 @@
 import com.android.networkstack.apishim.common.UnsupportedApiLevelException;
 import com.android.networkstack.metrics.DataStallDetectionStats;
 import com.android.networkstack.metrics.DataStallStatsUtils;
+import com.android.networkstack.metrics.NetworkValidationMetrics;
 import com.android.networkstack.netlink.TcpSocketTracker;
 import com.android.networkstack.util.DnsUtils;
 import com.android.server.NetworkStackService.NetworkStackServiceManager;
@@ -446,7 +450,14 @@
     protected boolean mIsCaptivePortalCheckEnabled;
 
     private boolean mUseHttps;
-    // The total number of captive portal detection attempts for this NetworkMonitor instance.
+    /**
+     * The total number of completed validation attempts (network validated or a captive portal was
+     * detected) for this NetworkMonitor instance.
+     * This does not include attempts that were interrupted, retried or finished with a result that
+     * is not success or portal. See {@code mValidationIndex} in {@link NetworkValidationMetrics}
+     * for a count of all attempts.
+     * TODO: remove when removing legacy metrics.
+     */
     private int mValidations = 0;
 
     // Set if the user explicitly selected "Do not use this network" in captive portal sign-in app.
@@ -500,6 +511,18 @@
 
     private final boolean mPrivateIpNoInternetEnabled;
 
+    private final boolean mMetricsEnabled;
+
+    // The validation metrics are accessed by individual probe threads, and by the StateMachine
+    // thread. All accesses must be synchronized to make sure the StateMachine thread can see
+    // reports from all probes.
+    // TODO: as that most usage is in the StateMachine thread and probes only add their probe
+    // events, consider having probes return their stats to the StateMachine, and only access this
+    // member on the StateMachine thread without synchronization.
+    @GuardedBy("mNetworkValidationMetrics")
+    private final NetworkValidationMetrics mNetworkValidationMetrics =
+            new NetworkValidationMetrics();
+
     private int getCallbackVersion(INetworkMonitorCallbacks cb) {
         int version;
         try {
@@ -563,6 +586,8 @@
 
         mIsCaptivePortalCheckEnabled = getIsCaptivePortalCheckEnabled();
         mPrivateIpNoInternetEnabled = getIsPrivateIpNoInternetEnabled();
+        mMetricsEnabled = deps.isFeatureEnabled(context, NAMESPACE_CONNECTIVITY,
+                NetworkStackUtils.VALIDATION_METRICS_VERSION, true /* defaultEnabled */);
         mUseHttps = getUseHttpsValidation();
         mCaptivePortalUserAgent = getCaptivePortalUserAgent();
         mCaptivePortalHttpsUrls = makeCaptivePortalHttpsUrls();
@@ -775,6 +800,51 @@
         }
     }
 
+    private void startMetricsCollection() {
+        if (!mMetricsEnabled) return;
+        try {
+            synchronized (mNetworkValidationMetrics) {
+                mNetworkValidationMetrics.startCollection(mNetworkCapabilities);
+            }
+        } catch (Exception e) {
+            Log.wtf(TAG, "Error resetting validation metrics", e);
+        }
+    }
+
+    private void recordProbeEventMetrics(ProbeType type, long latencyMicros, ProbeResult result,
+            CaptivePortalDataShim capportData) {
+        if (!mMetricsEnabled) return;
+        try {
+            synchronized (mNetworkValidationMetrics) {
+                mNetworkValidationMetrics.addProbeEvent(type, latencyMicros, result, capportData);
+            }
+        } catch (Exception e) {
+            Log.wtf(TAG, "Error recording probe event", e);
+        }
+    }
+
+    private void recordValidationResult(int result, String redirectUrl) {
+        if (!mMetricsEnabled) return;
+        try {
+            synchronized (mNetworkValidationMetrics) {
+                mNetworkValidationMetrics.setValidationResult(result, redirectUrl);
+            }
+        } catch (Exception e) {
+            Log.wtf(TAG, "Error recording validation result", e);
+        }
+    }
+
+    private void maybeStopCollectionAndSendMetrics() {
+        if (!mMetricsEnabled) return;
+        try {
+            synchronized (mNetworkValidationMetrics) {
+                mNetworkValidationMetrics.maybeStopCollectionAndSend();
+            }
+        } catch (Exception e) {
+            Log.wtf(TAG, "Error sending validation stats", 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 {
@@ -787,6 +857,7 @@
                     transitionTo(mEvaluatingState);
                     return HANDLED;
                 case CMD_NETWORK_DISCONNECTED:
+                    maybeStopCollectionAndSendMetrics();
                     logNetworkEvent(NetworkEvent.NETWORK_DISCONNECTED);
                     quit();
                     return HANDLED;
@@ -938,6 +1009,7 @@
             initSocketTrackingIfRequired();
             // start periodical polling.
             sendTcpPollingEvent();
+            maybeStopCollectionAndSendMetrics();
         }
 
         private void initSocketTrackingIfRequired() {
@@ -957,6 +1029,9 @@
                     transitionTo(mValidatedState);
                     break;
                 case CMD_EVALUATE_PRIVATE_DNS:
+                    // TODO: this causes reevaluation of a single probe that is not counted in
+                    // metrics. Add support for such reevaluation probes in metrics, and log them
+                    // separately.
                     transitionTo(mEvaluatingPrivateDnsState);
                     break;
                 case EVENT_DNS_NOTIFICATION:
@@ -1285,6 +1360,7 @@
             sendMessageDelayed(CMD_CAPTIVE_PORTAL_RECHECK, 0 /* no UID */,
                     CAPTIVE_PORTAL_REEVALUATE_DELAY_MS);
             mValidations++;
+            maybeStopCollectionAndSendMetrics();
         }
 
         @Override
@@ -1316,6 +1392,12 @@
                                 notifyPrivateDnsConfigResolved();
                             } else {
                                 handlePrivateDnsEvaluationFailure();
+                                // The private DNS probe fails-fast if the server hostname cannot
+                                // be resolved. Record it as a failure with zero latency.
+                                // TODO: refactor this together with the probe recorded in
+                                // sendPrivateDnsProbe, so logging is symmetric / easier to follow.
+                                recordProbeEventMetrics(ProbeType.PT_PRIVDNS, 0 /* latency */,
+                                        ProbeResult.PR_FAILURE, null /* capportData */);
                                 break;
                             }
                         }
@@ -1425,6 +1507,8 @@
                 validationLog(PROBE_PRIVDNS, host,
                         String.format("%dus - Error: %s", time, uhe.getMessage()));
             }
+            recordProbeEventMetrics(ProbeType.PT_PRIVDNS, time, success ? ProbeResult.PR_SUCCESS :
+                    ProbeResult.PR_FAILURE, null /* capportData */);
             logValidationProbe(time, PROBE_PRIVDNS, success ? DNS_SUCCESS : DNS_FAILURE);
             return success;
         }
@@ -1435,6 +1519,14 @@
 
         @Override
         public void enter() {
+            // When starting a full probe cycle here, record any pending stats (for example if
+            // CMD_FORCE_REEVALUATE was called before evaluation finished, as can happen in
+            // EvaluatingPrivateDnsState).
+            maybeStopCollectionAndSendMetrics();
+            // Restart the metrics collection timers. Metrics will be stopped and sent when the
+            // validation attempt finishes (as success, failure or portal), or if it is interrupted
+            // (by being restarted or if NetworkMonitor stops).
+            startMetricsCollection();
             if (mEvaluateAttempts >= BLAME_FOR_EVALUATION_ATTEMPTS) {
                 //Don't continue to blame UID forever.
                 TrafficStats.clearThreadStatsUid();
@@ -1516,6 +1608,9 @@
     private class WaitingForNextProbeState extends State {
         @Override
         public void enter() {
+            // Send metrics for this evaluation attempt. Metrics collection (and its timers) will be
+            // restarted when the next probe starts.
+            maybeStopCollectionAndSendMetrics();
             scheduleNextProbe();
         }
 
@@ -2265,6 +2360,8 @@
         // network validation (the HTTPS probe, which would likely fail anyway) or the PAC probe.
         if (mPrivateIpNoInternetEnabled && probeType == ValidationProbeEvent.PROBE_HTTP
                 && (proxy == null) && hasPrivateIpAddress(resolvedAddr)) {
+            recordProbeEventMetrics(NetworkValidationMetrics.probeTypeToEnum(probeType),
+                    0 /* latency */, ProbeResult.PR_PRIVATE_IP_DNS, null /* capportData */);
             return CaptivePortalProbeResult.PRIVATE_IP;
         }
         return sendHttpProbe(url, probeType, null);
@@ -2296,6 +2393,9 @@
             result = ValidationProbeEvent.DNS_FAILURE;
         }
         final long latency = watch.stop();
+        recordProbeEventMetrics(ProbeType.PT_DNS, latency,
+                (result == ValidationProbeEvent.DNS_SUCCESS) ? ProbeResult.PR_SUCCESS :
+                ProbeResult.PR_FAILURE, null /* capportData */);
         logValidationProbe(latency, ValidationProbeEvent.PROBE_DNS, result);
         return addresses;
     }
@@ -2413,12 +2513,17 @@
         }
         logValidationProbe(probeTimer.stop(), probeType, httpResponseCode);
 
+        final CaptivePortalProbeResult probeResult;
         if (probeSpec == null) {
-            return new CaptivePortalProbeResult(httpResponseCode, redirectUrl, url.toString(),
-                    1 << probeType);
+            probeResult = new CaptivePortalProbeResult(httpResponseCode, redirectUrl,
+                    url.toString(),   1 << probeType);
         } else {
-            return probeSpec.getResult(httpResponseCode, redirectUrl);
+            probeResult = probeSpec.getResult(httpResponseCode, redirectUrl);
         }
+        recordProbeEventMetrics(NetworkValidationMetrics.probeTypeToEnum(probeType),
+                probeTimer.stop(), NetworkValidationMetrics.httpProbeResultToEnum(probeResult),
+                null /* capportData */);
+        return probeResult;
     }
 
     @VisibleForTesting
@@ -2570,8 +2675,9 @@
             super(deps, proxy, url, captivePortalApiUrl);
         }
 
-        private CaptivePortalDataShim tryCapportApiProbe() {
-            if (mCaptivePortalApiUrl == null) return null;
+        private CaptivePortalDataShim sendCapportApiProbe() {
+            // TODO: consider adding metrics counters for each case returning null in this method
+            // (cases where the API is not implemented properly).
             validationLog("Fetching captive portal data from " + mCaptivePortalApiUrl);
 
             final String apiContent;
@@ -2610,26 +2716,38 @@
 
             try {
                 final JSONObject info = new JSONObject(apiContent);
-                return CaptivePortalDataShimImpl.fromJson(info);
+                final CaptivePortalDataShim capportData = CaptivePortalDataShimImpl.fromJson(info);
+                if (capportData != null && capportData.isCaptive()
+                        && capportData.getUserPortalUrl() == null) {
+                    validationLog("Missing user-portal-url from capport response");
+                    return null;
+                }
+                return capportData;
             } catch (JSONException e) {
                 validationLog("Could not parse capport API JSON: " + e.getMessage());
                 return null;
             } catch (UnsupportedApiLevelException e) {
+                // This should never happen because LinkProperties would not have a capport URL
+                // before R.
                 validationLog("Platform API too low to support capport API");
                 return null;
             }
         }
 
+        private CaptivePortalDataShim tryCapportApiProbe() {
+            if (mCaptivePortalApiUrl == null) return null;
+            final Stopwatch capportApiWatch = new Stopwatch().start();
+            final CaptivePortalDataShim capportData = sendCapportApiProbe();
+            recordProbeEventMetrics(ProbeType.PT_CAPPORT_API, capportApiWatch.stop(),
+                    capportData == null ? ProbeResult.PR_FAILURE : ProbeResult.PR_SUCCESS,
+                    capportData);
+            return capportData;
+        }
+
         @Override
         protected CaptivePortalProbeResult sendProbe() {
             final CaptivePortalDataShim capportData = tryCapportApiProbe();
             if (capportData != null && capportData.isCaptive()) {
-                if (capportData.getUserPortalUrl() == null) {
-                    validationLog("Missing user-portal-url from capport response");
-                    return new CapportApiProbeResult(
-                            sendDnsAndHttpProbes(mProxy, mUrl, ValidationProbeEvent.PROBE_HTTP),
-                            null /* capportData */);
-                }
                 final String loginUrlString = capportData.getUserPortalUrl().toString();
                 // Starting from R (where CaptivePortalData was introduced), the captive portal app
                 // delegates to NetworkMonitor for verifying when the network validates instead of
@@ -3343,6 +3461,7 @@
             p.redirectUrl = redirectUrl;
             p.timestampMillis = SystemClock.elapsedRealtime();
             notifyNetworkTested(p);
+            recordValidationResult(result, redirectUrl);
         }
 
         @VisibleForTesting
diff --git a/tests/unit/src/com/android/networkstack/metrics/NetworkValidationMetricsTest.java b/tests/unit/src/com/android/networkstack/metrics/NetworkValidationMetricsTest.java
new file mode 100644
index 0000000..98e7b63
--- /dev/null
+++ b/tests/unit/src/com/android/networkstack/metrics/NetworkValidationMetricsTest.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.networkstack.metrics;
+
+import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_VPN;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+
+import static org.junit.Assert.assertTrue;
+
+import android.net.INetworkMonitor;
+import android.net.NetworkCapabilities;
+import android.net.captiveportal.CaptivePortalProbeResult;
+import android.net.metrics.ValidationProbeEvent;
+import android.stats.connectivity.ProbeResult;
+import android.stats.connectivity.ProbeType;
+import android.stats.connectivity.TransportType;
+import android.stats.connectivity.ValidationResult;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.networkstack.apishim.CaptivePortalDataShimImpl;
+import com.android.networkstack.apishim.common.CaptivePortalDataShim;
+
+import org.json.JSONObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class NetworkValidationMetricsTest {
+    private static final String TEST_LOGIN_URL = "https://testportal.example.com/login";
+    private static final String TEST_VENUE_INFO_URL = "https://venue.example.com/info";
+    private static final int TTL_TOLERANCE_SECS = 10;
+
+    private static final NetworkCapabilities WIFI_CAPABILITIES =
+            new NetworkCapabilities()
+                .addTransportType(NetworkCapabilities.TRANSPORT_WIFI);
+
+    @Test
+    public void testNetworkValidationMetrics_VerifyProbeTypeToEnum() throws Exception {
+        verifyProbeType(ValidationProbeEvent.PROBE_DNS, ProbeType.PT_DNS);
+        verifyProbeType(ValidationProbeEvent.PROBE_HTTP, ProbeType.PT_HTTP);
+        verifyProbeType(ValidationProbeEvent.PROBE_HTTPS, ProbeType.PT_HTTPS);
+        verifyProbeType(ValidationProbeEvent.PROBE_PAC, ProbeType.PT_PAC);
+        verifyProbeType(ValidationProbeEvent.PROBE_FALLBACK, ProbeType.PT_FALLBACK);
+        verifyProbeType(ValidationProbeEvent.PROBE_PRIVDNS, ProbeType.PT_PRIVDNS);
+    }
+
+    private void verifyProbeType(int inputProbeType, ProbeType expectedEnumType) {
+        assertEquals(expectedEnumType, NetworkValidationMetrics.probeTypeToEnum(inputProbeType));
+    }
+
+    @Test
+    public void testNetworkValidationMetrics_VerifyHttpProbeResultToEnum() throws Exception {
+        verifyProbeType(new CaptivePortalProbeResult(CaptivePortalProbeResult.SUCCESS_CODE,
+                ValidationProbeEvent.PROBE_HTTP), ProbeResult.PR_SUCCESS);
+        verifyProbeType(new CaptivePortalProbeResult(CaptivePortalProbeResult.FAILED_CODE,
+                ValidationProbeEvent.PROBE_HTTP), ProbeResult.PR_FAILURE);
+        verifyProbeType(new CaptivePortalProbeResult(CaptivePortalProbeResult.PORTAL_CODE,
+                ValidationProbeEvent.PROBE_HTTP), ProbeResult.PR_PORTAL);
+        verifyProbeType(CaptivePortalProbeResult.PRIVATE_IP, ProbeResult.PR_PRIVATE_IP_DNS);
+    }
+
+    private void verifyProbeType(CaptivePortalProbeResult inputResult,
+            ProbeResult expectedResult) {
+        assertEquals(expectedResult, NetworkValidationMetrics.httpProbeResultToEnum(inputResult));
+    }
+
+    @Test
+    public void testNetworkValidationMetrics_VerifyValidationResultToEnum() throws Exception {
+        verifyProbeType(INetworkMonitor.NETWORK_VALIDATION_RESULT_VALID, null,
+                ValidationResult.VR_SUCCESS);
+        verifyProbeType(0, TEST_LOGIN_URL, ValidationResult.VR_PORTAL);
+        verifyProbeType(INetworkMonitor.NETWORK_VALIDATION_RESULT_PARTIAL, null,
+                ValidationResult.VR_PARTIAL);
+        verifyProbeType(0, null, ValidationResult.VR_FAILURE);
+    }
+
+    private void verifyProbeType(int inputResult, String inputRedirectUrl,
+            ValidationResult expectedResult) {
+        assertEquals(expectedResult, NetworkValidationMetrics.validationResultToEnum(inputResult,
+                inputRedirectUrl));
+    }
+
+    @Test
+    public void testNetworkValidationMetrics_VerifyTransportTypeToEnum() throws Exception {
+        final NetworkValidationMetrics metrics = new NetworkValidationMetrics();
+        NetworkCapabilities nc = new NetworkCapabilities();
+        nc.addTransportType(TRANSPORT_WIFI);
+        assertEquals(TransportType.TT_WIFI, metrics.getTransportTypeFromNC(nc));
+        nc.addTransportType(TRANSPORT_VPN);
+        assertEquals(TransportType.TT_WIFI_VPN, metrics.getTransportTypeFromNC(nc));
+        nc.addTransportType(TRANSPORT_CELLULAR);
+        assertEquals(TransportType.TT_WIFI_CELLULAR_VPN, metrics.getTransportTypeFromNC(nc));
+
+        nc = new NetworkCapabilities();
+        nc.addTransportType(TRANSPORT_CELLULAR);
+        assertEquals(TransportType.TT_CELLULAR, metrics.getTransportTypeFromNC(nc));
+        nc.addTransportType(TRANSPORT_VPN);
+        assertEquals(TransportType.TT_CELLULAR_VPN, metrics.getTransportTypeFromNC(nc));
+
+        nc = new NetworkCapabilities();
+        nc.addTransportType(TRANSPORT_BLUETOOTH);
+        assertEquals(TransportType.TT_BLUETOOTH, metrics.getTransportTypeFromNC(nc));
+        nc.addTransportType(TRANSPORT_VPN);
+        assertEquals(TransportType.TT_BLUETOOTH_VPN, metrics.getTransportTypeFromNC(nc));
+
+        nc = new NetworkCapabilities();
+        nc.addTransportType(TRANSPORT_ETHERNET);
+        assertEquals(TransportType.TT_ETHERNET, metrics.getTransportTypeFromNC(nc));
+        nc.addTransportType(TRANSPORT_VPN);
+        assertEquals(TransportType.TT_ETHERNET_VPN, metrics.getTransportTypeFromNC(nc));
+    }
+
+    @Test
+    public void testNetworkValidationMetrics_VerifyConsecutiveProbeFailure() throws Exception {
+        final NetworkValidationMetrics metrics = new NetworkValidationMetrics();
+        metrics.startCollection(WIFI_CAPABILITIES);
+        // 1. PT_DNS probe
+        metrics.addProbeEvent(ProbeType.PT_DNS, 1234, ProbeResult.PR_SUCCESS, null);
+        // 2. Consecutive PT_HTTP probe failure
+        for (int i = 0; i < 30; i++) {
+            metrics.addProbeEvent(ProbeType.PT_HTTP, 1234, ProbeResult.PR_FAILURE, null);
+        }
+
+        // Write metric into statsd
+        final NetworkValidationReported stats = metrics.maybeStopCollectionAndSend();
+
+        // The maximum number of probe records should be the same as MAX_PROBE_EVENTS_COUNT
+        final ProbeEvents probeEvents = stats.getProbeEvents();
+        assertEquals(NetworkValidationMetrics.MAX_PROBE_EVENTS_COUNT,
+                probeEvents.getProbeEventCount());
+    }
+
+    @Test
+    public void testNetworkValidationMetrics_VerifyCollectMetrics() throws Exception {
+        final long bytesRemaining = 12_345L;
+        final long secondsRemaining = 3000L;
+        String apiContent = "{'captive': true,"
+                + "'user-portal-url': '" + TEST_LOGIN_URL + "',"
+                + "'venue-info-url': '" + TEST_VENUE_INFO_URL + "',"
+                + "'bytes-remaining': " + bytesRemaining + ","
+                + "'seconds-remaining': " + secondsRemaining + "}";
+        final NetworkValidationMetrics metrics = new NetworkValidationMetrics();
+        final int validationIndex = 1;
+        final long longlatency = Integer.MAX_VALUE + 12344567L;
+        metrics.startCollection(WIFI_CAPABILITIES);
+
+        final JSONObject info = new JSONObject(apiContent);
+        final CaptivePortalDataShim captivePortalData = CaptivePortalDataShimImpl.isSupported()
+                ? CaptivePortalDataShimImpl.fromJson(info) : null;
+
+        // 1. PT_CAPPORT_API probe w CapportApiData info
+        metrics.addProbeEvent(ProbeType.PT_CAPPORT_API, 1234, ProbeResult.PR_SUCCESS,
+                captivePortalData);
+        // 2. PT_CAPPORT_API probe w/o CapportApiData info
+        metrics.addProbeEvent(ProbeType.PT_CAPPORT_API, 1234, ProbeResult.PR_FAILURE, null);
+
+        // 3. PT_DNS probe
+        metrics.addProbeEvent(ProbeType.PT_DNS, 5678, ProbeResult.PR_FAILURE, null);
+
+        // 4. PT_HTTP probe
+        metrics.addProbeEvent(ProbeType.PT_HTTP, longlatency, ProbeResult.PR_PORTAL, null);
+
+        // add Validation result
+        metrics.setValidationResult(INetworkMonitor.NETWORK_VALIDATION_RESULT_PARTIAL, null);
+
+        // Write metric into statsd
+        final NetworkValidationReported stats = metrics.maybeStopCollectionAndSend();
+
+        // Verify: TransportType: WIFI
+        assertEquals(TransportType.TT_WIFI, stats.getTransportType());
+
+        // Verify: validationIndex
+        assertEquals(validationIndex, stats.getValidationIndex());
+
+        //  probe Count: 4 (PT_CAPPORT_API, PT_DNS, PT_HTTP, PT_CAPPORT_API)
+        final ProbeEvents probeEvents = stats.getProbeEvents();
+        assertEquals(4, probeEvents.getProbeEventCount());
+
+        // Verify the 1st probe: ProbeType = PT_CAPPORT_API, Latency_us = 1234,
+        //                       ProbeResult = PR_SUCCESS, CapportApiData = capportData
+        ProbeEvent probeEvent = probeEvents.getProbeEvent(0);
+        assertEquals(ProbeType.PT_CAPPORT_API, probeEvent.getProbeType());
+        assertEquals(1234, probeEvent.getLatencyMicros());
+        assertEquals(ProbeResult.PR_SUCCESS, probeEvent.getProbeResult());
+        if (CaptivePortalDataShimImpl.isSupported()) {
+            assertTrue(probeEvent.hasCapportApiData());
+            // Set secondsRemaining to 3000 and check that getRemainingTtlSecs is within 10 seconds
+            final CapportApiData capportData = probeEvent.getCapportApiData();
+            assertTrue(capportData.getRemainingTtlSecs() <= secondsRemaining);
+            assertTrue(capportData.getRemainingTtlSecs() + TTL_TOLERANCE_SECS > secondsRemaining);
+            assertEquals(captivePortalData.getByteLimit() / 1000, capportData.getRemainingBytes());
+        } else {
+            assertFalse(probeEvent.hasCapportApiData());
+        }
+
+        // Verify the 2nd probe: ProbeType = PT_CAPPORT_API, Latency_us = 1234,
+        //                       ProbeResult = PR_SUCCESS, CapportApiData = null
+        probeEvent = probeEvents.getProbeEvent(1);
+        assertEquals(ProbeType.PT_CAPPORT_API, probeEvent.getProbeType());
+        assertEquals(1234, probeEvent.getLatencyMicros());
+        assertEquals(ProbeResult.PR_FAILURE, probeEvent.getProbeResult());
+        assertEquals(false, probeEvent.hasCapportApiData());
+
+        // Verify the 3rd probe: ProbeType = PT_DNS, Latency_us = 5678,
+        //                       ProbeResult = PR_FAILURE, CapportApiData = null
+        probeEvent = probeEvents.getProbeEvent(2);
+        assertEquals(ProbeType.PT_DNS, probeEvent.getProbeType());
+        assertEquals(5678, probeEvent.getLatencyMicros());
+        assertEquals(ProbeResult.PR_FAILURE, probeEvent.getProbeResult());
+        assertEquals(false, probeEvent.hasCapportApiData());
+
+        // Verify the 4th probe: ProbeType = PT_HTTP, Latency_us = longlatency,
+        //                       ProbeResult = PR_PORTAL, CapportApiData = null
+        probeEvent = probeEvents.getProbeEvent(3);
+        assertEquals(ProbeType.PT_HTTP, probeEvent.getProbeType());
+        // The latency exceeds Integer.MAX_VALUE(2147483647), it is limited to Integer.MAX_VALUE
+        assertEquals(Integer.MAX_VALUE, probeEvent.getLatencyMicros());
+        assertEquals(ProbeResult.PR_PORTAL, probeEvent.getProbeResult());
+        assertEquals(false, probeEvent.hasCapportApiData());
+
+        // Verify the ValidationResult
+        assertEquals(ValidationResult.VR_PARTIAL, stats.getValidationResult());
+    }
+}