Implement opt-in to custom tabs in captive portals.

This will allow portal operators to opt-in to custom tabs in
captive portal, either across the board with "true" or with
a version of the module above which it wants to opt-in.

Ideally there could be a separate key to also list opted-in
browsers and their versions, but this would become a lot
more complex. To start with, there is no place in
CaptivePortalData to store this kind of info, and that would
mean adding API which would have to be supported forever.

The constant inside the source represents a capport source that
contains the custom tabs opt-in. This is not a great place to put
this information in, but it has the characteristics of a benign and
useful hack :
- Time to market is 0
- It's easy to remove once we have a better API
- It's not a large problem if it's never cleaned up

Also, allowing to choose the browsers and the versions would
be uncomfortable since the choice of the browser is the user's
choice. On the one hand it does make some sense that the
operator could test with a given browser, see the UX is not
well adapted, and reject it, but on the other hand giving the
operator the choice of what browser the user can choose to
use on their portal is disputable, as this should generally be
under the control of the user.

In any case even if we decided to add a per-browser version
check it should be done in some other change.

Test: manual
Flag: EXEMPT opt-in feature
Change-Id: I540f4fcb6fb26f7f18077d9c8d06e5f7df206056
This commit is contained in:
Chalard Jean 2025-06-13 16:03:39 +09:00
commit f2cb190a73
3 changed files with 132 additions and 26 deletions

View file

@ -50,7 +50,9 @@ public final class CaptivePortalData implements Parcelable {
@Retention(RetentionPolicy.SOURCE)
@IntDef(prefix = {"CAPTIVE_PORTAL_DATA_SOURCE_"}, value = {
CAPTIVE_PORTAL_DATA_SOURCE_OTHER,
CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT})
CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT,
CAPTIVE_PORTAL_DATA_SOURCE_CAPPORT_WITH_CUSTOM_TABS_OPTIN
})
public @interface CaptivePortalDataSource {}
/**
@ -63,6 +65,17 @@ public final class CaptivePortalData implements Parcelable {
*/
public static final int CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT = 1;
// TODO : Remove this, replace with an explicit boolean for opting in. Better yet, when the
// feature is publicly rolled out, remove without a replacement. Because the network stack and
// connectivity modules are not yet merged, adding anything in this class would require
// public API, which would have to be maintained forever and would force very long time-to-
// market timelines, so this is used instead.
/**
* Source of information : Capport, and capport opts in to custom tabs.
* @hide
*/
public static final int CAPTIVE_PORTAL_DATA_SOURCE_CAPPORT_WITH_CUSTOM_TABS_OPTIN = 2;
private CaptivePortalData(long refreshTimeMillis, Uri userPortalUrl, Uri venueInfoUrl,
boolean isSessionExtendable, long byteLimit, long expiryTimeMillis, boolean captive,
CharSequence venueFriendlyName, int venueInfoUrlSource, int userPortalUrlSource) {

View file

@ -57,20 +57,35 @@ class CaptivePortalDataTest {
.apply {
if (SdkLevel.isAtLeastS()) {
setVenueFriendlyName("venue friendly name")
setUserPortalUrl(Uri.parse("https://tc.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT)
setVenueInfoUrl(Uri.parse("https://venue.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT)
setUserPortalUrl(
Uri.parse("https://tc.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT
)
setVenueInfoUrl(
Uri.parse("https://venue.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT
)
}
}
.build()
private val optInData = CaptivePortalData.Builder().apply {
setCaptive(true)
if (SdkLevel.isAtLeastS()) {
setUserPortalUrl(
Uri.parse("https://tc.example.passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_CAPPORT_WITH_CUSTOM_TABS_OPTIN
)
}
}.build()
private fun makeBuilder() = CaptivePortalData.Builder(data)
@Test
fun testParcelUnparcel() {
assertParcelingIsLossless(data)
assertParcelingIsLossless(dataFromPasspoint)
if (SdkLevel.isAtLeastS()) assertParcelingIsLossless(optInData)
assertParcelingIsLossless(makeBuilder().setUserPortalUrl(null).build())
assertParcelingIsLossless(makeBuilder().setVenueInfoUrl(null).build())
@ -96,24 +111,35 @@ class CaptivePortalDataTest {
assertEquals(dataFromPasspoint, CaptivePortalData.Builder(dataFromPasspoint).build())
assertNotEqualsAfterChange { it.setUserPortalUrl(
Uri.parse("https://tc.example.com/passpoint")) }
Uri.parse("https://tc.example.com/passpoint")
) }
assertNotEqualsAfterChange { it.setUserPortalUrl(
Uri.parse("https://tc.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER) }
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER
) }
assertNotEqualsAfterChange { it.setUserPortalUrl(
Uri.parse("https://tc.example.com/other"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT) }
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT
) }
assertNotEqualsAfterChange { it.setUserPortalUrl(
Uri.parse("https://tc.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER) }
Uri.parse("https://tc.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER
) }
assertNotEqualsAfterChange { it.setUserPortalUrl(
Uri.parse("https://tc.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_CAPPORT_WITH_CUSTOM_TABS_OPTIN
) }
assertNotEqualsAfterChange { it.setVenueInfoUrl(
Uri.parse("https://venue.example.com/passpoint")) }
Uri.parse("https://venue.example.com/passpoint")
) }
assertNotEqualsAfterChange { it.setVenueInfoUrl(
Uri.parse("https://venue.example.com/other"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT) }
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT
) }
assertNotEqualsAfterChange { it.setVenueInfoUrl(
Uri.parse("https://venue.example.com/passpoint"),
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER) }
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER
) }
}
}
@ -164,18 +190,32 @@ class CaptivePortalDataTest {
@Test @IgnoreUpTo(Build.VERSION_CODES.R)
fun testGetVenueInfoUrlSource() {
assertEquals(CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER,
data.venueInfoUrlSource)
assertEquals(CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT,
dataFromPasspoint.venueInfoUrlSource)
assertEquals(
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER,
data.venueInfoUrlSource
)
assertEquals(
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT,
dataFromPasspoint.venueInfoUrlSource
)
}
@Test @IgnoreUpTo(Build.VERSION_CODES.R)
fun testGetUserPortalUrlSource() {
assertEquals(CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER,
data.userPortalUrlSource)
assertEquals(CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT,
dataFromPasspoint.userPortalUrlSource)
assertEquals(
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER,
data.userPortalUrlSource
)
assertEquals(
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_PASSPOINT,
dataFromPasspoint.userPortalUrlSource
)
if (SdkLevel.isAtLeastS()) {
assertEquals(
CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_CAPPORT_WITH_CUSTOM_TABS_OPTIN,
optInData.userPortalUrlSource
)
}
}
private fun CaptivePortalData.mutate(mutator: (CaptivePortalData.Builder) -> Unit) =
@ -184,4 +224,4 @@ class CaptivePortalDataTest {
private fun assertNotEqualsAfterChange(mutator: (CaptivePortalData.Builder) -> Unit) {
assertNotEquals(data, data.mutate(mutator))
}
}
}

View file

@ -24,7 +24,10 @@ import android.content.Context.BIND_AUTO_CREATE
import android.content.Context.BIND_IMPORTANT
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.content.res.Resources
import android.net.CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_CAPPORT_WITH_CUSTOM_TABS_OPTIN
import android.net.CaptivePortalData.CAPTIVE_PORTAL_DATA_SOURCE_OTHER
import android.net.ConnectivityManager
import android.net.IDnsResolver
import android.net.INetd
@ -236,7 +239,7 @@ class ConnectivityServiceIntegrationTest {
}
private inner class TestConnectivityService(deps: Dependencies) : ConnectivityService(
context,
context,
dnsResolver,
log,
netd,
@ -253,7 +256,7 @@ class ConnectivityServiceIntegrationTest {
override fun getBpfNetMaps(
context: Context?,
netd: INetd?,
interfaceTracker: InterfaceTracker?
interfaceTracker: InterfaceTracker?
) = mock(BpfNetMaps::class.java)
.also {
doReturn(PERMISSION_INTERNET).`when`(it).getNetPermForUid(anyInt())
@ -284,7 +287,7 @@ class ConnectivityServiceIntegrationTest {
tm: TelephonyManager,
requestRestrictedWifiEnabled: Boolean,
listener: BiConsumer<Int, Int>,
handler: Handler
handler: Handler,
): CarrierPrivilegeAuthenticator {
return CarrierPrivilegeAuthenticator(
context,
@ -363,8 +366,44 @@ class ConnectivityServiceIntegrationTest {
}
}
private fun getModuleVersion(): Long {
val captivePortalActivity = Intent(ConnectivityManager.ACTION_CAPTIVE_PORTAL_SIGN_IN)
.resolveActivity(context.packageManager)
val captivePortalInfo = context.packageManager.getPackageInfo(
captivePortalActivity.packageName,
PackageManager.GET_ACTIVITIES
)
return captivePortalInfo.longVersionCode
}
val OPT_IN = true
var OPT_OUT = false
@Test
fun testCapportApi() {
fun testCapportApiWithoutOptInToCustomTabs() = testCapportApi(
optInToCustomTabsString = null,
expectedOptIn = OPT_OUT
)
@Test
fun testCapportApiWithFullOptInToCustomTabs() = testCapportApi(
optInToCustomTabsString = "true",
expectedOptIn = OPT_IN
)
@Test
fun testCapportApiWithHighVersionOptInToCustomTabs() = testCapportApi(
optInToCustomTabsString = (getModuleVersion() + 10).toString(),
expectedOptIn = OPT_OUT
)
@Test
fun testCapportApiWithLowVersionOptInToCustomTabs() = testCapportApi(
optInToCustomTabsString = (getModuleVersion() - 10).toString(),
expectedOptIn = OPT_IN
)
fun testCapportApi(optInToCustomTabsString: String?, expectedOptIn: Boolean) {
val request = NetworkRequest.Builder()
.clearCapabilities()
.addCapability(NET_CAPABILITY_INTERNET)
@ -373,11 +412,17 @@ class ConnectivityServiceIntegrationTest {
val apiUrl = "https://capport.android.com"
cm.registerNetworkCallback(request, testCb)
val optInString = if (optInToCustomTabsString != null) {
"\"x-android-use-custom-tabs\": $optInToCustomTabsString,"
} else {
""
}
nsInstrumentation.addHttpResponse(HttpResponse(
apiUrl,
"""
|{
| "captive": true,
| $optInString
| "user-portal-url": "https://login.capport.android.com",
| "venue-info-url": "https://venueinfo.capport.android.com"
|}
@ -412,6 +457,14 @@ class ConnectivityServiceIntegrationTest {
Uri.parse("https://venueinfo.capport.android.com"),
capportData.venueInfoUrl
)
assertEquals(
if (expectedOptIn == OPT_IN) {
CAPTIVE_PORTAL_DATA_SOURCE_CAPPORT_WITH_CUSTOM_TABS_OPTIN
} else {
CAPTIVE_PORTAL_DATA_SOURCE_OTHER
},
capportData.userPortalUrlSource
)
testCb.expectCaps(na, TEST_TIMEOUT_MS) {
it.hasCapability(NET_CAPABILITY_CAPTIVE_PORTAL) &&