diff --git a/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java b/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java index f1cb2f3edac..803b828fcb2 100644 --- a/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java +++ b/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2.java @@ -29,6 +29,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; @@ -39,6 +40,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.WifiInfo; import android.net.wifi.WifiManager; import android.os.Handler; @@ -72,6 +74,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.wifitrackerlib.WifiEntry; @@ -90,6 +93,11 @@ import java.net.Inet4Address; 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; @@ -173,6 +181,7 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle WifiDataUsageSummaryPreferenceController mSummaryHeaderController; private final IconInjector mIconInjector; + private final Clock mClock; private final NetworkRequest mNetworkRequest = new NetworkRequest.Builder() .clearCapabilities().addTransportType(TRANSPORT_WIFI).build(); @@ -183,6 +192,8 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle public void onLinkPropertiesChanged(Network network, LinkProperties lp) { if (network.equals(mNetwork) && !lp.equals(mLinkProperties)) { mLinkProperties = lp; + refreshEntityHeader(); + refreshButtons(); refreshIpLayerInfo(); } } @@ -253,7 +264,7 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle MetricsFeatureProvider metricsFeatureProvider) { return new WifiDetailPreferenceController2( wifiEntry, connectivityManager, context, fragment, handler, lifecycle, - wifiManager, metricsFeatureProvider, new IconInjector(context)); + wifiManager, metricsFeatureProvider, new IconInjector(context), new Clock()); } @VisibleForTesting @@ -266,7 +277,8 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle Lifecycle lifecycle, WifiManager wifiManager, MetricsFeatureProvider metricsFeatureProvider, - IconInjector injector) { + IconInjector injector, + Clock clock) { super(context); mWifiEntry = wifiEntry; @@ -278,6 +290,7 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle mWifiManager = wifiManager; mMetricsFeatureProvider = metricsFeatureProvider; mIconInjector = injector; + mClock = clock; mLifecycle = lifecycle; lifecycle.addObserver(this); @@ -313,6 +326,7 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle .setButton4Text(R.string.share) .setButton4Icon(R.drawable.ic_qrcode_24dp) .setButton4OnClickListener(view -> shareNetwork()); + updateCaptivePortalButton(); mSignalStrengthPref = screen.findPreference(KEY_SIGNAL_STRENGTH_PREF); mTxLinkSpeedPref = screen.findPreference(KEY_TX_LINK_SPEED); @@ -333,6 +347,43 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle mSecurityPref.setSummary(mWifiEntry.getSecurityString(false /* concise */)); } + /** + * 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); + }); + // Only show the venue website when the network is connected. + return mWifiEntry.getConnectedState() == WifiEntry.CONNECTED_STATE_CONNECTED; + } + + 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); @@ -359,6 +410,37 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle mEntityHeaderController.setLabel(mWifiEntry.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); @@ -367,6 +449,7 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle mEntityHeaderController .setSummary(summary) + .setSecondSummary(getExpiryTimeSummary()) .setRecyclerView(mFragment.getListView(), mLifecycle) .done(mFragment.getActivity(), true /* rebind */); } @@ -589,13 +672,13 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle private void refreshButtons() { final boolean canForgetNetwork = mWifiEntry.canForget(); - final boolean canSignIntoNetwork = canSignIntoNetwork(); + final boolean showCaptivePortalButton = updateCaptivePortalButton(); final boolean canConnectDisconnectNetwork = mWifiEntry.canConnect() || mWifiEntry.canDisconnect(); final boolean canShareNetwork = canShareNetwork(); mButtonsPref.setButton1Visible(canForgetNetwork); - mButtonsPref.setButton2Visible(canSignIntoNetwork); + mButtonsPref.setButton2Visible(showCaptivePortalButton); // If it's expired and connected, shows Disconnect button for users to disconnect it. // If it's expired and not connected, hides the button and users are not able to connect it. // @@ -612,7 +695,7 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle mButtonsPref.setButton3Icon(getConnectDisconnectButtonIconResource()); mButtonsPref.setButton4Visible(canShareNetwork); mButtonsPref.setVisible(canForgetNetwork - || canSignIntoNetwork + || showCaptivePortalButton || canConnectDisconnectNetwork || canShareNetwork); } @@ -839,6 +922,13 @@ public class WifiDetailPreferenceController2 extends AbstractPreferenceControlle } } + @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/details2/WifiDetailPreferenceController2Test.java b/tests/robotests/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2Test.java index b48cd3c362f..be5e1af39b8 100644 --- a/tests/robotests/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2Test.java +++ b/tests/robotests/src/com/android/settings/wifi/details2/WifiDetailPreferenceController2Test.java @@ -20,8 +20,8 @@ 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; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -40,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; @@ -51,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; @@ -74,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.wifitrackerlib.NetworkDetailsTracker; @@ -98,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.Arrays; import java.util.stream.Collectors; @@ -148,6 +155,8 @@ public class WifiDetailPreferenceController2Test { @Mock private WifiDetailPreferenceController2.IconInjector mMockIconInjector; @Mock + private WifiDetailPreferenceController2.Clock mMockClock; + @Mock private MacAddress mMockMacAddress; @Mock(answer = Answers.RETURNS_DEEP_STUBS) @@ -288,7 +297,10 @@ public class WifiDetailPreferenceController2Test { // builder pattern when(mMockHeaderController.setRecyclerView(mMockFragment.getListView(), mLifecycle)) .thenReturn(mMockHeaderController); - when(mMockHeaderController.setSummary(anyString())).thenReturn(mMockHeaderController); + when(mMockHeaderController.setSummary(nullable(String.class))) + .thenReturn(mMockHeaderController); + when(mMockHeaderController.setSecondSummary(nullable(String.class))) + .thenReturn(mMockHeaderController); when(mMockIconInjector.getIcon(anyInt())).thenReturn(new ColorDrawable()); setupMockedPreferenceScreen(); @@ -326,7 +338,8 @@ public class WifiDetailPreferenceController2Test { mLifecycle, mMockWifiManager, mMockMetricsFeatureProvider, - mMockIconInjector)); + mMockIconInjector, + mMockClock)); } private void setupMockedPreferenceScreen() { @@ -514,6 +527,54 @@ public class WifiDetailPreferenceController2Test { verify(mMockHeaderController).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(mMockHeaderController); + inOrder.verify(mMockHeaderController).setSecondSummary(expectedSummary); + + updateLinkProperties(new LinkProperties()); + inOrder.verify(mMockHeaderController).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(mMockHeaderController); + inOrder.verify(mMockHeaderController).setSecondSummary(expectedSummary); + + updateLinkProperties(new LinkProperties()); + inOrder.verify(mMockHeaderController).setSecondSummary((String) null); + } + @Test public void entityHeader_shouldConvertSavedAsDisconnected() { setUpForDisconnectedNetwork(); @@ -1260,6 +1321,8 @@ public class WifiDetailPreferenceController2Test { nc.addCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL); updateNetworkCapabilities(nc); + + inOrder.verify(mMockButtonsPref).setButton2Text(R.string.wifi_sign_in_button_text); inOrder.verify(mMockButtonsPref).setButton2Visible(true); nc.removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL); @@ -1267,6 +1330,31 @@ public class WifiDetailPreferenceController2Test { inOrder.verify(mMockButtonsPref).setButton2Visible(false); } + @Test + public void captivePortal_shouldShowVenueInfoButton() { + setUpForConnectedNetwork(); + + InOrder inOrder = inOrder(mMockButtonsPref); + + displayAndResume(); + + inOrder.verify(mMockButtonsPref).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(mMockButtonsPref).setButton2Text(R.string.wifi_venue_website_button_text); + inOrder.verify(mMockButtonsPref).setButton2Visible(true); + + lp.setCaptivePortalData(null); + updateLinkProperties(lp); + inOrder.verify(mMockButtonsPref).setButton2Visible(false); + } + @Test public void testSignInButton_shouldStartCaptivePortalApp() { setUpForConnectedNetwork(); @@ -1274,7 +1362,8 @@ public class WifiDetailPreferenceController2Test { displayAndResume(); ArgumentCaptor captor = ArgumentCaptor.forClass(OnClickListener.class); - verify(mMockButtonsPref).setButton2OnClickListener(captor.capture()); + verify(mMockButtonsPref, atLeastOnce()).setButton2OnClickListener(captor.capture()); + // getValue() returns the last captured value captor.getValue().onClick(null); verify(mMockConnectivityManager).startCaptivePortalApp(mMockNetwork); verify(mMockMetricsFeatureProvider)