Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,8 @@ dependencies {
testImplementation(libs.junit)
testImplementation("org.robolectric:robolectric:4.17")
}

// Android 16 Robolectric ApplicationSharedMemory uses the JDK FileDescriptor accessor.
tasks.withType<Test>().configureEach {
jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED")
}
36 changes: 27 additions & 9 deletions common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -495,17 +495,11 @@ class CarPlayHostActivity : ComponentActivity() {
safeAreaDrawOutside = AirPlayPersistence.loadSafeAreaDrawOutside(this)
locationReportingEnabled = AirPlayPersistence.loadLocationReportingEnabled(this)
locationPermissionAvailable = hasFineLocationPermission()
wirelessEnabled = AirPlayPersistence.loadWirelessEnabled(this)
mfiTarget = AirPlayPersistence.loadMfiTarget(this)
mfiI2cPath = AirPlayPersistence.loadMfiI2cPath(this)
remoteMfiServer = AirPlayPersistence.loadRemoteMfiServer(this)
remoteMfiToken = AirPlayPersistence.loadRemoteMfiToken(this)
wirelessHotspotMode = AirPlayPersistence.loadWirelessHotspotMode(this)
manualHotspotSsid = AirPlayPersistence.loadManualHotspotSsid(this)
manualHotspotPassphrase = AirPlayPersistence.loadManualHotspotPassphrase(this)
manualHotspotBand = AirPlayPersistence.loadManualHotspotBand(this)
manualHotspotChannel = AirPlayPersistence.loadManualHotspotChannel(this)
manualHotspotSecurity = AirPlayPersistence.loadManualHotspotSecurity(this)
loadPersistedWirelessSettings()
manualHotspotChannelPermissionAsked =
AirPlayPersistence.loadManualHotspotChannelPermissionPromptDismissed(this)
if (manualHotspotChannelPermissionAsked && !hasFineLocationPermission()) {
Expand All @@ -514,6 +508,16 @@ class CarPlayHostActivity : ComponentActivity() {
wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions()
}

private fun loadPersistedWirelessSettings() {
wirelessEnabled = AirPlayPersistence.loadWirelessEnabled(this)
wirelessHotspotMode = AirPlayPersistence.loadWirelessHotspotMode(this)
manualHotspotSsid = AirPlayPersistence.loadManualHotspotSsid(this)
manualHotspotPassphrase = AirPlayPersistence.loadManualHotspotPassphrase(this)
manualHotspotBand = AirPlayPersistence.loadManualHotspotBand(this)
manualHotspotChannel = AirPlayPersistence.loadManualHotspotChannel(this)
manualHotspotSecurity = AirPlayPersistence.loadManualHotspotSecurity(this)
}

private fun requestStartupPrerequisites() {
if (locationReportingEnabled && !locationPermissionAvailable) {
requestLocationPermission()
Expand Down Expand Up @@ -643,7 +647,15 @@ class CarPlayHostActivity : ComponentActivity() {

override fun onResume() {
super.onResume()
if (!isFinishing && controller == null && !menuOpen) {
var transportChanged = false
if (!isFinishing && !shuttingDown.get() && controller == null &&
!menuOpen && !handshakeResetInProgress
) {
// The singleTask host can return from settings before a controller exists.
// Refresh the backend and its credentials before any startup/permission checks.
val previousWireless = wirelessEnabled
loadPersistedWirelessSettings()
transportChanged = previousWireless != wirelessEnabled
locationReportingEnabled = AirPlayPersistence.loadLocationReportingEnabled(this)
val selectedMicrophone = AirPlayPersistence.loadMicrophoneSource(this)
if (microphoneSource != selectedMicrophone) {
Expand All @@ -667,7 +679,13 @@ class CarPlayHostActivity : ComponentActivity() {
}
wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions()
updateHotspotStatusBlock()
maybeStartCarPlay()
if (transportChanged && microphonePermissionResolved && !awaitingVpnConsent &&
!awaitingWirelessPermissions && !awaitingLocationPermission
) {
requestStartupPrerequisites()
} else {
maybeStartCarPlay()
}
showSystemBars()
}

Expand Down
6 changes: 5 additions & 1 deletion common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,11 @@ class DiPlayActivity : AppCompatActivity() {
private fun applyWirelessLink(mode: WirelessHotspotMode) {
AirPlayPersistence.saveWirelessHotspotMode(this, mode)
render()
if (CarPlayBackgroundSession.hasSession()) connect(true)
if (CarPlayBackgroundSession.hasSession()) {
// Release the previous backend before checking whether the new hotspot is on.
// Otherwise its P2P group can prevent the user from enabling the system hotspot.
CarPlayBackgroundSession.stop { runOnUiThread { connect(true) } }
}
}

private fun showBrowserDevices() {
Expand Down
108 changes: 108 additions & 0 deletions common/src/test/java/com/shilapi/xcertplay/HostWirelessSettingsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.shilapi.xcertplay

import android.Manifest
import android.content.Intent
import android.net.wifi.WifiManager
import com.shilapi.xcertplay.orchestration.CarPlayRuntimeConfig
import com.shilapi.xcertplay.orchestration.CarPlayTransport
import com.shilapi.xcertplay.orchestration.ManualHotspotBand
import com.shilapi.xcertplay.orchestration.ManualHotspotSecurity
import com.shilapi.xcertplay.orchestration.WirelessHotspotMode
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.util.ReflectionHelpers

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [29])
class HostWirelessSettingsTest {
private val context get() = RuntimeEnvironment.getApplication()

@Before fun setup() {
CarPlayBackgroundSession.clear()
val identity = SyntheticMfiIdentity.create()
MfiAssetStore(context.noBackupFilesDir).installFiles(
{ identity.key.inputStream() }, { identity.certificate.inputStream() })
AirPlayPersistence.saveWirelessEnabled(context, true)
AirPlayPersistence.saveManualHotspotSsid(context, " CarPlay hotspot ")
AirPlayPersistence.saveManualHotspotPassphrase(context, " password ")
AirPlayPersistence.saveManualHotspotBand(context, ManualHotspotBand.GHZ_5)
AirPlayPersistence.saveManualHotspotChannel(context, 149)
AirPlayPersistence.saveManualHotspotSecurity(context, ManualHotspotSecurity.WPA2)
// Keep startup waiting for microphone permission: no real radio or decoder is started.
shadowOf(context).denyPermissions(Manifest.permission.RECORD_AUDIO)
}

@After fun cleanup() { CarPlayBackgroundSession.clear() }

private fun config(host: CarPlayHostActivity): CarPlayRuntimeConfig =
ReflectionHelpers.callInstanceMethod(host, "createRuntimeConfig")

@Test fun reusedHostSwitchesFromDirectToDeviceHotspotWhileStationWifiIsOff() {
AirPlayPersistence.saveWirelessHotspotMode(context, WirelessHotspotMode.WIFI_P2P)
val activity = Robolectric.buildActivity(CarPlayHostActivity::class.java).setup()
try {
val host = activity.get()
assertFalse(host.isFinishing)
assertEquals(WirelessHotspotMode.WIFI_P2P, config(host).wirelessHotspotMode)
activity.pause()
AirPlayPersistence.saveWirelessHotspotMode(context, WirelessHotspotMode.MANUAL)
context.getSystemService(WifiManager::class.java).isWifiEnabled = false
activity.newIntent(Intent(context, CarPlayHostActivity::class.java)).resume()
val current = config(host)
assertEquals(CarPlayTransport.WIRELESS, current.transport)
assertEquals(WirelessHotspotMode.MANUAL, current.wirelessHotspotMode)
assertEquals(" CarPlay hotspot ", current.manualHotspotSsid)
assertEquals(" password ", current.manualHotspotPassphrase)
assertEquals(ManualHotspotBand.GHZ_5, current.manualHotspotBand)
assertEquals(149, current.manualHotspotChannel)
assertEquals(ManualHotspotSecurity.WPA2, current.manualHotspotSecurity)
assertFalse(context.getSystemService(WifiManager::class.java).isWifiEnabled)
assertFalse(CarPlayBackgroundSession.hasSession())
} finally { activity.pause().stop().destroy() }
}

@Test fun switchingAnIdleWiredHostToWirelessRequestsTheNewTransportPermissions() {
AirPlayPersistence.saveWirelessEnabled(context, false)
val activity = Robolectric.buildActivity(CarPlayHostActivity::class.java).setup()
try {
val host = activity.get()
assertEquals(CarPlayTransport.WIRED, config(host).transport)
activity.pause()
AirPlayPersistence.saveWirelessEnabled(context, true)
AirPlayPersistence.saveMicrophoneSource(context, CarPlayMicrophoneSource.DISABLED)
activity.newIntent(Intent(context, CarPlayHostActivity::class.java)).resume()
assertEquals(CarPlayTransport.WIRELESS, config(host).transport)
val permissions = shadowOf(host).lastRequestedPermission.requestedPermissions.toSet()
assertEquals(setOf(Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION), permissions)
assertFalse(CarPlayBackgroundSession.hasSession())
} finally { activity.pause().stop().destroy() }
}

@Test fun reusedHostRefreshesEditedHotspotCredentialsAndTheReverseModeSwitch() {
AirPlayPersistence.saveWirelessHotspotMode(context, WirelessHotspotMode.MANUAL)
val activity = Robolectric.buildActivity(CarPlayHostActivity::class.java).setup()
try {
val host = activity.get()
assertEquals(" CarPlay hotspot ", config(host).manualHotspotSsid)
activity.pause()
AirPlayPersistence.saveManualHotspotSsid(context, "New hotspot")
AirPlayPersistence.saveManualHotspotPassphrase(context, "new-password")
activity.newIntent(Intent(context, CarPlayHostActivity::class.java)).resume()
assertEquals("New hotspot", config(host).manualHotspotSsid)
assertEquals("new-password", config(host).manualHotspotPassphrase)
activity.pause()
AirPlayPersistence.saveWirelessHotspotMode(context, WirelessHotspotMode.WIFI_P2P)
activity.newIntent(Intent(context, CarPlayHostActivity::class.java)).resume()
assertEquals(WirelessHotspotMode.WIFI_P2P, config(host).wirelessHotspotMode)
} finally { activity.pause().stop().destroy() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowDialog
import org.robolectric.util.ReflectionHelpers

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [29], qualifiers = "zh-rCN-w360dp-h800dp")
Expand Down Expand Up @@ -57,6 +58,35 @@ class ManualHotspotSettingsTest {
AirPlayPersistence.saveWirelessHotspotMode(context, WirelessHotspotMode.MANUAL)
}

@Test fun switchingToDeviceHotspotStopsThePreviousSessionBeforeCheckingHotspotState() {
val identity = SyntheticMfiIdentity.create()
MfiAssetStore(context.noBackupFilesDir).installFiles(
{ identity.key.inputStream() }, { identity.certificate.inputStream() })
AirPlayPersistence.saveManualHotspotSsid(context, "CarPlay hotspot")
AirPlayPersistence.saveManualHotspotPassphrase(context, password)
var stopped = false
var completeStop: (() -> Unit)? = null
try {
withSettings { activity ->
ReflectionHelpers.setField(CarPlayBackgroundSession, "stopAction",
{ completion: () -> Unit ->
stopped = true
completeStop = completion
})
selectManual(activity)
assertTrue("The old P2P session must release the radio before asking to enable the device hotspot", stopped)
assertTrue(CarPlayBackgroundSession.hasSession())
assertFalse("The hotspot prompt must wait for the old session to stop", dialog().isShowing)
CarPlayBackgroundSession.clear()
completeStop!!.invoke()
shadowOf(android.os.Looper.getMainLooper()).idle()
assertFalse(CarPlayBackgroundSession.hasSession())
assertTrue(dialog().isShowing)
assertEquals(WirelessHotspotMode.MANUAL, AirPlayPersistence.loadWirelessHotspotMode(context))
}
} finally { CarPlayBackgroundSession.clear() }
}

@Test fun firstSetupPreservesSsidAndPasswordWhitespaceAcrossReopening() {
val ssid = " CarPlayTest "
withSettings { activity ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import android.net.wifi.p2p.WifiP2pInfo
import android.net.wifi.p2p.WifiP2pManager
import com.shilapi.xcertplay.network.P2pResetRequiredException
import com.shilapi.xcertplay.network.WifiP2pGroupManager
import com.shilapi.xcertplay.transport.Iap2WirelessSecurity
import java.io.IOException
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
Expand All @@ -35,13 +37,58 @@ class WifiP2pGroupManagerTest {
private val radio get() = shadowOf(context.getSystemService(WifiP2pManager::class.java)) as P2pRadio

@Before fun setup() {
shadowOf(context).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION)
shadowOf(context).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.NEARBY_WIFI_DEVICES)
val wifi = context.getSystemService(WifiManager::class.java)
wifi.isWifiEnabled = true
shadowOf(wifi.connectionInfo).setFrequency(5180)
shadowOf(wifi.connectionInfo).setSupplicantState(SupplicantState.COMPLETED)
}

@Test @Config(sdk = [36])
fun android16UnknownSecurityKeepsTheCreatedGroupAndItsCredentials() {
radio.securityType = WifiP2pGroup.SECURITY_TYPE_UNKNOWN
val logs = mutableListOf<String>()
WifiP2pGroupManager(context, logs::add).use { manager ->
val info = background { manager.start(5000) }
assertEquals(Iap2WirelessSecurity.WPA_WPA2, info.security)
assertEquals(5180, info.frequencyMHz)
assertEquals(radio.requests.single()!!.networkName, info.ssid)
assertEquals(radio.requests.single()!!.passphrase, info.passphrase)
assertEquals(0, radio.removals)
assertTrue(logs.any { it.contains("security type not reported") && it.contains("WPA2-PSK") })
assertTrue(logs.any { it.contains("ready mode=ALIGNED_5_GHZ") })
assertFalse(logs.any { it.contains(info.ssid) || it.contains(info.passphrase) })
}
assertEquals(1, radio.removals)
}

@Test @Config(sdk = [36])
fun android16ReportedSecurityTypesArePreserved() {
for ((reported, expected) in listOf(
WifiP2pGroup.SECURITY_TYPE_WPA2_PSK to Iap2WirelessSecurity.WPA_WPA2,
WifiP2pGroup.SECURITY_TYPE_WPA3_COMPATIBILITY to Iap2WirelessSecurity.WPA3_TRANSITION,
WifiP2pGroup.SECURITY_TYPE_WPA3_SAE to Iap2WirelessSecurity.WPA3_ONLY,
)) {
radio.securityType = reported
val logs = mutableListOf<String>()
WifiP2pGroupManager(context, logs::add).use { manager ->
assertEquals(expected, background { manager.start(5000) }.security)
assertFalse(logs.any { it.contains("assuming WPA2-PSK") })
}
}
}

@Test @Config(sdk = [36])
fun android16UnrecognizedSecurityStillFailsAndRemovesItsGroup() {
radio.securityType = 91
WifiP2pGroupManager(context).use { manager ->
val error = failure { manager.start(5000) }
assertTrue(error is IOException)
assertEquals("Unsupported Wi-Fi P2P security type: 91", error.message)
}
assertEquals(1, radio.removals)
}

@Test fun disconnectedStationCannotPinNewGroupToStaleTwoGhzChannel() {
val info = context.getSystemService(WifiManager::class.java).connectionInfo
shadowOf(info).setFrequency(2462)
Expand Down Expand Up @@ -329,6 +376,7 @@ class WifiP2pGroupManagerTest {
var fixed24Only = false
var allowed24 = setOf(2412, 2437, 2462)
var reportedFrequency: Int? = null
var securityType: Int? = null

@Implementation protected fun requestP2pState(channel: WifiP2pManager.Channel, listener: WifiP2pManager.P2pStateListener) {
listener.onP2pStateAvailable(WifiP2pManager.WIFI_P2P_STATE_ENABLED)
Expand Down Expand Up @@ -370,6 +418,7 @@ class WifiP2pGroupManagerTest {
shadowOf(this).setPassphrase(if (missingCredentials) null else config?.passphrase ?: "test-generated-key")
shadowOf(this).setInterface("p2p-test-missing")
ReflectionHelpers.setField(this, "mFrequency", reportedFrequency ?: config?.groupOwnerBand ?: 2437)
this@P2pRadio.securityType?.let { ReflectionHelpers.setField(this, "mSecurityType", it) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,12 @@ class WifiP2pGroupManager(
WifiP2pGroup.SECURITY_TYPE_WPA3_COMPATIBILITY ->
Iap2WirelessSecurity.WPA3_TRANSITION
WifiP2pGroup.SECURITY_TYPE_WPA3_SAE -> Iap2WirelessSecurity.WPA3_ONLY
// Vendor supplicants can leave the new API 36 security report UNKNOWN for
// the ordinary WPA2-PSK group created above. Keep the pre-36 assumption.
WifiP2pGroup.SECURITY_TYPE_UNKNOWN -> {
diagnostic("Wi-Fi P2P security type not reported by the framework; assuming WPA2-PSK")
Iap2WirelessSecurity.WPA_WPA2
}
else -> throw IOException(
"Unsupported Wi-Fi P2P security type: ${group.securityType}",
)
Expand Down
Loading