Add configurable captive portal probes
The probes allow testing for a configurable status code and location
header (regexes). They are disabled by default, so this CL is a
no-op unless the probe configurations are pushed.
Bug: b/79499239
Test: tests in CL pass, manual: captive portal login works
Change-Id: I785723aaed06054b9aa8ebff77803f23d7836db9
diff --git a/core/java/android/net/ConnectivityManager.java b/core/java/android/net/ConnectivityManager.java
index c3b8f39..c5cb1f5 100644
--- a/core/java/android/net/ConnectivityManager.java
+++ b/core/java/android/net/ConnectivityManager.java
@@ -238,6 +238,14 @@
public static final String EXTRA_CAPTIVE_PORTAL_URL = "android.net.extra.CAPTIVE_PORTAL_URL";
/**
+ * Key for passing a {@link android.net.captiveportal.CaptivePortalProbeSpec} to the captive
+ * portal login activity.
+ * {@hide}
+ */
+ public static final String EXTRA_CAPTIVE_PORTAL_PROBE_SPEC =
+ "android.net.extra.CAPTIVE_PORTAL_PROBE_SPEC";
+
+ /**
* Key for passing a user agent string to the captive portal login activity.
* {@hide}
*/
diff --git a/core/java/android/net/captiveportal/CaptivePortalProbeResult.java b/core/java/android/net/captiveportal/CaptivePortalProbeResult.java
index 614c0b8..1634694 100644
--- a/core/java/android/net/captiveportal/CaptivePortalProbeResult.java
+++ b/core/java/android/net/captiveportal/CaptivePortalProbeResult.java
@@ -16,6 +16,8 @@
package android.net.captiveportal;
+import android.annotation.Nullable;
+
/**
* Result of calling isCaptivePortal().
* @hide
@@ -23,6 +25,7 @@
public final class CaptivePortalProbeResult {
public static final int SUCCESS_CODE = 204;
public static final int FAILED_CODE = 599;
+ public static final int PORTAL_CODE = 302;
public static final CaptivePortalProbeResult FAILED = new CaptivePortalProbeResult(FAILED_CODE);
public static final CaptivePortalProbeResult SUCCESS =
@@ -32,15 +35,23 @@
public final String redirectUrl; // Redirect destination returned from Internet probe.
public final String detectUrl; // URL where a 204 response code indicates
// captive portal has been appeased.
+ @Nullable
+ public final CaptivePortalProbeSpec probeSpec;
public CaptivePortalProbeResult(int httpResponseCode) {
this(httpResponseCode, null, null);
}
public CaptivePortalProbeResult(int httpResponseCode, String redirectUrl, String detectUrl) {
+ this(httpResponseCode, redirectUrl, detectUrl, null);
+ }
+
+ public CaptivePortalProbeResult(int httpResponseCode, String redirectUrl, String detectUrl,
+ CaptivePortalProbeSpec probeSpec) {
mHttpResponseCode = httpResponseCode;
this.redirectUrl = redirectUrl;
this.detectUrl = detectUrl;
+ this.probeSpec = probeSpec;
}
public boolean isSuccessful() {
diff --git a/core/java/android/net/captiveportal/CaptivePortalProbeSpec.java b/core/java/android/net/captiveportal/CaptivePortalProbeSpec.java
new file mode 100644
index 0000000..57a926a
--- /dev/null
+++ b/core/java/android/net/captiveportal/CaptivePortalProbeSpec.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.captiveportal;
+
+import static android.net.captiveportal.CaptivePortalProbeResult.PORTAL_CODE;
+import static android.net.captiveportal.CaptivePortalProbeResult.SUCCESS_CODE;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/** @hide */
+public abstract class CaptivePortalProbeSpec {
+ public static final String HTTP_LOCATION_HEADER_NAME = "Location";
+
+ private static final String TAG = CaptivePortalProbeSpec.class.getSimpleName();
+ private static final String REGEX_SEPARATOR = "@@/@@";
+ private static final String SPEC_SEPARATOR = "@@,@@";
+
+ private final String mEncodedSpec;
+ private final URL mUrl;
+
+ CaptivePortalProbeSpec(String encodedSpec, URL url) {
+ mEncodedSpec = encodedSpec;
+ mUrl = url;
+ }
+
+ /**
+ * Parse a {@link CaptivePortalProbeSpec} from a {@link String}.
+ *
+ * <p>The valid format is a URL followed by two regular expressions, each separated by "@@/@@".
+ * @throws MalformedURLException The URL has invalid format for {@link URL#URL(String)}.
+ * @throws ParseException The string is empty, does not match the above format, or a regular
+ * expression is invalid for {@link Pattern#compile(String)}.
+ */
+ @NonNull
+ public static CaptivePortalProbeSpec parseSpec(String spec) throws ParseException,
+ MalformedURLException {
+ if (TextUtils.isEmpty(spec)) {
+ throw new ParseException("Empty probe spec", 0 /* errorOffset */);
+ }
+
+ String[] splits = TextUtils.split(spec, REGEX_SEPARATOR);
+ if (splits.length != 3) {
+ throw new ParseException("Probe spec does not have 3 parts", 0 /* errorOffset */);
+ }
+
+ final int statusRegexPos = splits[0].length() + REGEX_SEPARATOR.length();
+ final int locationRegexPos = statusRegexPos + splits[1].length() + REGEX_SEPARATOR.length();
+ final Pattern statusRegex = parsePatternIfNonEmpty(splits[1], statusRegexPos);
+ final Pattern locationRegex = parsePatternIfNonEmpty(splits[2], locationRegexPos);
+
+ return new RegexMatchProbeSpec(spec, new URL(splits[0]), statusRegex, locationRegex);
+ }
+
+ @Nullable
+ private static Pattern parsePatternIfNonEmpty(String pattern, int pos) throws ParseException {
+ if (TextUtils.isEmpty(pattern)) {
+ return null;
+ }
+ try {
+ return Pattern.compile(pattern);
+ } catch (PatternSyntaxException e) {
+ throw new ParseException(
+ String.format("Invalid status pattern [%s]: %s", pattern, e),
+ pos /* errorOffset */);
+ }
+ }
+
+ /**
+ * Parse a {@link CaptivePortalProbeSpec} from a {@link String}, or return a fallback spec
+ * based on the status code of the provided URL if the spec cannot be parsed.
+ */
+ @Nullable
+ public static CaptivePortalProbeSpec parseSpecOrNull(@Nullable String spec) {
+ if (spec != null) {
+ try {
+ return parseSpec(spec);
+ } catch (ParseException | MalformedURLException e) {
+ Log.e(TAG, "Invalid probe spec: " + spec, e);
+ // Fall through
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Parse a config String to build an array of {@link CaptivePortalProbeSpec}.
+ *
+ * <p>Each spec is separated by @@,@@ and follows the format for {@link #parseSpec(String)}.
+ * <p>This method does not throw but ignores any entry that could not be parsed.
+ */
+ public static CaptivePortalProbeSpec[] parseCaptivePortalProbeSpecs(String settingsVal) {
+ List<CaptivePortalProbeSpec> specs = new ArrayList<>();
+ if (settingsVal != null) {
+ for (String spec : TextUtils.split(settingsVal, SPEC_SEPARATOR)) {
+ try {
+ specs.add(parseSpec(spec));
+ } catch (ParseException | MalformedURLException e) {
+ Log.e(TAG, "Invalid probe spec: " + spec, e);
+ }
+ }
+ }
+
+ if (specs.isEmpty()) {
+ Log.e(TAG, String.format("could not create any validation spec from %s", settingsVal));
+ }
+ return specs.toArray(new CaptivePortalProbeSpec[specs.size()]);
+ }
+
+ /**
+ * Get the probe result from HTTP status and location header.
+ */
+ public abstract CaptivePortalProbeResult getResult(int status, @Nullable String locationHeader);
+
+ public String getEncodedSpec() {
+ return mEncodedSpec;
+ }
+
+ public URL getUrl() {
+ return mUrl;
+ }
+
+ /**
+ * Implementation of {@link CaptivePortalProbeSpec} that is based on configurable regular
+ * expressions for the HTTP status code and location header (if any). Matches indicate that
+ * the page is not a portal.
+ * This probe cannot fail: it always returns SUCCESS_CODE or PORTAL_CODE
+ */
+ private static class RegexMatchProbeSpec extends CaptivePortalProbeSpec {
+ @Nullable
+ final Pattern mStatusRegex;
+ @Nullable
+ final Pattern mLocationHeaderRegex;
+
+ RegexMatchProbeSpec(
+ String spec, URL url, Pattern statusRegex, Pattern locationHeaderRegex) {
+ super(spec, url);
+ mStatusRegex = statusRegex;
+ mLocationHeaderRegex = locationHeaderRegex;
+ }
+
+ @Override
+ public CaptivePortalProbeResult getResult(int status, String locationHeader) {
+ final boolean statusMatch = safeMatch(String.valueOf(status), mStatusRegex);
+ final boolean locationMatch = safeMatch(locationHeader, mLocationHeaderRegex);
+ final int returnCode = statusMatch && locationMatch ? SUCCESS_CODE : PORTAL_CODE;
+ return new CaptivePortalProbeResult(
+ returnCode, locationHeader, getUrl().toString(), this);
+ }
+ }
+
+ private static boolean safeMatch(@Nullable String value, @Nullable Pattern pattern) {
+ // No value is a match ("no location header" passes the location rule for non-redirects)
+ return pattern == null || TextUtils.isEmpty(value) || pattern.matcher(value).matches();
+ }
+}
diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java
index 7bf3af1..3d070c5 100644
--- a/core/java/android/provider/Settings.java
+++ b/core/java/android/provider/Settings.java
@@ -10251,6 +10251,15 @@
"captive_portal_other_fallback_urls";
/**
+ * A list of captive portal detection specifications used in addition to the fallback URLs.
+ * Each spec has the format url@@/@@statusCodeRegex@@/@@contentRegex. Specs are separated
+ * by "@@,@@".
+ * @hide
+ */
+ public static final String CAPTIVE_PORTAL_FALLBACK_PROBE_SPECS =
+ "captive_portal_fallback_probe_specs";
+
+ /**
* Whether to use HTTPS for network validation. This is enabled by default and the setting
* needs to be set to 0 to disable it. This setting is a misnomer because captive portals
* don't actually use HTTPS, but it's consistent with the other settings.
diff --git a/core/tests/coretests/src/android/provider/SettingsBackupTest.java b/core/tests/coretests/src/android/provider/SettingsBackupTest.java
index 5b7fc6e..14cf0a3 100644
--- a/core/tests/coretests/src/android/provider/SettingsBackupTest.java
+++ b/core/tests/coretests/src/android/provider/SettingsBackupTest.java
@@ -155,6 +155,7 @@
Settings.Global.CAPTIVE_PORTAL_HTTP_URL,
Settings.Global.CAPTIVE_PORTAL_MODE,
Settings.Global.CAPTIVE_PORTAL_OTHER_FALLBACK_URLS,
+ Settings.Global.CAPTIVE_PORTAL_FALLBACK_PROBE_SPECS,
Settings.Global.CAPTIVE_PORTAL_SERVER,
Settings.Global.CAPTIVE_PORTAL_USE_HTTPS,
Settings.Global.CAPTIVE_PORTAL_USER_AGENT,
diff --git a/services/core/java/com/android/server/connectivity/NetworkMonitor.java b/services/core/java/com/android/server/connectivity/NetworkMonitor.java
index 4521d3a..ca792c0 100644
--- a/services/core/java/com/android/server/connectivity/NetworkMonitor.java
+++ b/services/core/java/com/android/server/connectivity/NetworkMonitor.java
@@ -19,7 +19,11 @@
import static android.net.CaptivePortal.APP_RETURN_DISMISSED;
import static android.net.CaptivePortal.APP_RETURN_UNWANTED;
import static android.net.CaptivePortal.APP_RETURN_WANTED_AS_IS;
+import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL_PROBE_SPEC;
+import static android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL;
+import static android.net.metrics.ValidationProbeEvent.PROBE_FALLBACK;
+import android.annotation.Nullable;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
@@ -35,6 +39,7 @@
import android.net.TrafficStats;
import android.net.Uri;
import android.net.captiveportal.CaptivePortalProbeResult;
+import android.net.captiveportal.CaptivePortalProbeSpec;
import android.net.dns.ResolvUtil;
import android.net.metrics.IpConnectivityLog;
import android.net.metrics.NetworkEvent;
@@ -63,6 +68,7 @@
import android.util.Log;
import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.ArrayUtils;
import com.android.internal.util.Protocol;
import com.android.internal.util.State;
import com.android.internal.util.StateMachine;
@@ -259,6 +265,8 @@
private final URL mCaptivePortalHttpsUrl;
private final URL mCaptivePortalHttpUrl;
private final URL[] mCaptivePortalFallbackUrls;
+ @Nullable
+ private final CaptivePortalProbeSpec[] mCaptivePortalFallbackSpecs;
@VisibleForTesting
protected boolean mIsCaptivePortalCheckEnabled;
@@ -334,6 +342,7 @@
mCaptivePortalHttpsUrl = makeURL(getCaptivePortalServerHttpsUrl());
mCaptivePortalHttpUrl = makeURL(getCaptivePortalServerHttpUrl(settings, context));
mCaptivePortalFallbackUrls = makeCaptivePortalFallbackUrls();
+ mCaptivePortalFallbackSpecs = makeCaptivePortalFallbackProbeSpecs();
start();
}
@@ -542,8 +551,12 @@
sendMessage(CMD_CAPTIVE_PORTAL_APP_FINISHED, response);
}
}));
- intent.putExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_URL,
- mLastPortalProbeResult.detectUrl);
+ final CaptivePortalProbeResult probeRes = mLastPortalProbeResult;
+ intent.putExtra(EXTRA_CAPTIVE_PORTAL_URL, probeRes.detectUrl);
+ if (probeRes.probeSpec != null) {
+ final String encodedSpec = probeRes.probeSpec.getEncodedSpec();
+ intent.putExtra(EXTRA_CAPTIVE_PORTAL_PROBE_SPEC, encodedSpec);
+ }
intent.putExtra(ConnectivityManager.EXTRA_CAPTIVE_PORTAL_USER_AGENT,
mCaptivePortalUserAgent);
intent.setFlags(
@@ -882,23 +895,47 @@
}
private URL[] makeCaptivePortalFallbackUrls() {
- String separator = ",";
- String firstUrl = mSettings.getSetting(mContext,
- Settings.Global.CAPTIVE_PORTAL_FALLBACK_URL, DEFAULT_FALLBACK_URL);
- String joinedUrls = firstUrl + separator + mSettings.getSetting(mContext,
- Settings.Global.CAPTIVE_PORTAL_OTHER_FALLBACK_URLS, DEFAULT_OTHER_FALLBACK_URLS);
- List<URL> urls = new ArrayList<>();
- for (String s : joinedUrls.split(separator)) {
- URL u = makeURL(s);
- if (u == null) {
- continue;
+ try {
+ String separator = ",";
+ String firstUrl = mSettings.getSetting(mContext,
+ Settings.Global.CAPTIVE_PORTAL_FALLBACK_URL, DEFAULT_FALLBACK_URL);
+ String joinedUrls = firstUrl + separator + mSettings.getSetting(mContext,
+ Settings.Global.CAPTIVE_PORTAL_OTHER_FALLBACK_URLS,
+ DEFAULT_OTHER_FALLBACK_URLS);
+ List<URL> urls = new ArrayList<>();
+ for (String s : joinedUrls.split(separator)) {
+ URL u = makeURL(s);
+ if (u == null) {
+ continue;
+ }
+ urls.add(u);
}
- urls.add(u);
+ if (urls.isEmpty()) {
+ Log.e(TAG, String.format("could not create any url from %s", joinedUrls));
+ }
+ return urls.toArray(new URL[urls.size()]);
+ } catch (Exception e) {
+ // Don't let a misconfiguration bootloop the system.
+ Log.e(TAG, "Error parsing configured fallback URLs", e);
+ return new URL[0];
}
- if (urls.isEmpty()) {
- Log.e(TAG, String.format("could not create any url from %s", joinedUrls));
+ }
+
+ private CaptivePortalProbeSpec[] makeCaptivePortalFallbackProbeSpecs() {
+ try {
+ final String settingsValue = mSettings.getSetting(
+ mContext, Settings.Global.CAPTIVE_PORTAL_FALLBACK_PROBE_SPECS, null);
+ // Probe specs only used if configured in settings
+ if (TextUtils.isEmpty(settingsValue)) {
+ return null;
+ }
+
+ return CaptivePortalProbeSpec.parseCaptivePortalProbeSpecs(settingsValue);
+ } catch (Exception e) {
+ // Don't let a misconfiguration bootloop the system.
+ Log.e(TAG, "Error parsing configured fallback probe specs", e);
+ return null;
}
- return urls.toArray(new URL[urls.size()]);
}
private String getCaptivePortalUserAgent() {
@@ -915,6 +952,15 @@
return mCaptivePortalFallbackUrls[idx];
}
+ private CaptivePortalProbeSpec nextFallbackSpec() {
+ if (ArrayUtils.isEmpty(mCaptivePortalFallbackSpecs)) {
+ return null;
+ }
+ // Randomly change spec without memory. Also randomize the first attempt.
+ final int idx = Math.abs(new Random().nextInt()) % mCaptivePortalFallbackSpecs.length;
+ return mCaptivePortalFallbackSpecs[idx];
+ }
+
@VisibleForTesting
protected CaptivePortalProbeResult isCaptivePortal() {
if (!mIsCaptivePortalCheckEnabled) {
@@ -985,7 +1031,7 @@
// unnecessary resolution.
final String host = (proxy != null) ? proxy.getHost() : url.getHost();
sendDnsProbe(host);
- return sendHttpProbe(url, probeType);
+ return sendHttpProbe(url, probeType, null);
}
/** Do a DNS resolution of the given server. */
@@ -1021,7 +1067,8 @@
* @return a CaptivePortalProbeResult inferred from the HTTP response.
*/
@VisibleForTesting
- protected CaptivePortalProbeResult sendHttpProbe(URL url, int probeType) {
+ protected CaptivePortalProbeResult sendHttpProbe(URL url, int probeType,
+ @Nullable CaptivePortalProbeSpec probeSpec) {
HttpURLConnection urlConnection = null;
int httpResponseCode = CaptivePortalProbeResult.FAILED_CODE;
String redirectUrl = null;
@@ -1093,7 +1140,12 @@
TrafficStats.setThreadStatsTag(oldTag);
}
logValidationProbe(probeTimer.stop(), probeType, httpResponseCode);
- return new CaptivePortalProbeResult(httpResponseCode, redirectUrl, url.toString());
+
+ if (probeSpec == null) {
+ return new CaptivePortalProbeResult(httpResponseCode, redirectUrl, url.toString());
+ } else {
+ return probeSpec.getResult(httpResponseCode, redirectUrl);
+ }
}
private CaptivePortalProbeResult sendParallelHttpProbes(
@@ -1156,11 +1208,12 @@
if (httpsResult.isPortal() || httpsResult.isSuccessful()) {
return httpsResult;
}
- // If a fallback url exists, use a fallback probe to try again portal detection.
- URL fallbackUrl = nextFallbackUrl();
+ // If a fallback method exists, use it to retry portal detection.
+ // If we have new-style probe specs, use those. Otherwise, use the fallback URLs.
+ final CaptivePortalProbeSpec probeSpec = nextFallbackSpec();
+ final URL fallbackUrl = (probeSpec != null) ? probeSpec.getUrl() : nextFallbackUrl();
if (fallbackUrl != null) {
- CaptivePortalProbeResult result =
- sendHttpProbe(fallbackUrl, ValidationProbeEvent.PROBE_FALLBACK);
+ CaptivePortalProbeResult result = sendHttpProbe(fallbackUrl, PROBE_FALLBACK, probeSpec);
if (result.isPortal()) {
return result;
}
diff --git a/tests/net/java/android/net/captiveportal/CaptivePortalProbeSpecTest.java b/tests/net/java/android/net/captiveportal/CaptivePortalProbeSpecTest.java
new file mode 100644
index 0000000..40a8b3e
--- /dev/null
+++ b/tests/net/java/android/net/captiveportal/CaptivePortalProbeSpecTest.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.captiveportal;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNull;
+import static junit.framework.Assert.assertTrue;
+
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class CaptivePortalProbeSpecTest {
+
+ @Test
+ public void testGetResult_Regex() throws MalformedURLException, ParseException {
+ // 2xx status or 404, with an empty (match everything) location regex
+ CaptivePortalProbeSpec statusRegexSpec = CaptivePortalProbeSpec.parseSpec(
+ "http://www.google.com@@/@@2[0-9]{2}|404@@/@@");
+
+ // 404, or 301/302 redirect to some HTTPS page under google.com
+ CaptivePortalProbeSpec redirectSpec = CaptivePortalProbeSpec.parseSpec(
+ "http://google.com@@/@@404|30[12]@@/@@https://([0-9a-z]+\\.)*google\\.com.*");
+
+ assertSuccess(statusRegexSpec.getResult(200, null));
+ assertSuccess(statusRegexSpec.getResult(299, "qwer"));
+ assertSuccess(statusRegexSpec.getResult(404, null));
+ assertSuccess(statusRegexSpec.getResult(404, ""));
+
+ assertPortal(statusRegexSpec.getResult(300, null));
+ assertPortal(statusRegexSpec.getResult(399, "qwer"));
+ assertPortal(statusRegexSpec.getResult(500, null));
+
+ assertSuccess(redirectSpec.getResult(404, null));
+ assertSuccess(redirectSpec.getResult(404, ""));
+ assertSuccess(redirectSpec.getResult(301, "https://www.google.com"));
+ assertSuccess(redirectSpec.getResult(301, "https://www.google.com/test?q=3"));
+ assertSuccess(redirectSpec.getResult(302, "https://google.com/test?q=3"));
+
+ assertPortal(redirectSpec.getResult(299, "https://google.com/test?q=3"));
+ assertPortal(redirectSpec.getResult(299, ""));
+ assertPortal(redirectSpec.getResult(499, null));
+ assertPortal(redirectSpec.getResult(301, "http://login.portal.example.com/loginpage"));
+ assertPortal(redirectSpec.getResult(302, "http://www.google.com/test?q=3"));
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseSpec_Empty() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseSpec_Null() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec(null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseSpec_MissingParts() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("http://google.com/@@/@@123");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseSpec_TooManyParts() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("http://google.com/@@/@@123@@/@@456@@/@@extra");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseSpec_InvalidStatusRegex() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("http://google.com/@@/@@unmatched(parenthesis@@/@@456");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseSpec_InvalidLocationRegex() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("http://google.com/@@/@@123@@/@@unmatched[[]bracket");
+ }
+
+ @Test(expected = MalformedURLException.class)
+ public void testParseSpec_EmptyURL() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("@@/@@123@@/@@123");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseSpec_NoParts() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("invalid");
+ }
+
+ @Test(expected = MalformedURLException.class)
+ public void testParseSpec_RegexInvalidUrl() throws MalformedURLException, ParseException {
+ CaptivePortalProbeSpec.parseSpec("notaurl@@/@@123@@/@@123");
+ }
+
+ @Test
+ public void testParseSpecOrNull_UsesSpec() {
+ final String specUrl = "http://google.com/probe";
+ final String redirectUrl = "https://google.com/probe";
+ CaptivePortalProbeSpec spec = CaptivePortalProbeSpec.parseSpecOrNull(
+ specUrl + "@@/@@302@@/@@" + redirectUrl);
+ assertEquals(specUrl, spec.getUrl().toString());
+
+ assertPortal(spec.getResult(302, "http://portal.example.com"));
+ assertSuccess(spec.getResult(302, redirectUrl));
+ }
+
+ @Test
+ public void testParseSpecOrNull_UsesFallback() throws MalformedURLException {
+ CaptivePortalProbeSpec spec = CaptivePortalProbeSpec.parseSpecOrNull(null);
+ assertNull(spec);
+
+ spec = CaptivePortalProbeSpec.parseSpecOrNull("");
+ assertNull(spec);
+
+ spec = CaptivePortalProbeSpec.parseSpecOrNull("@@/@@ @@/@@ @@/@@");
+ assertNull(spec);
+
+ spec = CaptivePortalProbeSpec.parseSpecOrNull("invalid@@/@@123@@/@@456");
+ assertNull(spec);
+ }
+
+ @Test
+ public void testParseSpecOrUseStatusCodeFallback_EmptySpec() throws MalformedURLException {
+ CaptivePortalProbeSpec spec = CaptivePortalProbeSpec.parseSpecOrNull("");
+ assertNull(spec);
+ }
+
+ private void assertIsStatusSpec(CaptivePortalProbeSpec spec) {
+ assertSuccess(spec.getResult(204, null));
+ assertSuccess(spec.getResult(204, "1234"));
+
+ assertPortal(spec.getResult(200, null));
+ assertPortal(spec.getResult(301, null));
+ assertPortal(spec.getResult(302, "1234"));
+ assertPortal(spec.getResult(399, ""));
+
+ assertFailed(spec.getResult(404, null));
+ assertFailed(spec.getResult(500, "1234"));
+ }
+
+ private void assertPortal(CaptivePortalProbeResult result) {
+ assertTrue(result.isPortal());
+ }
+
+ private void assertSuccess(CaptivePortalProbeResult result) {
+ assertTrue(result.isSuccessful());
+ }
+
+ private void assertFailed(CaptivePortalProbeResult result) {
+ assertTrue(result.isFailed());
+ }
+}