Make CaptivePortalProbeSpec and CaptivePortalProbeResult as a library

These two classes were added to @SystemApi because they are used
both by NetworkMonitor and CaptivePortalLogin. However it turns
out they are not needed in the framework, so having them as a
library sounds better.

Change-Id: Iadf77ec5952b6da8812dc6d006a39bd4e93d2bd9
Fix: 129433264
Test: atest NetworkStackTests FrameworksNetTests
diff --git a/packages/NetworkStack/Android.bp b/packages/NetworkStack/Android.bp
index d56f97f..57a3db5 100644
--- a/packages/NetworkStack/Android.bp
+++ b/packages/NetworkStack/Android.bp
@@ -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 @@
         "networkstack-aidl-interfaces-java",
         "datastallprotosnano",
         "networkstackprotosnano",
+        "captiveportal-lib",
     ],
     manifest: "AndroidManifestBase.xml",
 }
diff --git a/packages/NetworkStack/common/CaptivePortalProbeResult.java b/packages/NetworkStack/common/CaptivePortalProbeResult.java
new file mode 100644
index 0000000..48cd48b
--- /dev/null
+++ b/packages/NetworkStack/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/packages/NetworkStack/common/CaptivePortalProbeSpec.java b/packages/NetworkStack/common/CaptivePortalProbeSpec.java
new file mode 100644
index 0000000..bf983a5
--- /dev/null
+++ b/packages/NetworkStack/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/packages/NetworkStack/tests/src/android/net/captiveportal/CaptivePortalProbeSpecTest.java b/packages/NetworkStack/tests/src/android/net/captiveportal/CaptivePortalProbeSpecTest.java
new file mode 100644
index 0000000..f948086
--- /dev/null
+++ b/packages/NetworkStack/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());
+    }
+}