diff options
-rw-r--r-- | Android.bp | 10 | ||||
-rw-r--r-- | common/CaptivePortalProbeResult.java | 88 | ||||
-rw-r--r-- | common/CaptivePortalProbeSpec.java | 195 | ||||
-rw-r--r-- | tests/src/android/net/captiveportal/CaptivePortalProbeSpecTest.java | 170 |
4 files changed, 463 insertions, 0 deletions
@@ -14,6 +14,15 @@ // limitations under the License. // +java_library { + name: "captiveportal-lib", + srcs: ["common/**/*.java"], + libs: [ + "androidx.annotation_annotation", + ], + sdk_version: "system_current", +} + java_defaults { name: "NetworkStackCommon", sdk_version: "system_current", @@ -35,6 +44,7 @@ android_library { "networkstack-aidl-interfaces-java", "datastallprotosnano", "networkstackprotosnano", + "captiveportal-lib", ], manifest: "AndroidManifestBase.xml", } diff --git a/common/CaptivePortalProbeResult.java b/common/CaptivePortalProbeResult.java new file mode 100644 index 0000000..48cd48b --- /dev/null +++ b/common/CaptivePortalProbeResult.java @@ -0,0 +1,88 @@ +/* + * 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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Result of calling isCaptivePortal(). + * @hide + */ +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; + // Set partial connectivity http response code to -1 to prevent conflict with the other http + // response codes. Besides the default http response code of probe result is set as 599 in + // NetworkMonitor#sendParallelHttpProbes(), so response code will be set as -1 only when + // NetworkMonitor detects partial connectivity. + /** + * @hide + */ + public static final int PARTIAL_CODE = -1; + + @NonNull + public static final CaptivePortalProbeResult FAILED = new CaptivePortalProbeResult(FAILED_CODE); + @NonNull + public static final CaptivePortalProbeResult SUCCESS = + new CaptivePortalProbeResult(SUCCESS_CODE); + public static final CaptivePortalProbeResult PARTIAL = + new CaptivePortalProbeResult(PARTIAL_CODE); + + private final int mHttpResponseCode; // HTTP response code returned from Internet probe. + @Nullable + public final String redirectUrl; // Redirect destination returned from Internet probe. + @Nullable + 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, @Nullable String redirectUrl, + @Nullable String detectUrl) { + this(httpResponseCode, redirectUrl, detectUrl, null); + } + + public CaptivePortalProbeResult(int httpResponseCode, @Nullable String redirectUrl, + @Nullable String detectUrl, @Nullable CaptivePortalProbeSpec probeSpec) { + mHttpResponseCode = httpResponseCode; + this.redirectUrl = redirectUrl; + this.detectUrl = detectUrl; + this.probeSpec = probeSpec; + } + + public boolean isSuccessful() { + return mHttpResponseCode == SUCCESS_CODE; + } + + public boolean isPortal() { + return !isSuccessful() && (mHttpResponseCode >= 200) && (mHttpResponseCode <= 399); + } + + public boolean isFailed() { + return !isSuccessful() && !isPortal(); + } + + public boolean isPartialConnectivity() { + return mHttpResponseCode == PARTIAL_CODE; + } +} diff --git a/common/CaptivePortalProbeSpec.java b/common/CaptivePortalProbeSpec.java new file mode 100644 index 0000000..bf983a5 --- /dev/null +++ b/common/CaptivePortalProbeSpec.java @@ -0,0 +1,195 @@ +/* + * 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.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import java.net.MalformedURLException; +import java.net.URL; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** @hide */ +public abstract class CaptivePortalProbeSpec { + 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(@NonNull String encodedSpec, @NonNull URL url) { + mEncodedSpec = checkNotNull(encodedSpec); + mUrl = checkNotNull(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)}. + * @hide + */ + @VisibleForTesting + @NonNull + public static CaptivePortalProbeSpec parseSpec(@NonNull 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(@Nullable 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. + */ + @NonNull + public static Collection<CaptivePortalProbeSpec> parseCaptivePortalProbeSpecs( + @NonNull 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; + } + + /** + * Get the probe result from HTTP status and location header. + */ + @NonNull + public abstract CaptivePortalProbeResult getResult(int status, @Nullable String locationHeader); + + @NonNull + public String getEncodedSpec() { + return mEncodedSpec; + } + + @NonNull + 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(); + } + + // Throws NullPointerException if the input is null. + private static <T> T checkNotNull(T object) { + if (object == null) throw new NullPointerException(); + return object; + } +} diff --git a/tests/src/android/net/captiveportal/CaptivePortalProbeSpecTest.java b/tests/src/android/net/captiveportal/CaptivePortalProbeSpecTest.java new file mode 100644 index 0000000..f948086 --- /dev/null +++ b/tests/src/android/net/captiveportal/CaptivePortalProbeSpecTest.java @@ -0,0 +1,170 @@ +/* + * 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 androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.net.MalformedURLException; +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()); + } +} |