[Passpoint] Support for Terms & Conditions

Added framework support for Terms & Conditions. Handle the
WNM-notification and extract the URL. Reject invalid and non-HTTPS
URLs (block these networks since there will be no access without
accepting the T&Cs).

Bug: 171928337
Test: atest ClientModeImplTest PasspointManagerTest
Change-Id: Iaf161d258f7768337f919aeaf27174742c541a1f
diff --git a/service/java/com/android/server/wifi/ClientModeImpl.java b/service/java/com/android/server/wifi/ClientModeImpl.java
index 894239e..1b2159e 100644
--- a/service/java/com/android/server/wifi/ClientModeImpl.java
+++ b/service/java/com/android/server/wifi/ClientModeImpl.java
@@ -837,7 +837,7 @@
 
         @Override
         public void onProvisioningSuccess(LinkProperties newLp) {
-            addPasspointVenueUrlToLinkProperties(newLp);
+            addPasspointUrlsToLinkProperties(newLp);
             mWifiMetrics.logStaEvent(mInterfaceName, StaEvent.TYPE_CMD_IP_CONFIGURATION_SUCCESSFUL);
             sendMessage(CMD_UPDATE_LINKPROPERTIES, newLp);
             sendMessage(CMD_IP_CONFIGURATION_SUCCESSFUL);
@@ -851,7 +851,7 @@
 
         @Override
         public void onLinkPropertiesChange(LinkProperties newLp) {
-            addPasspointVenueUrlToLinkProperties(newLp);
+            addPasspointUrlsToLinkProperties(newLp);
             sendMessage(CMD_UPDATE_LINKPROPERTIES, newLp);
         }
 
@@ -3500,11 +3500,16 @@
                     mPasspointManager.handleDeauthImminentEvent((WnmData) message.obj,
                             getConnectedWifiConfigurationInternal());
                     break;
+                case WifiMonitor.HS20_TERMS_AND_CONDITIONS_ACCEPTANCE_REQUIRED_EVENT:
+                    if (!mPasspointManager.handleTermsAndConditionsEvent((WnmData) message.obj,
+                            getConnectedWifiConfigurationInternal())) {
+                        loge("Disconnecting from Passpoint network due to an issue with T&C");
+                        sendMessage(CMD_DISCONNECT);
+                    }
+                    break;
                 case WifiMonitor.HS20_REMEDIATION_EVENT:
-                case WifiMonitor.HS20_TERMS_AND_CONDITIONS_ACCEPTANCE_REQUIRED_EVENT: {
                     mPasspointManager.receivedWnmFrame((WnmData) message.obj);
                     break;
-                }
                 case WifiMonitor.MBO_OCE_BSS_TM_HANDLING_DONE: {
                     handleBssTransitionRequest((BtmFrameData) message.obj);
                     break;
@@ -4066,6 +4071,7 @@
                     }
                     // When connecting to Passpoint, ask for the Venue URL
                     if (config.isPasspoint()) {
+                        mPasspointManager.clearTermsAndConditionsUrl();
                         if (scanResult == null && mLastBssid != null) {
                             // The cached scan result of connected network would be null at the
                             // first connection, try to check full scan result list again to look up
@@ -4119,6 +4125,7 @@
                     if (!newConnectionInProgress) {
                         transitionTo(mDisconnectedState);
                     }
+                    mPasspointManager.clearTermsAndConditionsUrl();
                     break;
                 }
                 case WifiMonitor.TARGET_BSSID_EVENT: {
@@ -5985,7 +5992,7 @@
         return mWifiNative.getRxPktFates(mInterfaceName);
     }
 
-    private void addPasspointVenueUrlToLinkProperties(LinkProperties linkProperties) {
+    private void addPasspointUrlsToLinkProperties(LinkProperties linkProperties) {
         WifiConfiguration currentNetwork = getConnectedWifiConfigurationInternal();
         if (currentNetwork == null || !currentNetwork.isPasspoint()) {
             return;
@@ -5996,14 +6003,29 @@
             return;
         }
         URL venueUrl = mPasspointManager.getVenueUrl(scanResult);
-        if (venueUrl != null) {
-            // Update the Venue URL and the friendly name to populate the notification
-            CaptivePortalData captivePortalData = new CaptivePortalData.Builder()
-                    .setVenueInfoUrl(Uri.parse(venueUrl.toString()))
-                    // TODO: Add when new API is available
-                    // .setVenueFriendlyName(currentNetwork.providerFriendlyName)
-                    .build();
-            linkProperties.setCaptivePortalData(captivePortalData);
+        URL termsAndConditionsUrl = mPasspointManager.getTermsAndConditionsUrl();
+
+        if (venueUrl == null && termsAndConditionsUrl == null) {
+            return;
         }
+
+        // Update the friendly name to populate the notification
+        CaptivePortalData.Builder captivePortalDataBuilder = new CaptivePortalData.Builder();
+        // TODO: Add when new API is available
+        //    .setVenueFriendlyName(currentNetwork.providerFriendlyName);
+
+        // Update the Venue URL if available
+        if (venueUrl != null) {
+            captivePortalDataBuilder.setVenueInfoUrl(Uri.parse(venueUrl.toString()));
+        }
+
+        // Update the T&C URL if available. The network is captive if T&C URL is available
+        if (termsAndConditionsUrl != null) {
+            // TODO: Add when NetworkStack changes are implemented
+            //captivePortalDataBuilder.setUserPortalUrl(Uri.parse(termsAndConditionsUrl.toString()))
+            //        .setCaptive(true);
+        }
+
+        linkProperties.setCaptivePortalData(captivePortalDataBuilder.build());
     }
 }
diff --git a/service/java/com/android/server/wifi/hotspot2/PasspointManager.java b/service/java/com/android/server/wifi/hotspot2/PasspointManager.java
index a65ba32..d60390a 100644
--- a/service/java/com/android/server/wifi/hotspot2/PasspointManager.java
+++ b/service/java/com/android/server/wifi/hotspot2/PasspointManager.java
@@ -132,6 +132,7 @@
     private final MacAddressUtil mMacAddressUtil;
     private final Clock mClock;
     private final WifiPermissionsUtil mWifiPermissionsUtil;
+    private URL mTermsAndConditionsUrl = null;
 
     /**
      * Map of package name of an app to the app ops changed listener for the app.
@@ -1498,9 +1499,21 @@
             return;
         }
 
-        PasspointProvider provider = mProviders.get(config.getProfileKey());
+        blockProvider(config.getProfileKey(), event.getBssid(), event.isEss(), event.getDelay());
+    }
+
+    /**
+     * Block a specific provider from network selection
+     *
+     * @param passpointUniqueId The unique ID of the Passpoint network
+     * @param bssid BSSID of the AP
+     * @param isEss Block the ESS or the BSS
+     * @param delay Delay in seconds
+     */
+    private void blockProvider(String passpointUniqueId, long bssid, boolean isEss, int delay) {
+        PasspointProvider provider = mProviders.get(passpointUniqueId);
         if (provider != null) {
-            provider.blockBssOrEss(event.getBssid(), event.isEss(), event.getDelay());
+            provider.blockBssOrEss(bssid, isEss, delay);
         }
     }
 
@@ -1525,4 +1538,59 @@
         mProviders.values().stream().forEach(p -> p.setAnonymousIdentity(null));
         mWifiConfigManager.saveToStore(true);
     }
+
+    /**
+     * Clears the Terms & Conditions URL, to be used upon a successful connection to Passpoint
+     */
+    public void clearTermsAndConditionsUrl() {
+        mTermsAndConditionsUrl = null;
+    }
+
+    /**
+     * Handle Terms & Conditions acceptance required WNM-Notification event
+     *
+     * @param event Terms & Conditions WNM-Notification data
+     * @param config Configuration of the currently connected Passpoint network
+     *
+     * @return true if Terms & conditions URL is valid, false otherwise
+     */
+    public boolean handleTermsAndConditionsEvent(WnmData event, WifiConfiguration config) {
+        if (event == null || config == null || !config.isPasspoint()) {
+            return false;
+        }
+        final int oneHourInSeconds = 60 * 60;
+        final int twentyFourHoursInSeconds = 24 * 60 * 60;
+        URL termsAndConditionsUrl;
+        try {
+            termsAndConditionsUrl = new URL(event.getUrl());
+        } catch (java.net.MalformedURLException e) {
+            Log.e(TAG, "Malformed T&C URL: " + event.getUrl() + " from BSSID: "
+                    + Utils.macToString(event.getBssid()));
+
+            // Block this provider for an hour, this unlikely issue may be resolved shortly
+            blockProvider(config.getProfileKey(), event.getBssid(), true, oneHourInSeconds);
+            return false;
+        }
+        // Reject URLs that are not HTTPS
+        if (!TextUtils.equals(termsAndConditionsUrl.getProtocol(), "https")) {
+            Log.e(TAG, "Non-HTTPS T&C URL rejected: " + termsAndConditionsUrl
+                    + " from BSSID: " + Utils.macToString(event.getBssid()));
+
+            // Block this provider for 24 hours, it is unlikely to be changed
+            blockProvider(config.getProfileKey(), event.getBssid(), true, twentyFourHoursInSeconds);
+            return false;
+        }
+        mTermsAndConditionsUrl = termsAndConditionsUrl;
+        return true;
+    }
+
+    /**
+     * Get the Terms & Conditions URL, if acceptance is required in this network
+     *
+     * @return URL to T&C website, null if not required by this network
+     */
+    @Nullable
+    public URL getTermsAndConditionsUrl() {
+        return mTermsAndConditionsUrl;
+    }
 }
diff --git a/tests/wifitests/src/com/android/server/wifi/ClientModeImplTest.java b/tests/wifitests/src/com/android/server/wifi/ClientModeImplTest.java
index faff301..2d8bc98 100644
--- a/tests/wifitests/src/com/android/server/wifi/ClientModeImplTest.java
+++ b/tests/wifitests/src/com/android/server/wifi/ClientModeImplTest.java
@@ -207,6 +207,8 @@
     private static final int TEST_DELAY_IN_SECONDS = 300;
 
     private static final int DEFINED_ERROR_CODE = 32764;
+    private static final String TEST_TERMS_AND_CONDITIONS_URL =
+            "https://policies.google.com/terms?hl=en-US";
 
     private long mBinderToken;
     private MockitoSession mSession;
@@ -1834,6 +1836,7 @@
         verify(mWifiConnectivityManager).handleConnectionAttemptEnded(
                 any(), anyInt(), any(), any());
         assertEquals("DisconnectedState", getCurrentState().getName());
+        verify(mPasspointManager).clearTermsAndConditionsUrl();
     }
 
     /**
@@ -5607,12 +5610,12 @@
     }
 
     /**
-     * When connected to a Passpoint network, verify that the Venue URL is updated in the
-     * {@link LinkProperties} object when provisioning complete and when link properties change
+     * When connected to a Passpoint network, verify that the Venue URL and T&C URL are updated in
+     * the {@link LinkProperties} object when provisioning complete and when link properties change
      * events are received.
      */
     @Test
-    public void testVenueUrlUpdateForPasspointNetworks() throws Exception {
+    public void testVenueAndTCUrlsUpdateForPasspointNetworks() throws Exception {
         setupPasspointConnection();
         DhcpResultsParcelable dhcpResults = new DhcpResultsParcelable();
         dhcpResults.baseConfiguration = new StaticIpConfiguration();
@@ -5626,6 +5629,46 @@
         mLooper.dispatchAll();
         mIpClientCallback.onLinkPropertiesChange(new LinkProperties());
         mLooper.dispatchAll();
+        verify(mPasspointManager).clearTermsAndConditionsUrl();
         verify(mPasspointManager, times(2)).getVenueUrl(any(ScanResult.class));
+        verify(mPasspointManager, times(2)).getTermsAndConditionsUrl();
+    }
+
+    /**
+     * Verify that the T&C WNM-Notification is handled by relaying to the Passpoint
+     * Manager.
+     */
+    @Test
+    public void testHandlePasspointTermsAndConditionsWnmNotification() throws Exception {
+        setupEapSimConnection();
+        WnmData wnmData = WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                TEST_TERMS_AND_CONDITIONS_URL);
+        when(mPasspointManager.handleTermsAndConditionsEvent(eq(wnmData),
+                any(WifiConfiguration.class))).thenReturn(true);
+        mCmi.sendMessage(WifiMonitor.HS20_TERMS_AND_CONDITIONS_ACCEPTANCE_REQUIRED_EVENT,
+                0, 0, wnmData);
+        mLooper.dispatchAll();
+        verify(mPasspointManager).handleTermsAndConditionsEvent(eq(wnmData),
+                any(WifiConfiguration.class));
+        verify(mWifiNative, never()).disconnect(anyString());
+    }
+
+    /**
+     * Verify that when a bad URL is received in the T&C WNM-Notification, the connection is
+     * disconnected.
+     */
+    @Test
+    public void testHandlePasspointTermsAndConditionsWnmNotificationWithBadUrl() throws Exception {
+        setupEapSimConnection();
+        WnmData wnmData = WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                TEST_TERMS_AND_CONDITIONS_URL);
+        when(mPasspointManager.handleTermsAndConditionsEvent(eq(wnmData),
+                any(WifiConfiguration.class))).thenReturn(false);
+        mCmi.sendMessage(WifiMonitor.HS20_TERMS_AND_CONDITIONS_ACCEPTANCE_REQUIRED_EVENT,
+                0, 0, wnmData);
+        mLooper.dispatchAll();
+        verify(mPasspointManager).handleTermsAndConditionsEvent(eq(wnmData),
+                any(WifiConfiguration.class));
+        verify(mWifiNative).disconnect(eq(WIFI_IFACE_NAME));
     }
 }
diff --git a/tests/wifitests/src/com/android/server/wifi/hotspot2/PasspointManagerTest.java b/tests/wifitests/src/com/android/server/wifi/hotspot2/PasspointManagerTest.java
index cd96164..9d7c887 100644
--- a/tests/wifitests/src/com/android/server/wifi/hotspot2/PasspointManagerTest.java
+++ b/tests/wifitests/src/com/android/server/wifi/hotspot2/PasspointManagerTest.java
@@ -153,6 +153,12 @@
     private static final String TEST_LOCALE_ENGLISH = "eng";
     private static final String TEST_LOCALE_HEBREW = "heb";
     private static final String TEST_LOCALE_SPANISH = "spa";
+    private static final String TEST_TERMS_AND_CONDITIONS_URL =
+            "https://policies.google.com/terms?hl=en-US";
+    private static final String TEST_TERMS_AND_CONDITIONS_URL_NON_HTTPS =
+            "http://policies.google.com/terms?hl=en-US";
+    private static final String TEST_TERMS_AND_CONDITIONS_URL_INVALID =
+            "httpps://policies.google.com/terms?hl=en-US";
 
     private static final long TEST_BSSID = 0x112233445566L;
     private static final String TEST_SSID = "TestSSID";
@@ -2938,5 +2944,65 @@
             session.finishMocking();
         }
     }
+
+    /**
+     * Verify that Passpoint manager handles the terms and conditions URL correctly: Accepts only
+     * HTTPS URLs, and rejects HTTP and invalid URLs.
+     *
+     * @throws Exception
+     */
+    @Test
+    public void testHandleTermsAndConditionsEvent() throws Exception {
+        WifiConfiguration config = WifiConfigurationTestUtil.createPasspointNetwork();
+        PasspointProvider passpointProvider = addTestProvider(TEST_FQDN, TEST_FRIENDLY_NAME,
+                TEST_PACKAGE, config, false, null);
+        assertTrue(mManager.handleTermsAndConditionsEvent(
+                WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                        TEST_TERMS_AND_CONDITIONS_URL), config));
+
+        // Verify that this provider is never blocked
+        verify(passpointProvider, never()).blockBssOrEss(anyLong(), anyBoolean(), anyInt());
+
+        assertFalse(mManager.handleTermsAndConditionsEvent(
+                WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                        TEST_TERMS_AND_CONDITIONS_URL_NON_HTTPS), config));
+
+        // Verify that the ESS is blocked for 24 hours, the URL is non-HTTPS and unlikely to change
+        verify(passpointProvider).blockBssOrEss(eq(TEST_BSSID), eq(true), eq(24 * 60 * 60));
+
+        assertFalse(mManager.handleTermsAndConditionsEvent(
+                WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                        TEST_TERMS_AND_CONDITIONS_URL_INVALID), config));
+
+        // Verify that the ESS is blocked for an hour due to a temporary issue with the URL
+        verify(passpointProvider).blockBssOrEss(eq(TEST_BSSID), eq(true), eq(60 * 60));
+
+        // Now try with a non-Passpoint network
+        config = WifiConfigurationTestUtil.createEapNetwork();
+        assertFalse(mManager.handleTermsAndConditionsEvent(
+                WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                        TEST_TERMS_AND_CONDITIONS_URL), config));
+        // and a null configuration
+        assertFalse(mManager.handleTermsAndConditionsEvent(
+                WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                        TEST_TERMS_AND_CONDITIONS_URL), null));
+    }
+
+    /**
+     * Verify that Passpoint manager get and clear terms and conditions URL work as expected.
+     *
+     * @throws Exception
+     */
+    @Test
+    public void testGetAndClearTermsAndConditions() throws Exception {
+        WifiConfiguration config = WifiConfigurationTestUtil.createPasspointNetwork();
+        assertTrue(mManager.handleTermsAndConditionsEvent(
+                WnmData.createTermsAndConditionsAccetanceRequiredEvent(TEST_BSSID,
+                        TEST_TERMS_AND_CONDITIONS_URL), config));
+        assertEquals(TEST_TERMS_AND_CONDITIONS_URL, mManager.getTermsAndConditionsUrl().toString());
+
+        mManager.clearTermsAndConditionsUrl();
+        assertTrue(mManager.getTermsAndConditionsUrl() == null);
+    }
 }