diff options
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(); } |