diff --git a/common/build.gradle.kts b/common/build.gradle.kts index c48bc77..e449789 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -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().configureEach { + jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") +} diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt index 525a3d8..a389ef6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -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()) { @@ -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() @@ -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) { @@ -667,7 +679,13 @@ class CarPlayHostActivity : ComponentActivity() { } wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions() updateHotspotStatusBlock() - maybeStartCarPlay() + if (transportChanged && microphonePermissionResolved && !awaitingVpnConsent && + !awaitingWirelessPermissions && !awaitingLocationPermission + ) { + requestStartupPrerequisites() + } else { + maybeStartCarPlay() + } showSystemBars() } diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 2af8ad5..868c160 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -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() { diff --git a/common/src/test/java/com/shilapi/xcertplay/HostWirelessSettingsTest.kt b/common/src/test/java/com/shilapi/xcertplay/HostWirelessSettingsTest.kt new file mode 100644 index 0000000..d8abba0 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/HostWirelessSettingsTest.kt @@ -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() } + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt b/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt index 2710461..eacf0a9 100644 --- a/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt @@ -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") @@ -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 -> diff --git a/common/src/test/java/com/shilapi/xcertplay/WifiP2pGroupManagerTest.kt b/common/src/test/java/com/shilapi/xcertplay/WifiP2pGroupManagerTest.kt index aa5bc47..17efa47 100644 --- a/common/src/test/java/com/shilapi/xcertplay/WifiP2pGroupManagerTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/WifiP2pGroupManagerTest.kt @@ -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 @@ -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() + 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() + 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) @@ -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) @@ -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) } } } } diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/WifiP2pGroupManager.kt b/shared/src/main/java/com/shilapi/xcertplay/network/WifiP2pGroupManager.kt index ee35506..b8e7e68 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/network/WifiP2pGroupManager.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/network/WifiP2pGroupManager.kt @@ -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}", )