summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apishim/29/com/android/networkstack/apishim/api29/CaptivePortalDataShimImpl.java6
-rw-r--r--apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java8
-rw-r--r--common/moduleutils/src/android/net/shared/IpConfigurationParcelableUtil.java2
-rw-r--r--common/networkstackclient/src/android/net/DhcpResultsParcelable.aidl1
-rw-r--r--src/com/android/server/connectivity/NetworkMonitor.java237
-rw-r--r--tests/unit/src/android/net/shared/IpConfigurationParcelableUtilTest.java9
-rw-r--r--tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java181
7 files changed, 396 insertions, 48 deletions
diff --git a/apishim/29/com/android/networkstack/apishim/api29/CaptivePortalDataShimImpl.java b/apishim/29/com/android/networkstack/apishim/api29/CaptivePortalDataShimImpl.java
index d6e6b53..31be29c 100644
--- a/apishim/29/com/android/networkstack/apishim/api29/CaptivePortalDataShimImpl.java
+++ b/apishim/29/com/android/networkstack/apishim/api29/CaptivePortalDataShimImpl.java
@@ -17,6 +17,7 @@
package com.android.networkstack.apishim.api29;
import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
import com.android.networkstack.apishim.CaptivePortalDataShim;
import com.android.networkstack.apishim.UnsupportedApiLevelException;
@@ -46,4 +47,9 @@ public abstract class CaptivePortalDataShimImpl implements CaptivePortalDataShim
// Data class not supported in API 29
throw new UnsupportedApiLevelException("CaptivePortalData not supported on API 29");
}
+
+ @VisibleForTesting
+ public static boolean isSupported() {
+ return false;
+ }
}
diff --git a/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java b/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
index 6d3fb88..135aa63 100644
--- a/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
+++ b/apishim/30/com/android/networkstack/apishim/CaptivePortalDataShimImpl.java
@@ -23,6 +23,7 @@ import android.os.Build;
import android.os.RemoteException;
import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
import org.json.JSONException;
import org.json.JSONObject;
@@ -47,7 +48,7 @@ public class CaptivePortalDataShimImpl
@NonNull
public static CaptivePortalDataShim fromJson(JSONObject obj) throws JSONException,
UnsupportedApiLevelException {
- if (!ShimUtils.isReleaseOrDevelopmentApiAbove(Build.VERSION_CODES.Q)) {
+ if (!isSupported()) {
return com.android.networkstack.apishim.api29.CaptivePortalDataShimImpl.fromJson(obj);
}
@@ -69,6 +70,11 @@ public class CaptivePortalDataShimImpl
.build());
}
+ @VisibleForTesting
+ public static boolean isSupported() {
+ return ShimUtils.isReleaseOrDevelopmentApiAbove(Build.VERSION_CODES.Q);
+ }
+
private static long getLongOrDefault(JSONObject o, String key, long def) throws JSONException {
if (!o.has(key)) return def;
return o.getLong(key);
diff --git a/common/moduleutils/src/android/net/shared/IpConfigurationParcelableUtil.java b/common/moduleutils/src/android/net/shared/IpConfigurationParcelableUtil.java
index 172dc24..7ef764b 100644
--- a/common/moduleutils/src/android/net/shared/IpConfigurationParcelableUtil.java
+++ b/common/moduleutils/src/android/net/shared/IpConfigurationParcelableUtil.java
@@ -42,6 +42,7 @@ public final class IpConfigurationParcelableUtil {
p.serverAddress = parcelAddress(results.serverAddress);
p.vendorInfo = results.vendorInfo;
p.serverHostName = results.serverHostName;
+ p.captivePortalApiUrl = results.captivePortalApiUrl;
return p;
}
@@ -56,6 +57,7 @@ public final class IpConfigurationParcelableUtil {
results.serverAddress = (Inet4Address) unparcelAddress(p.serverAddress);
results.vendorInfo = p.vendorInfo;
results.serverHostName = p.serverHostName;
+ results.captivePortalApiUrl = p.captivePortalApiUrl;
return results;
}
diff --git a/common/networkstackclient/src/android/net/DhcpResultsParcelable.aidl b/common/networkstackclient/src/android/net/DhcpResultsParcelable.aidl
index c98d9c2..3151565 100644
--- a/common/networkstackclient/src/android/net/DhcpResultsParcelable.aidl
+++ b/common/networkstackclient/src/android/net/DhcpResultsParcelable.aidl
@@ -25,4 +25,5 @@ parcelable DhcpResultsParcelable {
String serverAddress;
String vendorInfo;
String serverHostName;
+ String captivePortalApiUrl;
}
diff --git a/src/com/android/server/connectivity/NetworkMonitor.java b/src/com/android/server/connectivity/NetworkMonitor.java
index 9ee4cd9..c29cea4 100644
--- a/src/com/android/server/connectivity/NetworkMonitor.java
+++ b/src/com/android/server/connectivity/NetworkMonitor.java
@@ -139,19 +139,26 @@ import androidx.annotation.BoolRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
+import androidx.annotation.VisibleForTesting;
-import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.RingBufferIndices;
import com.android.internal.util.State;
import com.android.internal.util.StateMachine;
import com.android.internal.util.TrafficStatsConstants;
import com.android.networkstack.R;
+import com.android.networkstack.apishim.CaptivePortalDataShim;
+import com.android.networkstack.apishim.CaptivePortalDataShimImpl;
+import com.android.networkstack.apishim.NetworkInformationShimImpl;
import com.android.networkstack.apishim.ShimUtils;
+import com.android.networkstack.apishim.UnsupportedApiLevelException;
import com.android.networkstack.metrics.DataStallDetectionStats;
import com.android.networkstack.metrics.DataStallStatsUtils;
import com.android.networkstack.netlink.TcpSocketTracker;
import com.android.networkstack.util.DnsUtils;
+import org.json.JSONException;
+import org.json.JSONObject;
+
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
@@ -169,6 +176,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Random;
import java.util.StringJoiner;
import java.util.UUID;
@@ -197,6 +205,11 @@ public class NetworkMonitor extends StateMachine {
private static final int SOCKET_TIMEOUT_MS = 10000;
private static final int PROBE_TIMEOUT_MS = 3000;
+ private static final int CAPPORT_API_MAX_JSON_LENGTH = 4096;
+ private static final String ACCEPT_HEADER = "Accept";
+ private static final String CONTENT_TYPE_HEADER = "Content-Type";
+ private static final String CAPPORT_API_CONTENT_TYPE = "application/captive+json";
+
enum EvaluationResult {
VALIDATED(true),
CAPTIVE_PORTAL(false);
@@ -760,7 +773,12 @@ public class NetworkMonitor extends StateMachine {
maybeDisableHttpsProbing(true /* acceptPartial */);
break;
case EVENT_LINK_PROPERTIES_CHANGED:
+ final Uri oldCapportUrl = getCaptivePortalApiUrl(mLinkProperties);
mLinkProperties = (LinkProperties) message.obj;
+ final Uri newCapportUrl = getCaptivePortalApiUrl(mLinkProperties);
+ if (!Objects.equals(oldCapportUrl, newCapportUrl)) {
+ sendMessage(CMD_FORCE_REEVALUATION, NO_UID, 0);
+ }
break;
case EVENT_NETWORK_CAPABILITIES_CHANGED:
mNetworkCapabilities = (NetworkCapabilities) message.obj;
@@ -964,6 +982,8 @@ public class NetworkMonitor extends StateMachine {
// 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 {
+ private Uri mEvaluatingCapportUrl;
+
@Override
public void enter() {
// If we have already started to track time spent in EvaluatingState
@@ -979,6 +999,7 @@ public class NetworkMonitor extends StateMachine {
}
mReevaluateDelayMs = INITIAL_REEVALUATE_DELAY_MS;
mEvaluateAttempts = 0;
+ mEvaluatingCapportUrl = getCaptivePortalApiUrl(mLinkProperties);
// Reset all current probe results to zero, but retain current validation state until
// validation succeeds or fails.
mEvaluationState.clearProbeResults();
@@ -1029,10 +1050,8 @@ public class NetworkMonitor extends StateMachine {
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;
+ // The evaluation process restarts via EvaluatingState#enter.
+ return shouldAcceptForceRevalidation() ? NOT_HANDLED : HANDLED;
// Disable HTTPS probe and transition to EvaluatingPrivateDnsState because:
// 1. Network is connected and finish the network validation.
// 2. NetworkMonitor detects network is partial connectivity and user accepts it.
@@ -1045,6 +1064,15 @@ public class NetworkMonitor extends StateMachine {
}
}
+ private boolean shouldAcceptForceRevalidation() {
+ // If the captive portal URL has changed since the last evaluation attempt, always
+ // revalidate. Otherwise, ignore any re-evaluation requests before
+ // IGNORE_REEVALUATE_ATTEMPTS are made.
+ return mEvaluateAttempts >= IGNORE_REEVALUATE_ATTEMPTS
+ || !Objects.equals(
+ mEvaluatingCapportUrl, getCaptivePortalApiUrl(mLinkProperties));
+ }
+
@Override
public void exit() {
TrafficStats.clearThreadStatsUid();
@@ -1942,45 +1970,161 @@ public class NetworkMonitor extends StateMachine {
}
}
- 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);
+ private abstract static class ProbeThread extends Thread {
+ private final CountDownLatch mLatch;
+ private final ProxyInfo mProxy;
+ private final URL mUrl;
+ protected final Uri mCaptivePortalApiUrl;
- final class ProbeThread extends Thread {
- private final boolean mIsHttps;
- private volatile CaptivePortalProbeResult mResult = CaptivePortalProbeResult.FAILED;
+ protected ProbeThread(CountDownLatch latch, ProxyInfo proxy, URL url,
+ Uri captivePortalApiUrl) {
+ mLatch = latch;
+ mProxy = proxy;
+ mUrl = url;
+ mCaptivePortalApiUrl = captivePortalApiUrl;
+ }
- ProbeThread(boolean isHttps) {
- mIsHttps = isHttps;
- }
+ private volatile CaptivePortalProbeResult mResult = CaptivePortalProbeResult.FAILED;
- public CaptivePortalProbeResult result() {
- return mResult;
+ public CaptivePortalProbeResult result() {
+ return mResult;
+ }
+
+ protected abstract CaptivePortalProbeResult sendProbe(ProxyInfo proxy, URL url);
+ public abstract boolean isConclusiveResult(CaptivePortalProbeResult result);
+
+ @Override
+ public void run() {
+ mResult = sendProbe(mProxy, mUrl);
+ if (isConclusiveResult(mResult)) {
+ // Stop waiting immediately if any probe is conclusive.
+ while (mLatch.getCount() > 0) {
+ mLatch.countDown();
+ }
}
+ // Signal this probe has completed.
+ mLatch.countDown();
+ }
+ }
- @Override
- public void run() {
- if (mIsHttps) {
- mResult =
- sendDnsAndHttpProbes(proxy, httpsUrl, ValidationProbeEvent.PROBE_HTTPS);
- } else {
- mResult = sendDnsAndHttpProbes(proxy, httpUrl, ValidationProbeEvent.PROBE_HTTP);
+ final class HttpsProbeThread extends ProbeThread {
+ HttpsProbeThread(CountDownLatch latch, ProxyInfo proxy, URL url, Uri captivePortalApiUrl) {
+ super(latch, proxy, url, captivePortalApiUrl);
+ }
+
+ @Override
+ protected CaptivePortalProbeResult sendProbe(ProxyInfo proxy, URL url) {
+ return sendDnsAndHttpProbes(proxy, url, ValidationProbeEvent.PROBE_HTTPS);
+ }
+
+ @Override
+ public boolean isConclusiveResult(CaptivePortalProbeResult result) {
+ // isPortal() is not expected on the HTTPS probe, but check it nonetheless.
+ // In case the capport API is available, the API is authoritative on whether there is
+ // a portal, so the HTTPS probe is not enough to conclude there is connectivity,
+ // and a determination will be made once the capport API probe returns. Note that the
+ // API can only force the system to detect a portal even if the HTTPS probe succeeds.
+ // It cannot force the system to detect no portal if the HTTPS probe fails.
+ return (result.isPortal() || result.isSuccessful()) && mCaptivePortalApiUrl == null;
+ }
+ }
+
+ final class HttpProbeThread extends ProbeThread {
+ private volatile CaptivePortalDataShim mCapportData;
+ HttpProbeThread(CountDownLatch latch, ProxyInfo proxy, URL url, Uri captivePortalApiUrl) {
+ super(latch, proxy, url, captivePortalApiUrl);
+ }
+
+ CaptivePortalDataShim getCaptivePortalData() {
+ return mCapportData;
+ }
+
+ private CaptivePortalDataShim tryCapportApiProbe() {
+ if (mCaptivePortalApiUrl == null) return null;
+ validationLog("Fetching captive portal data from " + mCaptivePortalApiUrl);
+
+ final String apiContent;
+ try {
+ final URL url = new URL(mCaptivePortalApiUrl.toString());
+ if (!"https".equals(url.getProtocol())) {
+ validationLog("Invalid captive portal API protocol: " + url.getProtocol());
+ return null;
}
- 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();
- }
+
+ final HttpURLConnection conn = makeProbeConnection(
+ url, true /* followRedirects */);
+ conn.setRequestProperty(ACCEPT_HEADER, CAPPORT_API_CONTENT_TYPE);
+ final int responseCode = conn.getResponseCode();
+ if (responseCode != 200) {
+ validationLog("Non-200 API response code: " + conn.getResponseCode());
+ return null;
+ }
+ final Charset charset = extractCharset(conn.getHeaderField(CONTENT_TYPE_HEADER));
+ if (charset != StandardCharsets.UTF_8) {
+ validationLog("Invalid charset for capport API: " + charset);
+ return null;
}
- // Signal this probe has completed.
- latch.countDown();
+
+ apiContent = readAsString(conn.getInputStream(),
+ CAPPORT_API_MAX_JSON_LENGTH, charset);
+ } catch (IOException e) {
+ validationLog("I/O error reading capport data: " + e.getMessage());
+ return null;
+ }
+
+ try {
+ final JSONObject info = new JSONObject(apiContent);
+ return CaptivePortalDataShimImpl.fromJson(info);
+ } catch (JSONException e) {
+ validationLog("Could not parse capport API JSON: " + e.getMessage());
+ return null;
+ } catch (UnsupportedApiLevelException e) {
+ validationLog("Platform API too low to support capport API");
+ return null;
}
}
- final ProbeThread httpsProbe = new ProbeThread(true);
- final ProbeThread httpProbe = new ProbeThread(false);
+ @Override
+ protected CaptivePortalProbeResult sendProbe(ProxyInfo proxy, URL url) {
+ mCapportData = tryCapportApiProbe();
+ if (mCapportData != null && mCapportData.isCaptive()) {
+ if (mCapportData.getUserPortalUrl() == null) {
+ validationLog("Missing user-portal-url from capport response");
+ return sendDnsAndHttpProbes(proxy, url, ValidationProbeEvent.PROBE_HTTP);
+ }
+ final String loginUrlString = mCapportData.getUserPortalUrl().toString();
+ // Starting from R (where CaptivePortalData was introduced), the captive portal app
+ // delegates to NetworkMonitor for verifying when the network validates instead of
+ // probing the detectUrl. So pass the detectUrl to have the portal open on that,
+ // page; CaptivePortalLogin will not use it for probing.
+ return new CaptivePortalProbeResult(
+ CaptivePortalProbeResult.PORTAL_CODE,
+ loginUrlString /* redirectUrl */,
+ loginUrlString /* detectUrl */);
+ }
+
+ // If the API says it's not captive, still check for HTTP connectivity. This helps
+ // with partial connectivity detection, and a broken API saying that there is no
+ // redirect when there is one.
+ return sendDnsAndHttpProbes(proxy, url, ValidationProbeEvent.PROBE_HTTP);
+ }
+
+ @Override
+ public boolean isConclusiveResult(CaptivePortalProbeResult result) {
+ return result.isPortal();
+ }
+ }
+
+ 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 Uri capportApiUrl = getCaptivePortalApiUrl(mLinkProperties);
+ final HttpsProbeThread httpsProbe = new HttpsProbeThread(latch, proxy, httpsUrl,
+ capportApiUrl);
+ final HttpProbeThread httpProbe = new HttpProbeThread(latch, proxy, httpUrl, capportApiUrl);
try {
httpsProbe.start();
@@ -1995,12 +2139,14 @@ public class NetworkMonitor extends StateMachine {
final CaptivePortalProbeResult httpResult = httpProbe.result();
// Look for a conclusive probe result first.
- if (httpResult.isPortal()) {
+ if (httpProbe.isConclusiveResult(httpResult)) {
+ maybeReportCaptivePortalData(httpProbe.getCaptivePortalData());
reportHttpProbeResult(NETWORK_VALIDATION_PROBE_HTTP, httpResult);
return httpResult;
}
- // httpsResult.isPortal() is not expected, but check it nonetheless.
- if (httpsResult.isPortal() || httpsResult.isSuccessful()) {
+
+ if (httpsProbe.isConclusiveResult(httpsResult)) {
+ maybeReportCaptivePortalData(httpProbe.getCaptivePortalData());
reportHttpProbeResult(NETWORK_VALIDATION_PROBE_HTTPS, httpsResult);
return httpsResult;
}
@@ -2019,6 +2165,7 @@ public class NetworkMonitor extends StateMachine {
// Otherwise wait until http and https probes completes and use their results.
try {
httpProbe.join();
+ maybeReportCaptivePortalData(httpProbe.getCaptivePortalData());
reportHttpProbeResult(NETWORK_VALIDATION_PROBE_HTTP, httpProbe.result());
if (httpProbe.result().isPortal()) {
@@ -2519,6 +2666,18 @@ public class NetworkMonitor extends StateMachine {
mEvaluationState.noteProbeResult(probeResult, succeeded);
}
+ private void maybeReportCaptivePortalData(@Nullable CaptivePortalDataShim data) {
+ // Do not clear data even if it is null: access points should not stop serving the API, so
+ // if the API disappears this is treated as a temporary failure, and previous data should
+ // remain valid.
+ if (data == null) return;
+ try {
+ data.notifyChanged(mCallback);
+ } catch (RemoteException e) {
+ Log.e(TAG, "Error notifying ConnectivityService of new capport data", e);
+ }
+ }
+
/**
* Interface for logging dns results.
*/
@@ -2546,4 +2705,8 @@ public class NetworkMonitor extends StateMachine {
return ((type & DATA_STALL_EVALUATION_TYPE_DNS) != 0)
? new DnsStallDetector(threshold) : null;
}
+
+ private static Uri getCaptivePortalApiUrl(LinkProperties lp) {
+ return NetworkInformationShimImpl.newInstance().getCaptivePortalApiUrl(lp);
+ }
}
diff --git a/tests/unit/src/android/net/shared/IpConfigurationParcelableUtilTest.java b/tests/unit/src/android/net/shared/IpConfigurationParcelableUtilTest.java
index f987389..532cb64 100644
--- a/tests/unit/src/android/net/shared/IpConfigurationParcelableUtilTest.java
+++ b/tests/unit/src/android/net/shared/IpConfigurationParcelableUtilTest.java
@@ -57,8 +57,9 @@ public class IpConfigurationParcelableUtilTest {
mDhcpResults.leaseDuration = 3600;
mDhcpResults.serverHostName = "dhcp.example.com";
mDhcpResults.mtu = 1450;
+ mDhcpResults.captivePortalApiUrl = "https://example.com/testapi";
// Any added DhcpResults field must be included in equals() to be tested properly
- assertFieldCountEquals(9, DhcpResults.class);
+ assertFieldCountEquals(10, DhcpResults.class);
}
@Test
@@ -108,6 +109,12 @@ public class IpConfigurationParcelableUtilTest {
doDhcpResultsParcelUnparcelTest();
}
+ @Test
+ public void testParcelUnparcelDhcpResults_NullCaptivePortalApiUrl() {
+ mDhcpResults.captivePortalApiUrl = null;
+ doDhcpResultsParcelUnparcelTest();
+ }
+
private void doDhcpResultsParcelUnparcelTest() {
final DhcpResults unparceled = fromStableParcelable(toStableParcelable(mDhcpResults));
assertEquals(mDhcpResults, unparceled);
diff --git a/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java b/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
index 157d257..f861a44 100644
--- a/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
+++ b/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
@@ -57,6 +57,8 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
@@ -72,6 +74,8 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static java.lang.System.currentTimeMillis;
+import static java.util.Collections.singletonList;
import static java.util.stream.Collectors.toList;
import android.annotation.NonNull;
@@ -82,6 +86,7 @@ import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.content.res.Resources;
+import android.net.CaptivePortalData;
import android.net.ConnectivityManager;
import android.net.DnsResolver;
import android.net.INetd;
@@ -90,6 +95,7 @@ import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkInfo;
+import android.net.Uri;
import android.net.captiveportal.CaptivePortalProbeResult;
import android.net.metrics.IpConnectivityLog;
import android.net.shared.PrivateDnsConfig;
@@ -119,6 +125,7 @@ import androidx.test.filters.SmallTest;
import androidx.test.runner.AndroidJUnit4;
import com.android.networkstack.R;
+import com.android.networkstack.apishim.CaptivePortalDataShimImpl;
import com.android.networkstack.apishim.ShimUtils;
import com.android.networkstack.metrics.DataStallDetectionStats;
import com.android.networkstack.metrics.DataStallStatsUtils;
@@ -137,6 +144,7 @@ import org.mockito.Spy;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
@@ -149,8 +157,10 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Random;
import java.util.concurrent.Executor;
@@ -160,6 +170,7 @@ import javax.net.ssl.SSLHandshakeException;
@SmallTest
public class NetworkMonitorTest {
private static final String LOCATION_HEADER = "location";
+ private static final String CONTENT_TYPE_HEADER = "Content-Type";
private @Mock Context mContext;
private @Mock Configuration mConfiguration;
@@ -175,6 +186,7 @@ public class NetworkMonitorTest {
private @Mock HttpURLConnection mHttpsConnection;
private @Mock HttpURLConnection mFallbackConnection;
private @Mock HttpURLConnection mOtherFallbackConnection;
+ private @Mock HttpURLConnection mCapportApiConnection;
private @Mock Random mRandom;
private @Mock NetworkMonitor.Dependencies mDependencies;
private @Mock INetworkMonitorCallbacks mCallbacks;
@@ -194,6 +206,9 @@ public class NetworkMonitorTest {
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 String TEST_CAPPORT_API_URL = "https://capport.example.com/api";
+ 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 String TEST_MCCMNC = "123456";
private static final int VALIDATION_RESULT_INVALID = 0;
@@ -208,6 +223,8 @@ public class NetworkMonitorTest {
private static final int VALIDATION_RESULT_VALID = NETWORK_VALIDATION_PROBE_DNS
| NETWORK_VALIDATION_PROBE_HTTPS
| NETWORK_VALIDATION_RESULT_VALID;
+ private static final int VALIDATION_RESULT_VALID_ALL_PROBES =
+ VALIDATION_RESULT_VALID | NETWORK_VALIDATION_PROBE_HTTP;
private static final int VALIDATION_RESULT_PRIVDNS_VALID = NETWORK_VALIDATION_PROBE_DNS
| NETWORK_VALIDATION_PROBE_HTTPS | NETWORK_VALIDATION_PROBE_PRIVDNS;
@@ -218,6 +235,13 @@ public class NetworkMonitorTest {
private static final int HANDLER_TIMEOUT_MS = 1000;
private static final LinkProperties TEST_LINK_PROPERTIES = new LinkProperties();
+ private static final LinkProperties CAPPORT_LINK_PROPERTIES = makeCapportLinkProperties();
+
+ private static LinkProperties makeCapportLinkProperties() {
+ final LinkProperties lp = new LinkProperties(TEST_LINK_PROPERTIES);
+ lp.setCaptivePortalApiUrl(Uri.parse(TEST_CAPPORT_API_URL));
+ return lp;
+ }
private static final NetworkCapabilities METERED_CAPABILITIES = new NetworkCapabilities()
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
@@ -412,6 +436,8 @@ public class NetworkMonitorTest {
return mFallbackConnection;
case TEST_OTHER_FALLBACK_URL:
return mOtherFallbackConnection;
+ case TEST_CAPPORT_API_URL:
+ return mCapportApiConnection;
default:
fail("URL not mocked: " + url.toString());
return null;
@@ -743,6 +769,123 @@ public class NetworkMonitorTest {
verify(mFallbackConnection, never()).getResponseCode();
}
+ @Test
+ public void testIsCaptivePortal_CapportApiIsPortal() throws Exception {
+ assumeTrue(CaptivePortalDataShimImpl.isSupported());
+ setSslException(mHttpsConnection);
+ final long bytesRemaining = 10_000L;
+ final long secondsRemaining = 500L;
+
+ setApiContent(mCapportApiConnection, "{'captive': true,"
+ + "'user-portal-url': '" + TEST_LOGIN_URL + "',"
+ + "'venue-info-url': '" + TEST_VENUE_INFO_URL + "',"
+ + "'bytes-remaining': " + bytesRemaining + ","
+ + "'seconds-remaining': " + secondsRemaining + "}");
+
+ runNetworkTest(CAPPORT_LINK_PROPERTIES, METERED_CAPABILITIES, VALIDATION_RESULT_PORTAL);
+
+ verify(mHttpConnection, never()).getResponseCode();
+ verify(mCapportApiConnection).getResponseCode();
+ assertNotNull(mNetworkTestedRedirectUrlCaptor.getValue());
+
+ final ArgumentCaptor<CaptivePortalData> capportDataCaptor =
+ ArgumentCaptor.forClass(CaptivePortalData.class);
+ verify(mCallbacks).notifyCaptivePortalDataChanged(capportDataCaptor.capture());
+ final CaptivePortalData p = capportDataCaptor.getValue();
+ assertTrue(p.isCaptive());
+ assertEquals(Uri.parse(TEST_LOGIN_URL), p.getUserPortalUrl());
+ assertEquals(Uri.parse(TEST_VENUE_INFO_URL), p.getVenueInfoUrl());
+ assertEquals(bytesRemaining, p.getByteLimit());
+ final long expectedExpiry = currentTimeMillis() + secondsRemaining * 1000;
+ // Actual expiry will be slightly lower as some time as passed
+ assertTrue(p.getExpiryTimeMillis() <= expectedExpiry);
+ assertTrue(p.getExpiryTimeMillis() > expectedExpiry - 30_000);
+ }
+
+ @Test
+ public void testIsCaptivePortal_CapportApiRevalidation() throws Exception {
+ assumeTrue(CaptivePortalDataShimImpl.isSupported());
+ final NetworkMonitor nm = runValidatedNetworkTest();
+
+ setApiContent(mCapportApiConnection, "{'captive': true, "
+ + "'user-portal-url': '" + TEST_LOGIN_URL + "'}");
+ nm.notifyLinkPropertiesChanged(CAPPORT_LINK_PROPERTIES);
+
+ verifyNetworkTested(VALIDATION_RESULT_PORTAL);
+ verify(mCallbacks).notifyCaptivePortalDataChanged(
+ argThat(data -> Uri.parse(TEST_LOGIN_URL).equals(data.getUserPortalUrl())));
+ assertEquals(TEST_LOGIN_URL, mNetworkTestedRedirectUrlCaptor.getValue());
+
+ // HTTP probe was sent on first validation but not re-sent when there was a portal URL.
+ verify(mHttpConnection, times(1)).getResponseCode();
+ verify(mCapportApiConnection, times(1)).getResponseCode();
+ }
+
+ @Test
+ public void testIsCaptivePortal_CapportApiNotPortalNotValidated() throws Exception {
+ assumeTrue(CaptivePortalDataShimImpl.isSupported());
+ setSslException(mHttpsConnection);
+ setStatus(mHttpConnection, 500);
+ setApiContent(mCapportApiConnection, "{'captive': false,"
+ + "'venue-info-url': '" + TEST_VENUE_INFO_URL + "'}");
+ runNetworkTest(CAPPORT_LINK_PROPERTIES, METERED_CAPABILITIES, VALIDATION_RESULT_INVALID);
+
+ verify(mCallbacks).notifyCaptivePortalDataChanged(argThat(data ->
+ Uri.parse(TEST_VENUE_INFO_URL).equals(data.getVenueInfoUrl())));
+ }
+
+ @Test
+ public void testIsCaptivePortal_CapportApiNotPortalPartial() throws Exception {
+ assumeTrue(CaptivePortalDataShimImpl.isSupported());
+ setSslException(mHttpsConnection);
+ setStatus(mHttpConnection, 204);
+ setApiContent(mCapportApiConnection, "{'captive': false,"
+ + "'venue-info-url': '" + TEST_VENUE_INFO_URL + "'}");
+ runNetworkTest(CAPPORT_LINK_PROPERTIES, METERED_CAPABILITIES, VALIDATION_RESULT_PARTIAL);
+
+ verify(mCallbacks).notifyCaptivePortalDataChanged(argThat(data ->
+ Uri.parse(TEST_VENUE_INFO_URL).equals(data.getVenueInfoUrl())));
+ }
+
+ @Test
+ public void testIsCaptivePortal_CapportApiNotPortalValidated() throws Exception {
+ assumeTrue(CaptivePortalDataShimImpl.isSupported());
+ setStatus(mHttpsConnection, 204);
+ setStatus(mHttpConnection, 204);
+ setApiContent(mCapportApiConnection, "{'captive': false,"
+ + "'venue-info-url': '" + TEST_VENUE_INFO_URL + "'}");
+ runNetworkTest(CAPPORT_LINK_PROPERTIES, METERED_CAPABILITIES,
+ VALIDATION_RESULT_VALID_ALL_PROBES);
+
+ verify(mCallbacks).notifyCaptivePortalDataChanged(argThat(data ->
+ Uri.parse(TEST_VENUE_INFO_URL).equals(data.getVenueInfoUrl())));
+ }
+
+ @Test
+ public void testIsCaptivePortal_CapportApiInvalidContent() throws Exception {
+ assumeTrue(CaptivePortalDataShimImpl.isSupported());
+ setStatus(mHttpsConnection, 204);
+ setPortal302(mHttpConnection);
+ setApiContent(mCapportApiConnection, "{SomeInvalidText");
+ runNetworkTest(CAPPORT_LINK_PROPERTIES, METERED_CAPABILITIES, VALIDATION_RESULT_PORTAL);
+
+ verify(mCallbacks, never()).notifyCaptivePortalDataChanged(any());
+ verify(mHttpConnection).getResponseCode();
+ }
+
+ @Test
+ public void testIsCaptivePortal_CapportApiNotSupported() throws Exception {
+ assumeFalse(CaptivePortalDataShimImpl.isSupported());
+ setSslException(mHttpsConnection);
+ setPortal302(mHttpConnection);
+ setApiContent(mCapportApiConnection, "{'captive': false,"
+ + "'venue-info-url': '" + TEST_VENUE_INFO_URL + "'}");
+ runNetworkTest(CAPPORT_LINK_PROPERTIES, METERED_CAPABILITIES, VALIDATION_RESULT_PORTAL);
+
+ verify(mCallbacks, never()).notifyCaptivePortalDataChanged(any());
+ verify(mHttpConnection).getResponseCode();
+ }
+
private void setupFallbackSpec() throws IOException {
setFallbackSpecs("http://example.com@@/@@204@@/@@"
+ "@@,@@"
@@ -931,7 +1074,8 @@ public class NetworkMonitorTest {
@Test
public void testNoInternetCapabilityValidated() throws Exception {
- runNetworkTest(NO_INTERNET_CAPABILITIES, NETWORK_VALIDATION_RESULT_VALID);
+ runNetworkTest(TEST_LINK_PROPERTIES, NO_INTERNET_CAPABILITIES,
+ NETWORK_VALIDATION_RESULT_VALID);
verify(mCleartextDnsNetwork, never()).openConnection(any());
}
@@ -941,7 +1085,7 @@ public class NetworkMonitorTest {
setPortal302(mHttpConnection);
final NetworkMonitor nm = makeMonitor(METERED_CAPABILITIES);
- nm.notifyNetworkConnected(TEST_LINK_PROPERTIES, METERED_CAPABILITIES);
+ notifyNetworkConnected(nm, METERED_CAPABILITIES);
verify(mCallbacks, timeout(HANDLER_TIMEOUT_MS).times(1))
.showProvisioningNotification(any(), any());
@@ -989,7 +1133,7 @@ public class NetworkMonitorTest {
WrappedNetworkMonitor wnm = makeNotMeteredNetworkMonitor();
wnm.notifyPrivateDnsSettingsChanged(new PrivateDnsConfig("dns6.google",
new InetAddress[0]));
- wnm.notifyNetworkConnected(TEST_LINK_PROPERTIES, NOT_METERED_CAPABILITIES);
+ notifyNetworkConnected(wnm, NOT_METERED_CAPABILITIES);
verify(mCallbacks, timeout(HANDLER_TIMEOUT_MS).times(1))
.notifyNetworkTestedWithExtras(eq(expectedResult), eq(null), anyLong(),
bundleForNotifyNetworkTested(expectedResult));
@@ -1483,7 +1627,7 @@ public class NetworkMonitorTest {
assertNull(mNetworkTestedRedirectUrlCaptor.getValue());
}
- private NetworkMonitor runValidatedNetworkTest() throws Exception {
+ private NetworkMonitor runValidatedNetworkTest() throws IOException {
setStatus(mHttpsConnection, 204);
setStatus(mHttpConnection, 204);
// Expect to send HTTPs and evaluation results.
@@ -1491,12 +1635,20 @@ public class NetworkMonitorTest {
}
private NetworkMonitor runNetworkTest(int testResult) {
- return runNetworkTest(METERED_CAPABILITIES, testResult);
+ return runNetworkTest(TEST_LINK_PROPERTIES, METERED_CAPABILITIES, testResult);
}
- private NetworkMonitor runNetworkTest(NetworkCapabilities nc, int testResult) {
+ private NetworkMonitor runNetworkTest(LinkProperties lp, NetworkCapabilities nc,
+ int testResult) {
final NetworkMonitor monitor = makeMonitor(nc);
- monitor.notifyNetworkConnected(TEST_LINK_PROPERTIES, nc);
+ monitor.notifyNetworkConnected(lp, nc);
+ verifyNetworkTested(testResult);
+ HandlerUtilsKt.waitForIdle(monitor.getHandler(), HANDLER_TIMEOUT_MS);
+
+ return monitor;
+ }
+
+ private void verifyNetworkTested(int testResult) {
try {
verify(mCallbacks, timeout(HANDLER_TIMEOUT_MS))
.notifyNetworkTestedWithExtras(eq(testResult),
@@ -1505,9 +1657,10 @@ public class NetworkMonitorTest {
} catch (RemoteException e) {
fail("Unexpected exception: " + e);
}
- HandlerUtilsKt.waitForIdle(monitor.getHandler(), HANDLER_TIMEOUT_MS);
+ }
- return monitor;
+ private void notifyNetworkConnected(NetworkMonitor nm, NetworkCapabilities nc) {
+ nm.notifyNetworkConnected(TEST_LINK_PROPERTIES, nc);
}
private void setSslException(HttpURLConnection connection) throws IOException {
@@ -1523,6 +1676,16 @@ public class NetworkMonitorTest {
set302(connection, "http://login.example.com");
}
+ private void setApiContent(HttpURLConnection connection, String content) throws IOException {
+ setStatus(connection, 200);
+ final Map<String, List<String>> headerFields = new HashMap<>();
+ headerFields.put(
+ CONTENT_TYPE_HEADER, singletonList("application/captive+json;charset=UTF-8"));
+ doReturn(headerFields).when(connection).getHeaderFields();
+ doReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)))
+ .when(connection).getInputStream();
+ }
+
private void setStatus(HttpURLConnection connection, int status) throws IOException {
doReturn(status).when(connection).getResponseCode();
}