diff --git a/res/values/strings.xml b/res/values/strings.xml index adf414f255b..f66c8b39179 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1755,6 +1755,12 @@ 5 GHz Sign in + + Open site + + %1$s left + + Expires on %1$s Tap here to sign in to network diff --git a/src/com/android/settings/wifi/details/WifiDetailPreferenceController.java b/src/com/android/settings/wifi/details/WifiDetailPreferenceController.java index e9d2e08bb1a..a7c409de7f4 100644 --- a/src/com/android/settings/wifi/details/WifiDetailPreferenceController.java +++ b/src/com/android/settings/wifi/details/WifiDetailPreferenceController.java @@ -31,6 +31,7 @@ import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.VectorDrawable; +import android.net.CaptivePortalData; import android.net.ConnectivityManager; import android.net.ConnectivityManager.NetworkCallback; import android.net.LinkAddress; @@ -41,6 +42,7 @@ import android.net.NetworkInfo; import android.net.NetworkRequest; import android.net.NetworkUtils; import android.net.RouteInfo; +import android.net.Uri; import android.net.wifi.WifiConfiguration; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; @@ -75,6 +77,7 @@ import com.android.settingslib.core.lifecycle.Lifecycle; import com.android.settingslib.core.lifecycle.LifecycleObserver; import com.android.settingslib.core.lifecycle.events.OnPause; import com.android.settingslib.core.lifecycle.events.OnResume; +import com.android.settingslib.utils.StringUtil; import com.android.settingslib.widget.ActionButtonsPreference; import com.android.settingslib.widget.LayoutPreference; import com.android.settingslib.wifi.AccessPoint; @@ -86,6 +89,10 @@ import java.net.Inet6Address; import java.net.InetAddress; import java.net.UnknownHostException; import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.util.StringJoiner; import java.util.stream.Collectors; @@ -189,6 +196,7 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController WifiDataUsageSummaryPreferenceController mSummaryHeaderController; private final IconInjector mIconInjector; + private final Clock mClock; private final IntentFilter mFilter; // Passpoint information - cache it in case of losing these information after @@ -229,6 +237,8 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController public void onLinkPropertiesChanged(Network network, LinkProperties lp) { if (network.equals(mNetwork) && !lp.equals(mLinkProperties)) { mLinkProperties = lp; + refreshEntityHeader(); + refreshButtons(); refreshIpLayerInfo(); } } @@ -322,7 +332,7 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController MetricsFeatureProvider metricsFeatureProvider) { return new WifiDetailPreferenceController( accessPoint, connectivityManager, context, fragment, handler, lifecycle, - wifiManager, metricsFeatureProvider, new IconInjector(context)); + wifiManager, metricsFeatureProvider, new IconInjector(context), new Clock()); } @VisibleForTesting @@ -335,7 +345,8 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController Lifecycle lifecycle, WifiManager wifiManager, MetricsFeatureProvider metricsFeatureProvider, - IconInjector injector) { + IconInjector injector, + Clock clock) { super(context); mAccessPoint = accessPoint; @@ -347,6 +358,7 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController mWifiManager = wifiManager; mMetricsFeatureProvider = metricsFeatureProvider; mIconInjector = injector; + mClock = clock; mFilter = new IntentFilter(); mFilter.addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION); @@ -404,9 +416,6 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController .setButton1Text(R.string.forget) .setButton1Icon(R.drawable.ic_settings_delete) .setButton1OnClickListener(view -> forgetNetwork()) - .setButton2Text(R.string.wifi_sign_in_button_text) - .setButton2Icon(R.drawable.ic_settings_sign_in) - .setButton2OnClickListener(view -> signIntoNetwork()) .setButton3Text(R.string.wifi_connect) .setButton3Icon(R.drawable.ic_settings_wireless) .setButton3OnClickListener(view -> connectNetwork()) @@ -414,6 +423,7 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController .setButton4Text(R.string.share) .setButton4Icon(R.drawable.ic_qrcode_24dp) .setButton4OnClickListener(view -> shareNetwork()); + updateCaptivePortalButton(); if (isPasspointConfigurationR1Expired()) { // Hide Connect button. @@ -439,6 +449,42 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController mSecurityPref.setSummary(mAccessPoint.getSecurityString(/* concise */ false)); } + /** + * Update text, icon and listener of the captive portal button. + * @return True if the button should be shown. + */ + private boolean updateCaptivePortalButton() { + final Uri venueInfoUrl = getCaptivePortalVenueInfoUrl(); + if (venueInfoUrl == null) { + mButtonsPref.setButton2Text(R.string.wifi_sign_in_button_text) + .setButton2Icon(R.drawable.ic_settings_sign_in) + .setButton2OnClickListener(view -> signIntoNetwork()); + return canSignIntoNetwork(); + } + + mButtonsPref.setButton2Text(R.string.wifi_venue_website_button_text) + .setButton2Icon(R.drawable.ic_settings_sign_in) + .setButton2OnClickListener(view -> { + final Intent infoIntent = new Intent(Intent.ACTION_VIEW); + infoIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + infoIntent.setData(venueInfoUrl); + mContext.startActivity(infoIntent); + }); + return mAccessPoint.isActive(); + } + + private Uri getCaptivePortalVenueInfoUrl() { + final LinkProperties lp = mLinkProperties; + if (lp == null) { + return null; + } + final CaptivePortalData data = lp.getCaptivePortalData(); + if (data == null) { + return null; + } + return data.getVenueInfoUrl(); + } + private void setupEntityHeader(PreferenceScreen screen) { LayoutPreference headerPref = screen.findPreference(KEY_HEADER); @@ -464,6 +510,37 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController mEntityHeaderController.setLabel(mAccessPoint.getTitle()); } + private String getExpiryTimeSummary() { + if (mLinkProperties == null || mLinkProperties.getCaptivePortalData() == null) { + return null; + } + + final long expiryTimeMillis = mLinkProperties.getCaptivePortalData().getExpiryTimeMillis(); + if (expiryTimeMillis <= 0) { + return null; + } + final ZonedDateTime now = mClock.now(); + final ZonedDateTime expiryTime = ZonedDateTime.ofInstant( + Instant.ofEpochMilli(expiryTimeMillis), + now.getZone()); + + if (now.isAfter(expiryTime)) { + return null; + } + + if (now.plusDays(2).isAfter(expiryTime)) { + // Expiration within 2 days: show a duration + return mContext.getString(R.string.wifi_time_remaining, StringUtil.formatElapsedTime( + mContext, + Duration.between(now, expiryTime).getSeconds() * 1000, + false /* withSeconds */)); + } + + // For more than 2 days, show the expiry date + return mContext.getString(R.string.wifi_expiry_time, + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).format(expiryTime)); + } + private void refreshEntityHeader() { if (usingDataUsageHeader(mContext)) { mSummaryHeaderController.updateState(mDataUsageSummaryPref); @@ -480,6 +557,7 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController mEntityHeaderController .setSummary(summary) + .setSecondSummary(getExpiryTimeSummary()) .setRecyclerView(mFragment.getListView(), mLifecycle) .done(mFragment.getActivity(), true /* rebind */); } @@ -766,16 +844,16 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController mIsEphemeral ? R.string.wifi_disconnect_button_text : R.string.forget); boolean canForgetNetwork = canForgetNetwork(); - boolean canSignIntoNetwork = canSignIntoNetwork(); + boolean showCaptivePortalButton = updateCaptivePortalButton(); boolean canConnectNetwork = canConnectNetwork() && !isPasspointConfigurationR1Expired(); boolean canShareNetwork = canShareNetwork(); mButtonsPref.setButton1Visible(canForgetNetwork); - mButtonsPref.setButton2Visible(canSignIntoNetwork); + mButtonsPref.setButton2Visible(showCaptivePortalButton); mButtonsPref.setButton3Visible(canConnectNetwork); mButtonsPref.setButton4Visible(canShareNetwork); mButtonsPref.setVisible(canForgetNetwork - || canSignIntoNetwork + || showCaptivePortalButton || canConnectNetwork || canShareNetwork); } @@ -996,6 +1074,13 @@ public class WifiDetailPreferenceController extends AbstractPreferenceController } } + @VisibleForTesting + static class Clock { + public ZonedDateTime now() { + return ZonedDateTime.now(); + } + } + private boolean usingDataUsageHeader(Context context) { return FeatureFlagUtils.isEnabled(context, FeatureFlags.WIFI_DETAILS_DATAUSAGE_HEADER); } diff --git a/tests/robotests/src/com/android/settings/wifi/details/WifiDetailPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/wifi/details/WifiDetailPreferenceControllerTest.java index b9908467260..11d5ece8037 100644 --- a/tests/robotests/src/com/android/settings/wifi/details/WifiDetailPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/wifi/details/WifiDetailPreferenceControllerTest.java @@ -20,7 +20,6 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doNothing; @@ -41,6 +40,7 @@ import android.content.res.Resources; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.net.CaptivePortalData; import android.net.ConnectivityManager; import android.net.ConnectivityManager.NetworkCallback; import android.net.IpPrefix; @@ -52,6 +52,7 @@ import android.net.NetworkCapabilities; import android.net.NetworkInfo; import android.net.NetworkRequest; import android.net.RouteInfo; +import android.net.Uri; import android.net.wifi.WifiConfiguration; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; @@ -75,6 +76,7 @@ import com.android.settings.testutils.shadow.ShadowEntityHeaderController; import com.android.settings.widget.EntityHeaderController; import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.utils.StringUtil; import com.android.settingslib.widget.ActionButtonsPreference; import com.android.settingslib.widget.LayoutPreference; import com.android.settingslib.wifi.AccessPoint; @@ -99,6 +101,10 @@ import org.robolectric.shadows.ShadowToast; import java.net.Inet4Address; import java.net.InetAddress; import java.net.UnknownHostException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.Arrays; import java.util.stream.Collectors; @@ -146,6 +152,8 @@ public class WifiDetailPreferenceControllerTest { @Mock private WifiDetailPreferenceController.IconInjector mockIconInjector; @Mock + private WifiDetailPreferenceController.Clock mMockClock; + @Mock private MacAddress mockMacAddress; @Mock(answer = Answers.RETURNS_DEEP_STUBS) @@ -286,7 +294,10 @@ public class WifiDetailPreferenceControllerTest { // builder pattern when(mockHeaderController.setRecyclerView(mockFragment.getListView(), mLifecycle)) .thenReturn(mockHeaderController); - when(mockHeaderController.setSummary(anyString())).thenReturn(mockHeaderController); + when(mockHeaderController.setSummary(nullable(String.class))) + .thenReturn(mockHeaderController); + when(mockHeaderController.setSecondSummary(nullable(String.class))) + .thenReturn(mockHeaderController); when(mockIconInjector.getIcon(anyInt())).thenReturn(new ColorDrawable()); setupMockedPreferenceScreen(); @@ -338,7 +349,8 @@ public class WifiDetailPreferenceControllerTest { mLifecycle, mockWifiManager, mockMetricsFeatureProvider, - mockIconInjector); + mockIconInjector, + mMockClock); } private void setupMockedPreferenceScreen() { @@ -525,6 +537,54 @@ public class WifiDetailPreferenceControllerTest { verify(mockHeaderController).setSummary(summary); } + private void doShouldShowRemainingTimeTest(ZonedDateTime now, long timeRemainingMs) { + when(mMockClock.now()).thenReturn(now); + setUpForConnectedNetwork(); + displayAndResume(); + + final CaptivePortalData data = new CaptivePortalData.Builder() + .setExpiryTime(now.toInstant().getEpochSecond() * 1000 + timeRemainingMs) + .build(); + final LinkProperties lp = new LinkProperties(); + lp.setCaptivePortalData(data); + + updateLinkProperties(lp); + } + + @Test + public void entityHeader_shouldShowShortRemainingTime() { + // Expires in 1h, 2min, 15sec + final long timeRemainingMs = (3600 + 2 * 60 + 15) * 1000; + final ZonedDateTime fakeNow = ZonedDateTime.of(2020, 1, 2, 3, 4, 5, 6, + ZoneId.of("Europe/London")); + doShouldShowRemainingTimeTest(fakeNow, timeRemainingMs); + final String expectedSummary = mContext.getString(R.string.wifi_time_remaining, + StringUtil.formatElapsedTime(mContext, timeRemainingMs, false /* withSeconds */)); + final InOrder inOrder = inOrder(mockHeaderController); + inOrder.verify(mockHeaderController).setSecondSummary(expectedSummary); + + updateLinkProperties(new LinkProperties()); + inOrder.verify(mockHeaderController).setSecondSummary((String) null); + } + + @Test + public void entityHeader_shouldShowExpiryDate() { + // Expires in 49h, 2min, 15sec + final long timeRemainingMs = (49 * 3600 + 2 * 60 + 15) * 1000; + final ZonedDateTime fakeNow = ZonedDateTime.of(2020, 1, 2, 3, 4, 5, 6, + ZoneId.of("Europe/London")); + doShouldShowRemainingTimeTest(fakeNow, timeRemainingMs); + final String expectedSummary = mContext.getString( + R.string.wifi_expiry_time, + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).format( + fakeNow.plusNanos(timeRemainingMs * 1_000_000))); + final InOrder inOrder = inOrder(mockHeaderController); + inOrder.verify(mockHeaderController).setSecondSummary(expectedSummary); + + updateLinkProperties(new LinkProperties()); + inOrder.verify(mockHeaderController).setSecondSummary((String) null); + } + @Test public void entityHeader_shouldConvertSavedAsDisconnected() { setUpForDisconnectedNetwork(); @@ -1272,6 +1332,8 @@ public class WifiDetailPreferenceControllerTest { nc.addCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL); updateNetworkCapabilities(nc); + + inOrder.verify(mockButtonsPref).setButton2Text(R.string.wifi_sign_in_button_text); inOrder.verify(mockButtonsPref).setButton2Visible(true); nc.removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL); @@ -1279,6 +1341,31 @@ public class WifiDetailPreferenceControllerTest { inOrder.verify(mockButtonsPref).setButton2Visible(false); } + @Test + public void captivePortal_shouldShowVenueInfoButton() { + setUpForConnectedNetwork(); + + InOrder inOrder = inOrder(mockButtonsPref); + + displayAndResume(); + + inOrder.verify(mockButtonsPref).setButton2Visible(false); + + LinkProperties lp = new LinkProperties(); + final CaptivePortalData data = new CaptivePortalData.Builder() + .setVenueInfoUrl(Uri.parse("https://example.com/info")) + .build(); + lp.setCaptivePortalData(data); + updateLinkProperties(lp); + + inOrder.verify(mockButtonsPref).setButton2Text(R.string.wifi_venue_website_button_text); + inOrder.verify(mockButtonsPref).setButton2Visible(true); + + lp.setCaptivePortalData(null); + updateLinkProperties(lp); + inOrder.verify(mockButtonsPref).setButton2Visible(false); + } + @Test public void testSignInButton_shouldStartCaptivePortalApp() { setUpForConnectedNetwork(); @@ -1286,7 +1373,8 @@ public class WifiDetailPreferenceControllerTest { displayAndResume(); ArgumentCaptor captor = ArgumentCaptor.forClass(OnClickListener.class); - verify(mockButtonsPref).setButton2OnClickListener(captor.capture()); + verify(mockButtonsPref, atLeastOnce()).setButton2OnClickListener(captor.capture()); + // getValue() returns the last captured value captor.getValue().onClick(null); verify(mockConnectivityManager).startCaptivePortalApp(mockNetwork); verify(mockMetricsFeatureProvider)