From b72773de0a123230a640b55d01c0e47a179efbc7 Mon Sep 17 00:00:00 2001 From: TioaChan <8370450+TioaChan@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:10:25 +0800 Subject: [PATCH 1/2] fix: handle unknown Wi-Fi P2P security on Android 16 Android 16 vendor frameworks can return SECURITY_TYPE_UNKNOWN (-1) for a successfully created WPA2-PSK group. Treat that documented value as the pre-Android-16 WPA2 assumption instead of tearing down the group and preventing wireless CarPlay from starting. Preserve explicit WPA2/WPA3 mappings and reject other unsupported values. Adapt the UNKNOWN fallback from shihabal3amri/DiPlay#121. Add API 36 regressions for successful startup and credential preservation, explicit security modes, and cleanup of unsupported values. Export the JDK FileDescriptor accessor required by Android 16 Robolectric tests. Validation: 45 related tests passed; release lint has 0 errors and 4 existing warnings; signed arm64 release built and installed. Wireless CarPlay was confirmed working by the reporter on SM-S9280 / Android 16. Fixes #20 Co-authored-by: Codex --- common/build.gradle.kts | 5 ++ .../xcertplay/WifiP2pGroupManagerTest.kt | 51 ++++++++++++++++++- .../xcertplay/network/WifiP2pGroupManager.kt | 6 +++ 3 files changed, 61 insertions(+), 1 deletion(-) 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/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}", ) From bd941a461e930560feebbe695bca4136ac588e06 Mon Sep 17 00:00:00 2001 From: TioaChan <8370450+TioaChan@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:30:48 +0800 Subject: [PATCH 2/2] fix: refresh hotspot settings when reusing the connection screen The singleTask host cached the wireless backend and manual hotspot credentials from onCreate. Returning from settings before its controller started could therefore create a Wi-Fi Direct group after the user had selected Device hotspot, triggering Android's hotspot conflict dialog. Refresh persisted transport/backend settings on idle resume, and request startup prerequisites if the transport changed. Preserve active sessions, in-progress teardown, menu edits and transient permission prompt state. When applying a different wireless mode, wait for the old background session to stop before checking the new hotspot state. This releases the old P2P group even when the system hotspot still needs to be enabled. Add lifecycle regressions for both mode directions, edited credentials, station Wi-Fi being off, wired-to-wireless permissions and asynchronous old-session cleanup. The original code failed the first three regressions. Related validation: 90 tests passed, with the 10 directly related tests rerun against the final code; release lint has 0 errors and 4 existing warnings. Signed arm64 release, authentication assets and 16 KiB alignment verified. Co-authored-by: Codex --- .../shilapi/xcertplay/CarPlayHostActivity.kt | 36 ++++-- .../com/shilapi/xcertplay/DiPlayActivity.kt | 6 +- .../xcertplay/HostWirelessSettingsTest.kt | 108 ++++++++++++++++++ .../xcertplay/ManualHotspotSettingsTest.kt | 30 +++++ 4 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 common/src/test/java/com/shilapi/xcertplay/HostWirelessSettingsTest.kt 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 ->