32 changed files with 3159 additions and 966 deletions
File diff suppressed because it is too large
@ -0,0 +1,354 @@ |
|||
package com.dct.app.presentation.dialog |
|||
|
|||
import androidx.compose.material3.MaterialTheme |
|||
import androidx.compose.runtime.mutableStateOf |
|||
import androidx.compose.ui.semantics.SemanticsProperties |
|||
import androidx.compose.ui.test.* |
|||
import androidx.compose.ui.test.junit4.createComposeRule |
|||
import com.dct.app.data.communication.* |
|||
import org.junit.Assert.* |
|||
import org.junit.Rule |
|||
import org.junit.Test |
|||
import android.Manifest |
|||
import android.content.pm.PackageManager |
|||
import androidx.test.platform.app.InstrumentationRegistry |
|||
|
|||
class WifiApConnectionUiTest { |
|||
@get:Rule val compose = createComposeRule() |
|||
private val device = WifiApDevice("DCT_DEVICE", "", -40, WifiApSecurity.WPA2) |
|||
private val state = mutableStateOf(WifiApUiState(enabled = true, permissionGranted = true, |
|||
locationEnabled = true, devices = listOf(device))) |
|||
private var connected: Pair<WifiApDevice, String>? = null |
|||
private var cancelled = 0 |
|||
private var dismissed = false |
|||
private var disconnected = 0 |
|||
private var retried = 0 |
|||
private var current = 0 |
|||
private var scanned = 0 |
|||
private var settingsAction: String? = null |
|||
private val endpoint = mutableStateOf(WifiApEndpoint()) |
|||
private val endpointEditable = mutableStateOf(true) |
|||
private val communicationReady = mutableStateOf(false) |
|||
private val socketConnected = mutableStateOf(false) |
|||
private val connectedEndpoint = mutableStateOf<WifiApEndpoint?>(null) |
|||
private var savedEndpoint: WifiApEndpoint? = null |
|||
@Test fun installedAppHasPermissionToRequestTheEquipmentNetwork() { |
|||
val context = InstrumentationRegistry.getInstrumentation().targetContext |
|||
assertEquals(PackageManager.PERMISSION_GRANTED, |
|||
context.checkSelfPermission(Manifest.permission.CHANGE_NETWORK_STATE)) |
|||
} |
|||
private fun show() { |
|||
compose.setContent { MaterialTheme { |
|||
WifiApConnectionContent(state.value, communicationReady.value, socketConnected.value, |
|||
onScan = { scanned++ }, onConnect = { ap, password -> connected = ap to password }, |
|||
onCancel = { cancelled++ }, onDisconnect = { disconnected++ }, onRetry = { retried++ }, |
|||
onCurrent = { current++ }, onPermissions = {}, onSettings = { settingsAction = it }, |
|||
onDismiss = { dismissed = true }, endpoint = endpoint.value, connectedEndpoint = connectedEndpoint.value, |
|||
canEditEndpoint = endpointEditable.value, |
|||
onEndpointChange = { savedEndpoint = it; endpoint.value = it; true }) |
|||
} } |
|||
} |
|||
@Test fun fixedEndpointAndPasswordFlowReplaceP2pAndManualTcpInputs() { |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap-ssid").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap-connect").assertDoesNotExist() |
|||
compose.onNodeWithText("Wi-Fi Direct").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:manual-ip").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:manual-port").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() |
|||
compose.onNodeWithTag("wifi:ap-connect").assertIsNotEnabled() |
|||
compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678") |
|||
compose.onNodeWithTag("wifi:ap-connect").assertIsEnabled().performClick() |
|||
compose.runOnIdle { assertEquals(device to "12345678", connected) } |
|||
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() |
|||
} |
|||
@Test fun closingTheSheetDoesNotCancelTheApLeaseAndPendingConnectionCanBeCancelled() { |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = device.ssid) |
|||
show() |
|||
compose.onNodeWithText("연결 취소").assertIsDisplayed().performClick() |
|||
compose.runOnIdle { assertEquals(1, cancelled) } |
|||
compose.onNodeWithText("닫기").assertIsDisplayed().performClick() |
|||
compose.runOnIdle { assertTrue(dismissed); assertEquals(1, cancelled) } |
|||
} |
|||
@Test fun permissionDenialDisablesApRequestButLeavesSettingsAvailable() { |
|||
state.value = state.value.copy(permissionGranted = false) |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertIsNotEnabled() |
|||
compose.onNodeWithContentDescription("새로고침").assertIsNotEnabled() |
|||
compose.onNodeWithText("권한 허용").assertIsDisplayed().assertIsEnabled() |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("네트워크 추가").assertIsNotEnabled() |
|||
compose.onNodeWithText("Wi-Fi 설정").assertIsEnabled().performClick() |
|||
compose.runOnIdle { assertNull(connected) } |
|||
} |
|||
|
|||
@Test fun cancellingPasswordPromptForgetsCredentialsWithoutConnecting() { |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() |
|||
compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678") |
|||
compose.onNodeWithText("취소").performClick() |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() |
|||
assertEquals("", compose.onNodeWithTag("wifi:ap-password").fetchSemanticsNode() |
|||
.config[SemanticsProperties.EditableText].text) |
|||
compose.runOnIdle { assertNull(connected); assertEquals(0, cancelled) } |
|||
} |
|||
|
|||
@Test fun advancedInputsAndFixedEndpointAreOnlyInMenuAndDetails() { |
|||
show() |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("네트워크 추가").performClick() |
|||
compose.onNodeWithTag("wifi:ap-ssid").performTextInput("HIDDEN_DCT") |
|||
compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678") |
|||
compose.onNodeWithTag("wifi:ap-connect").performClick() |
|||
compose.runOnIdle { assertEquals("HIDDEN_DCT", connected!!.first.ssid) } |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("연결 정보").performClick() |
|||
compose.onNodeWithTag("wifi:ap-endpoint").assertTextContains("192.168.4.1:5000", substring = true) |
|||
compose.onNodeWithText("확인").performClick() |
|||
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() |
|||
} |
|||
|
|||
@Test fun connectedRowOpensDetailsForRetryAndDisconnect() { |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, |
|||
selectedSsid = device.ssid, localAddress = "192.168.4.20") |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertDoesNotExist() |
|||
compose.onNodeWithText("연결 해제").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap-current").performClick() |
|||
compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed() |
|||
compose.onNodeWithText("통신 재연결").performClick() |
|||
compose.runOnIdle { assertEquals(1, retried); assertEquals(0, disconnected) } |
|||
compose.onNodeWithTag("wifi:ap-current").performClick() |
|||
compose.onNodeWithText("연결 해제").performClick() |
|||
compose.runOnIdle { assertEquals(1, disconnected) } |
|||
} |
|||
|
|||
@Test fun alreadyConnectedSystemWifiIsVisibleBeforeTcpAndIsNotRepeatedInScanResults() { |
|||
state.value = state.value.copy(connectedNetworks = listOf( |
|||
ConnectedWifiNetwork("101", device.ssid, "192.168.1.20"))) |
|||
show() |
|||
compose.onNodeWithTag("wifi:connected:101").assertIsDisplayed() |
|||
compose.onNodeWithText("Wi-Fi 연결됨").assertIsDisplayed() |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap-current").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:connected:101").performClick() |
|||
compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed() |
|||
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() |
|||
compose.onNodeWithText("연결 해제").assertDoesNotExist() |
|||
compose.onNodeWithText("확인").performClick() |
|||
compose.runOnIdle { assertEquals(0, current); assertEquals(0, retried); assertNull(connected) } |
|||
} |
|||
|
|||
@Test fun managedNetworkIsShownOnlyOnceAndOtherConnectedWifiRemainsVisible() { |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = device.ssid, |
|||
localAddress = "192.168.4.20", activeNetworkKey = "101", connectedNetworks = listOf( |
|||
ConnectedWifiNetwork("101", device.ssid, "192.168.4.20"), |
|||
ConnectedWifiNetwork("102", "Office", "192.168.1.20"))) |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed() |
|||
compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:connected:102").assertIsDisplayed() |
|||
compose.onNodeWithText("통신 연결 대기").assertIsDisplayed() |
|||
compose.onNodeWithTag("wifi:ap-current").performClick() |
|||
compose.onNodeWithText("통신 재연결").assertIsDisplayed() |
|||
} |
|||
|
|||
@Test fun listFooterStartsCommunicationAndDetailsNoLongerContainsTheConnectButton() { |
|||
endpoint.value = WifiApEndpoint("192.168.1.100", 6000) |
|||
state.value = state.value.copy(connectedNetworks = listOf( |
|||
ConnectedWifiNetwork("101", device.ssid, "192.168.1.20"))) |
|||
show() |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled() |
|||
compose.onNodeWithTag("wifi:connected:101").performClick() |
|||
compose.onNode(hasText("접속 설정: 192.168.1.100:6000") and |
|||
hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertIsDisplayed() |
|||
compose.onAllNodes(hasTestTag("wifi:connected-connect") and |
|||
hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertCountEquals(0) |
|||
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() |
|||
compose.runOnIdle { assertEquals(0, current); assertNull(connected) } |
|||
compose.onNodeWithText("확인").performClick() |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled().performClick() |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed() |
|||
compose.runOnIdle { |
|||
assertEquals(1, current); assertEquals(0, retried); assertEquals(0, disconnected) |
|||
assertFalse(dismissed) |
|||
assertNull(connected); assertEquals(WifiApEndpoint("192.168.1.100", 6000), endpoint.value) |
|||
} |
|||
} |
|||
|
|||
@Test fun communicationShortcutIsDisabledForUnavailableAmbiguousOrBusyConnections() { |
|||
val network = ConnectedWifiNetwork("101", device.ssid, "192.168.1.20") |
|||
val base = state.value.copy(connectedNetworks = listOf(network)) |
|||
state.value = base |
|||
show() |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled() |
|||
listOf(base.copy(permissionGranted = false), base.copy(enabled = false), |
|||
base.copy(phase = WifiApPhase.CONNECTING), |
|||
base.copy(connectedNetworks = emptyList()), |
|||
base.copy(connectedNetworks = listOf(network.copy(localAddress = null))), |
|||
base.copy(connectedNetworks = listOf(network, ConnectedWifiNetwork("102", "Other", "192.168.2.20")))) |
|||
.forEach { unavailable -> |
|||
compose.runOnIdle { state.value = unavailable } |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() |
|||
} |
|||
compose.runOnIdle { state.value = base; endpointEditable.value = false } |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() |
|||
compose.runOnIdle { endpointEditable.value = true; communicationReady.value = true } |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() |
|||
compose.runOnIdle { communicationReady.value = false; socketConnected.value = true } |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() |
|||
compose.runOnIdle { socketConnected.value = false; endpoint.value = WifiApEndpoint("0.0.0.0", 6000) } |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() |
|||
compose.runOnIdle { endpoint.value = WifiApEndpoint() } |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled() |
|||
compose.runOnIdle { assertEquals(0, current); assertNull(connected) } |
|||
} |
|||
|
|||
@Test fun wifiOnlyRowShowsConfiguredIpAndPortWithoutClaimingTcpConnection() { |
|||
endpoint.value = WifiApEndpoint("192.168.1.100", 6000) |
|||
state.value = state.value.copy(connectedNetworks = listOf( |
|||
ConnectedWifiNetwork("101", "Office", "192.168.1.20"))) |
|||
show() |
|||
compose.onNodeWithTag("wifi:connected:101:endpoint", useUnmergedTree = true) |
|||
.assertIsDisplayed().assertTextEquals("접속 설정: 192.168.1.100:6000") |
|||
compose.runOnIdle { endpoint.value = WifiApEndpoint("192.168.4.1", 5000) } |
|||
compose.onNodeWithTag("wifi:connected:101:endpoint", useUnmergedTree = true).assertTextEquals("접속 설정: 192.168.4.1:5000") |
|||
compose.onNodeWithText("Wi-Fi 연결됨").assertIsDisplayed() |
|||
compose.runOnIdle { assertEquals(0, current); assertNull(connected) } |
|||
} |
|||
|
|||
@Test fun connectedRowKeepsTheActualPeerWhenNextConnectionSettingsChange() { |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = device.ssid, |
|||
localAddress = "192.168.1.20", activeNetworkKey = "101") |
|||
endpoint.value = WifiApEndpoint("192.168.1.100", 7000) |
|||
connectedEndpoint.value = WifiApEndpoint("192.168.1.100", 6000) |
|||
socketConnected.value = true |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true) |
|||
.assertIsDisplayed().assertTextEquals("통신 주소: 192.168.1.100:6000") |
|||
compose.runOnIdle { |
|||
communicationReady.value = true |
|||
endpoint.value = WifiApEndpoint("192.168.4.1", 5000) |
|||
} |
|||
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("통신 주소: 192.168.1.100:6000") |
|||
compose.runOnIdle { connectedEndpoint.value = null } |
|||
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("통신 주소 확인 중…") |
|||
compose.runOnIdle { socketConnected.value = false; communicationReady.value = false } |
|||
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("접속 설정: 192.168.4.1:5000") |
|||
} |
|||
|
|||
@Test fun unknownSystemWifiIsDisplayedWithoutScanResultsAndDisappearsWhenLost() { |
|||
state.value = state.value.copy(permissionGranted = false, devices = emptyList(), |
|||
connectedNetworks = listOf(ConnectedWifiNetwork("101", localAddress = "192.168.1.20"))) |
|||
show() |
|||
compose.onNodeWithTag("wifi:connected:101").assertIsDisplayed().performClick() |
|||
compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed() |
|||
compose.runOnIdle { state.value = state.value.copy(connectedNetworks = emptyList()) } |
|||
compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist() |
|||
compose.onNodeWithText("IP 주소: 192.168.1.20").assertDoesNotExist() |
|||
compose.runOnIdle { assertEquals(0, current); assertEquals(0, disconnected) } |
|||
} |
|||
|
|||
@Test fun openNetworkHasNoPasswordAndUnsupportedSecurityUsesSettings() { |
|||
state.value = state.value.copy(devices = listOf(device.copy(security = WifiApSecurity.OPEN))) |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() |
|||
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap-connect").assertIsEnabled().performClick() |
|||
compose.runOnIdle { |
|||
assertEquals("", connected!!.second) |
|||
state.value = state.value.copy(devices = listOf(device.copy(security = WifiApSecurity.UNSUPPORTED))) |
|||
} |
|||
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick() |
|||
compose.onNodeWithTag("wifi:ap-connect").assertDoesNotExist() |
|||
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() |
|||
compose.onNodeWithText("Wi-Fi 설정").performClick() |
|||
compose.runOnIdle { assertEquals(android.provider.Settings.ACTION_WIFI_SETTINGS, settingsAction) } |
|||
} |
|||
|
|||
@Test fun longNetworkListScrollsWithoutHidingHeaderOrCloseButton() { |
|||
state.value = state.value.copy(devices = (1..30).map { device.copy(ssid = "DCT_$it") }, |
|||
connectedNetworks = listOf(ConnectedWifiNetwork("101", "Office", "192.168.1.20"))) |
|||
show() |
|||
compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31) |
|||
compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed() |
|||
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled() |
|||
val connectBounds = compose.onNodeWithTag("wifi:connected-connect").fetchSemanticsNode().boundsInRoot |
|||
val closeBounds = compose.onNodeWithText("닫기").fetchSemanticsNode().boundsInRoot |
|||
assertTrue(closeBounds.left >= connectBounds.right) |
|||
assertEquals(connectBounds.center.y, closeBounds.center.y, 1f) |
|||
compose.onNodeWithContentDescription("새로고침").assertIsDisplayed().performClick() |
|||
compose.onNodeWithText("닫기").assertIsDisplayed().performClick() |
|||
compose.runOnIdle { assertEquals(1, scanned); assertTrue(dismissed) } |
|||
} |
|||
|
|||
@Test fun alreadyConnectedWifiActionRemainsAvailableInOverflow() { |
|||
show() |
|||
compose.onNodeWithText("현재 Wi-Fi로 연결").assertDoesNotExist() |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("현재 Wi-Fi로 연결").assertIsEnabled().performClick() |
|||
compose.runOnIdle { assertEquals(1, current) } |
|||
} |
|||
|
|||
@Test fun tcpSettingsValidateAndSavePcAddressWithoutConnectingThenRestoreDefault() { |
|||
show() |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("IP / 포트 설정").performClick() |
|||
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") |
|||
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("65536") |
|||
compose.onNodeWithTag("wifi:tcp-save").assertIsNotEnabled() |
|||
compose.onNodeWithTag("wifi:tcp-connect").assertIsNotEnabled() |
|||
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000") |
|||
compose.onNodeWithTag("wifi:tcp-save").performClick() |
|||
compose.runOnIdle { |
|||
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint) |
|||
assertEquals(0, current); assertEquals(0, retried) |
|||
} |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("연결 정보").performClick() |
|||
compose.onNodeWithTag("wifi:ap-endpoint").assertTextContains("192.168.1.100:6000", substring = true) |
|||
compose.onNodeWithText("IP / 포트 설정").performClick() |
|||
compose.onNodeWithText("장비 기본값").performClick() |
|||
compose.onNodeWithTag("wifi:tcp-save").performClick() |
|||
compose.runOnIdle { assertEquals(WifiApEndpoint(), savedEndpoint) } |
|||
} |
|||
|
|||
@Test fun savingAndConnectingUsesNewTargetForCurrentWifiAndConnectedAp() { |
|||
show() |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("IP / 포트 설정").performClick() |
|||
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") |
|||
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000") |
|||
compose.onNodeWithTag("wifi:tcp-connect").performClick() |
|||
compose.runOnIdle { |
|||
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint) |
|||
assertEquals(1, current) |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, |
|||
selectedSsid = "PC LAN", localAddress = "192.168.1.20") |
|||
} |
|||
compose.onNodeWithTag("wifi:ap-current").performClick() |
|||
compose.onNodeWithText("IP / 포트 설정").performClick() |
|||
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("7000") |
|||
compose.onNodeWithTag("wifi:tcp-connect").performClick() |
|||
compose.runOnIdle { |
|||
assertEquals(7000, savedEndpoint!!.port) |
|||
assertEquals(1, retried); assertEquals(1, current) |
|||
} |
|||
} |
|||
|
|||
@Test fun tcpSettingsCancelAndMeasurementGuardPreserveStoredTarget() { |
|||
show() |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("IP / 포트 설정").performClick() |
|||
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") |
|||
compose.onNodeWithText("취소").performClick() |
|||
compose.runOnIdle { |
|||
assertNull(savedEndpoint); assertEquals(WifiApEndpoint(), endpoint.value) |
|||
endpointEditable.value = false |
|||
} |
|||
compose.onNodeWithContentDescription("더보기").performClick() |
|||
compose.onNodeWithText("IP / 포트 설정").assertIsNotEnabled() |
|||
} |
|||
} |
|||
@ -0,0 +1,353 @@ |
|||
package com.dct.app.data.communication |
|||
|
|||
import android.annotation.SuppressLint |
|||
import android.content.BroadcastReceiver |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.IntentFilter |
|||
import android.content.pm.PackageManager |
|||
import android.location.LocationManager |
|||
import android.net.ConnectivityManager |
|||
import android.net.LinkProperties |
|||
import android.net.MacAddress |
|||
import android.net.Network |
|||
import android.net.NetworkCapabilities |
|||
import android.net.NetworkRequest |
|||
import android.net.wifi.WifiManager |
|||
import android.net.wifi.WifiInfo |
|||
import android.net.wifi.WifiNetworkSpecifier |
|||
import android.os.Build |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import androidx.core.content.ContextCompat |
|||
import androidx.annotation.RequiresApi |
|||
import androidx.core.location.LocationManagerCompat |
|||
import kotlinx.coroutines.flow.MutableStateFlow |
|||
import kotlinx.coroutines.flow.asStateFlow |
|||
import java.io.IOException |
|||
|
|||
/** Application-owned AP lease. Closing the dialog must not release the measurement network. */ |
|||
class WifiApManager(context: Context, |
|||
private val onReady: (String) -> Unit, |
|||
private val onLost: () -> Unit, |
|||
private val sdk: Int = Build.VERSION.SDK_INT, |
|||
private val onConnecting: () -> Unit = {}, |
|||
private val endpointProvider: () -> WifiApEndpoint = { WifiApEndpoint() } |
|||
) : AutoCloseable { |
|||
private val context = context.applicationContext |
|||
private val wifi = this.context.getSystemService(WifiManager::class.java) |
|||
private val connectivity = this.context.getSystemService(ConnectivityManager::class.java) |
|||
private val handler = Handler(Looper.getMainLooper()) |
|||
private val state = MutableStateFlow(WifiApUiState()) |
|||
val uiState = state.asStateFlow() |
|||
private var registered = false |
|||
private var closed = false |
|||
private var generation = 0L |
|||
private var callback: ConnectivityManager.NetworkCallback? = null |
|||
private var deadline: Runnable? = null |
|||
private var scanDeadline: Runnable? = null |
|||
private var route: WifiApRoute? = null |
|||
private var availableNetwork: Network? = null |
|||
private var observer: ConnectivityManager.NetworkCallback? = null |
|||
private val connectedNetworks = linkedMapOf<Network, ConnectedWifiNetwork>() |
|||
private val receiver = object : BroadcastReceiver() { |
|||
override fun onReceive(context: Context?, intent: Intent?) { |
|||
if (intent?.action == WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) { |
|||
synchronized(this@WifiApManager) { |
|||
scanDeadline?.let(handler::removeCallbacks); scanDeadline = null |
|||
readScanResults() |
|||
} |
|||
} else refresh() |
|||
} |
|||
} |
|||
|
|||
@Synchronized fun start() { |
|||
if (closed || registered) return |
|||
try { |
|||
ContextCompat.registerReceiver(context, receiver, IntentFilter().apply { |
|||
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) |
|||
addAction(WifiManager.WIFI_STATE_CHANGED_ACTION) |
|||
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION) |
|||
addAction(LocationManager.MODE_CHANGED_ACTION) |
|||
}, ContextCompat.RECEIVER_EXPORTED) |
|||
registered = true |
|||
refresh() |
|||
observeConnectedNetworks() |
|||
} catch (_: Exception) { fail("Wi-Fi AP 상태 감시를 시작할 수 없습니다.") } |
|||
} |
|||
|
|||
@Synchronized fun refresh() { |
|||
if (closed) return |
|||
val permissions = DctWifiAp.runtimePermissions(sdk).all { |
|||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED |
|||
} |
|||
val enabled = wifi?.isWifiEnabled == true |
|||
val location = context.getSystemService(LocationManager::class.java)?.let(LocationManagerCompat::isLocationEnabled) == true |
|||
state.value = state.value.copy(enabled = enabled, permissionGranted = permissions, locationEnabled = location) |
|||
refreshConnectedNetworks() |
|||
if ((!enabled || !permissions) && route != null) fail("Wi-Fi가 꺼졌거나 연결 권한이 해제되었습니다.") |
|||
} |
|||
|
|||
private fun wifiCapabilities(capabilities: NetworkCapabilities?): Boolean = |
|||
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true && |
|||
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) |
|||
|
|||
private fun ssid(raw: String?): String? = raw?.removeSurrounding("\"") |
|||
?.takeIf { it.isNotBlank() && it != WifiManager.UNKNOWN_SSID } |
|||
|
|||
private fun canReadSsid(): Boolean = state.value.permissionGranted && state.value.locationEnabled |
|||
|
|||
private fun networkSsid(capabilities: NetworkCapabilities?): String? = |
|||
if (sdk >= 29 && canReadSsid()) ssid((capabilities?.transportInfo as? WifiInfo)?.ssid) else null |
|||
|
|||
private fun localAddress(properties: LinkProperties?): String? = properties?.linkAddresses |
|||
?.firstOrNull { DctWifiAp.isUsableWifiAddress(it.address) }?.address?.hostAddress |
|||
|
|||
private fun publishConnectedNetworks() { |
|||
state.value = state.value.copy(connectedNetworks = connectedNetworks.values.toList()) |
|||
} |
|||
|
|||
/** Snapshot on open/resume; callbacks below use their payloads rather than racing synchronous queries. */ |
|||
@SuppressLint("MissingPermission") |
|||
@Suppress("DEPRECATION") |
|||
private fun refreshConnectedNetworks() { |
|||
if (!state.value.enabled) { |
|||
connectedNetworks.clear(); publishConnectedNetworks(); return |
|||
} |
|||
try { |
|||
val networks = connectivity?.allNetworks.orEmpty().mapNotNull { network -> |
|||
val capabilities = connectivity?.getNetworkCapabilities(network) |
|||
if (wifiCapabilities(capabilities)) network to capabilities else null |
|||
}.distinctBy { it.first } |
|||
// connectionInfo refers to the primary Wi-Fi: use it only when there is no ambiguous second Wi-Fi. |
|||
val primarySsid = if (networks.size == 1 && canReadSsid()) ssid(wifi?.connectionInfo?.ssid) else null |
|||
val snapshot = networks.associate { (network, capabilities) -> |
|||
val name = if (canReadSsid()) networkSsid(capabilities) ?: primarySsid ?: connectedNetworks[network]?.ssid else null |
|||
network to ConnectedWifiNetwork(network.toString(), name, localAddress(connectivity?.getLinkProperties(network))) |
|||
} |
|||
connectedNetworks.clear(); connectedNetworks.putAll(snapshot); publishConnectedNetworks() |
|||
} catch (_: SecurityException) { |
|||
connectedNetworks.clear(); publishConnectedNetworks() |
|||
state.value = state.value.copy(error = "현재 Wi-Fi 정보를 읽을 수 없습니다. 앱 권한을 확인하세요.") |
|||
} |
|||
} |
|||
|
|||
private fun observeConnectedNetworks() { |
|||
if (closed || observer != null || connectivity == null) return |
|||
val listener = if (sdk >= 31) ConnectedWifiCallback(ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO) |
|||
else ConnectedWifiCallback() |
|||
try { |
|||
connectivity.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities() |
|||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(), listener) |
|||
observer = listener |
|||
} catch (_: Exception) { |
|||
state.value = state.value.copy(error = "현재 Wi-Fi 상태 감시를 시작할 수 없습니다. Wi-Fi 설정에서 확인하세요.") |
|||
} |
|||
} |
|||
|
|||
private inner class ConnectedWifiCallback : ConnectivityManager.NetworkCallback { |
|||
constructor() : super() |
|||
@RequiresApi(31) constructor(flags: Int) : super(flags) |
|||
|
|||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { |
|||
synchronized(this@WifiApManager) { |
|||
if (closed || !state.value.enabled) return |
|||
if (wifiCapabilities(capabilities)) { |
|||
val previous = connectedNetworks[network] |
|||
val name = if (sdk < 29 && canReadSsid()) previous?.ssid else networkSsid(capabilities) |
|||
connectedNetworks[network] = ConnectedWifiNetwork(network.toString(), name, previous?.localAddress) |
|||
} else connectedNetworks.remove(network) |
|||
publishConnectedNetworks() |
|||
} |
|||
} |
|||
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { |
|||
synchronized(this@WifiApManager) { |
|||
if (closed) return |
|||
val previous = connectedNetworks[network] ?: return |
|||
connectedNetworks[network] = previous.copy(localAddress = localAddress(properties)) |
|||
publishConnectedNetworks() |
|||
} |
|||
} |
|||
override fun onLost(network: Network) { |
|||
synchronized(this@WifiApManager) { |
|||
if (closed) return |
|||
connectedNetworks.remove(network); publishConnectedNetworks() |
|||
} |
|||
} |
|||
} |
|||
|
|||
@SuppressLint("MissingPermission") |
|||
@Synchronized fun scan() { |
|||
refresh() |
|||
if (!state.value.canScan || closed) return |
|||
readScanResults() // Cached results remain usable when Android throttles a scan. |
|||
try { |
|||
if (wifi?.startScan() != true) { |
|||
state.value = state.value.copy(error = "검색이 제한되었습니다. 기존 목록을 확인하거나 잠시 후 다시 검색하세요.") |
|||
return |
|||
} |
|||
state.value = state.value.copy(phase = if (route == null) WifiApPhase.SCANNING else WifiApPhase.CONNECTED, error = null) |
|||
scanDeadline?.let(handler::removeCallbacks) |
|||
scanDeadline = Runnable { synchronized(this) { |
|||
readScanResults() |
|||
state.value = state.value.copy(error = "검색 완료 알림이 지연되었습니다. 목록이 없으면 Wi-Fi 설정에서 확인하세요.") |
|||
} }.also { handler.postDelayed(it, 15_000) } |
|||
} catch (_: SecurityException) { state.value = state.value.copy(error = "AP 검색에 정확한 위치 권한이 필요합니다.") } |
|||
} |
|||
|
|||
@SuppressLint("MissingPermission") |
|||
private fun readScanResults() { |
|||
if (closed || !state.value.permissionGranted) return |
|||
try { |
|||
@Suppress("DEPRECATION") |
|||
val devices = wifi?.scanResults.orEmpty().filter { it.SSID.isNotBlank() }.map { |
|||
WifiApDevice(it.SSID, it.BSSID, it.level, WifiApSecurity.fromCapabilities(it.capabilities)) |
|||
}.sortedByDescending { it.level }.distinctBy { it.ssid to it.security } |
|||
state.value = state.value.copy(devices = devices, |
|||
phase = if (state.value.phase == WifiApPhase.SCANNING) WifiApPhase.IDLE else state.value.phase) |
|||
} catch (_: SecurityException) { state.value = state.value.copy(error = "AP 검색 권한을 확인하세요.") } |
|||
} |
|||
|
|||
@SuppressLint("MissingPermission") |
|||
@Synchronized fun connect(device: WifiApDevice, password: String) { |
|||
refresh() |
|||
if (closed || !state.value.canScan) return |
|||
if (device.ssid.isBlank() || !device.security.acceptsPassword(password)) { |
|||
state.value = state.value.copy(error = "AP 보안 방식과 비밀번호를 확인하세요. WPA2는 8~63자리입니다.") |
|||
return |
|||
} |
|||
if (sdk < 29) { |
|||
state.value = state.value.copy(error = "Android 10 미만은 Wi-Fi 설정에서 AP에 연결한 뒤 현재 AP로 통신 연결을 누르세요.") |
|||
return |
|||
} |
|||
onConnecting() // Invalidate old transport/cleanup before a new AP request owns the lease. |
|||
val replacing = route != null |
|||
release() |
|||
if (replacing) onLost() |
|||
val current = generation |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = device.ssid, localAddress = null, activeNetworkKey = null, error = null) |
|||
try { |
|||
val specifier = WifiNetworkSpecifier.Builder().setSsid(device.ssid) |
|||
if (device.bssid.isNotBlank()) specifier.setBssid(MacAddress.fromString(device.bssid)) |
|||
when (device.security) { |
|||
WifiApSecurity.WPA2 -> specifier.setWpa2Passphrase(password) |
|||
WifiApSecurity.WPA3 -> specifier.setWpa3Passphrase(password) |
|||
else -> Unit |
|||
} |
|||
val request = NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI) |
|||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) |
|||
.setNetworkSpecifier(specifier.build()).build() |
|||
val listener = listener(current, device.ssid) |
|||
callback = listener |
|||
connectivity?.requestNetwork(request, listener) ?: throw IOException("Wi-Fi 연결 서비스를 사용할 수 없습니다.") |
|||
// Never persist or log the password. Android owns credentials/approval for this request. |
|||
armDeadline(current) |
|||
} catch (_: Exception) { fail("AP 연결 요청을 시작할 수 없습니다. 권한/보안 방식과 Wi-Fi 상태를 확인하세요.") } |
|||
} |
|||
|
|||
/** Fallback for Settings-connected APs; never use the default cellular/VPN network. */ |
|||
@SuppressLint("MissingPermission") |
|||
@Synchronized fun connectCurrent() { |
|||
refresh() |
|||
if (closed || !state.value.permissionGranted || !state.value.enabled || state.value.busy) return |
|||
val candidates = connectivity?.allNetworks.orEmpty().filter { |
|||
connectivity?.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true && |
|||
connectivity?.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != true && |
|||
connectivity?.getLinkProperties(it)?.linkAddresses?.any { address -> DctWifiAp.isUsableWifiAddress(address.address) } == true |
|||
} |
|||
if (candidates.size != 1) { |
|||
state.value = state.value.copy(error = "Wi-Fi에 먼저 연결하세요. 사용할 IPv4 Wi-Fi 경로가 하나여야 합니다.") |
|||
return |
|||
} |
|||
val network = candidates.single() |
|||
val name = connectedNetworks[network]?.displayName ?: "현재 Wi-Fi" |
|||
if (route?.network == network) { onReady(route!!.ssid); return } |
|||
onConnecting() |
|||
val replacing = route != null |
|||
release() |
|||
if (replacing) onLost() |
|||
val current = generation |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = name, localAddress = null, activeNetworkKey = null, error = null) |
|||
val listener = listener(current, name, network) |
|||
try { |
|||
callback = listener |
|||
connectivity!!.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities() |
|||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(), listener) |
|||
availableNetwork = network |
|||
updateRoute(current, network, connectivity.getLinkProperties(network), name) |
|||
if (route == null) armDeadline(current) |
|||
} catch (_: Exception) { fail("현재 Wi-Fi AP 경로를 사용할 수 없습니다.") } |
|||
} |
|||
|
|||
private fun listener(current: Long, ssid: String, expected: Network? = null) = object : ConnectivityManager.NetworkCallback() { |
|||
override fun onAvailable(network: Network) { synchronized(this@WifiApManager) { |
|||
if (current != generation || closed || (expected != null && expected != network)) return |
|||
availableNetwork = network // Wait for onLinkPropertiesChanged; do not query synchronously here. |
|||
} } |
|||
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { synchronized(this@WifiApManager) { |
|||
if (current != generation || closed || (expected != null && expected != network)) return |
|||
availableNetwork = network |
|||
updateRoute(current, network, properties, ssid) |
|||
} } |
|||
override fun onLost(network: Network) { synchronized(this@WifiApManager) { |
|||
if (current == generation && network == availableNetwork) fail("장비 AP 연결이 해제되었습니다.") |
|||
} } |
|||
override fun onUnavailable() { synchronized(this@WifiApManager) { |
|||
if (current == generation) fail("AP 연결이 취소되었거나 실패했습니다. 비밀번호와 장비 상태를 확인하세요.") |
|||
} } |
|||
} |
|||
|
|||
private fun updateRoute(current: Long, network: Network, properties: LinkProperties?, ssid: String) { |
|||
if (current != generation || closed) return |
|||
val local = properties?.linkAddresses?.firstOrNull { DctWifiAp.isUsableWifiAddress(it.address) }?.address |
|||
if (local == null) { |
|||
if (route != null) fail("Wi-Fi IPv4 연결 경로가 사라졌습니다.") |
|||
return |
|||
} |
|||
val next = WifiApRoute(network, local, ssid, endpointProvider()) |
|||
// Saving a destination applies on the next explicit TCP connection, not on DHCP refresh. |
|||
if (route?.network == network && route?.localAddress == local) return |
|||
if (route != null) onLost() |
|||
route = next |
|||
deadline?.let(handler::removeCallbacks); deadline = null |
|||
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = ssid, localAddress = local.hostAddress, |
|||
activeNetworkKey = network.toString(), error = null) |
|||
onReady(ssid) |
|||
} |
|||
|
|||
private fun armDeadline(current: Long) { |
|||
deadline = Runnable { synchronized(this) { |
|||
if (current == generation && route == null) fail("AP 연결 또는 Wi-Fi IPv4 할당 시간이 초과되었습니다.") |
|||
} }.also { handler.postDelayed(it, 60_000) } |
|||
} |
|||
|
|||
@Synchronized fun currentRoute(): WifiApRoute = route?.copy(endpoint = endpointProvider()) |
|||
?: throw IOException("Wi-Fi 연결/IP 할당이 준비되지 않았습니다.") |
|||
@Synchronized fun disconnect() { |
|||
release() |
|||
state.value = state.value.copy(phase = WifiApPhase.IDLE, localAddress = null, selectedSsid = null, activeNetworkKey = null, error = null) |
|||
} |
|||
private fun fail(message: String) { |
|||
val wasActive = route != null || state.value.busy |
|||
release() |
|||
state.value = state.value.copy(phase = WifiApPhase.ERROR, localAddress = null, activeNetworkKey = null, error = message) |
|||
if (wasActive) onLost() |
|||
} |
|||
private fun release() { |
|||
generation++ |
|||
deadline?.let(handler::removeCallbacks); deadline = null |
|||
scanDeadline?.let(handler::removeCallbacks); scanDeadline = null |
|||
route = null; availableNetwork = null |
|||
callback?.let { try { connectivity?.unregisterNetworkCallback(it) } catch (_: IllegalArgumentException) { } } |
|||
callback = null |
|||
} |
|||
@Synchronized override fun close() { |
|||
if (closed) return |
|||
closed = true |
|||
release() |
|||
observer?.let { try { connectivity?.unregisterNetworkCallback(it) } catch (_: IllegalArgumentException) { } } |
|||
observer = null |
|||
connectedNetworks.clear(); publishConnectedNetworks() |
|||
if (registered) { context.unregisterReceiver(receiver); registered = false } |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
package com.dct.app.data.communication |
|||
|
|||
import android.Manifest |
|||
import android.net.Network |
|||
import java.io.IOException |
|||
import java.net.Inet4Address |
|||
import java.net.InetAddress |
|||
|
|||
/** ESP32 equipment is an access point, not a Wi-Fi Direct peer. */ |
|||
object DctWifiAp { |
|||
const val HOST = "192.168.4.1" |
|||
const val PORT = 5000 |
|||
fun runtimePermissions(sdk: Int): Array<String> = buildList { |
|||
// Scanning still needs precise location on Android 13+, independently of Nearby Wi-Fi. |
|||
add(Manifest.permission.ACCESS_COARSE_LOCATION) |
|||
add(Manifest.permission.ACCESS_FINE_LOCATION) |
|||
if (sdk >= 33) add(Manifest.permission.NEARBY_WIFI_DEVICES) |
|||
}.toTypedArray() |
|||
|
|||
fun isEquipmentSubnet(address: InetAddress): Boolean = address is Inet4Address && |
|||
address.address.take(3).map { it.toInt() and 255 } == listOf(192, 168, 4) && |
|||
(address.address[3].toInt() and 255) in 2..254 |
|||
|
|||
fun isUsableWifiAddress(address: InetAddress): Boolean = address is Inet4Address && |
|||
(address.address[0].toInt() and 255) in 1..223 && !address.isLoopbackAddress |
|||
} |
|||
|
|||
/** Configured TCP destination; the equipment profile remains the first-use default. */ |
|||
data class WifiApEndpoint(val host: String = DctWifiAp.HOST, val port: Int = DctWifiAp.PORT) { |
|||
val isValid: Boolean get() = WifiConnectionInput(host, port.toString()).canConnect && |
|||
host.substringBefore('.').toIntOrNull()?.let { it in 1..223 && it != 127 } == true |
|||
fun asInput() = WifiConnectionInput(host, port.toString()) |
|||
companion object { |
|||
fun fromInput(input: WifiConnectionInput): WifiApEndpoint? { |
|||
if (!input.canConnect) return null |
|||
val host = input.ip.trim().split('.').joinToString(".") { it.toInt().toString() } |
|||
return WifiApEndpoint(host, input.portNumber!!).takeIf { it.isValid } |
|||
} |
|||
} |
|||
} |
|||
|
|||
enum class WifiApSecurity { |
|||
OPEN, WPA2, WPA3, UNSUPPORTED; |
|||
companion object { |
|||
fun fromCapabilities(capabilities: String): WifiApSecurity = when { |
|||
capabilities.contains("EAP") || capabilities.contains("WEP") || capabilities.contains("OWE") -> UNSUPPORTED |
|||
capabilities.contains("PSK") -> WPA2 // WPA2/WPA3 transition APs accept WPA2. |
|||
capabilities.contains("SAE") -> WPA3 |
|||
else -> OPEN |
|||
} |
|||
} |
|||
fun acceptsPassword(password: String): Boolean = when (this) { |
|||
OPEN -> password.isEmpty() |
|||
WPA2 -> password.length in 8..63 && password.all { it.code in 32..126 } |
|||
WPA3 -> password.length in 1..63 && password.all { it.code in 32..126 } |
|||
UNSUPPORTED -> false |
|||
} |
|||
} |
|||
|
|||
data class WifiApDevice(val ssid: String, val bssid: String, val level: Int, val security: WifiApSecurity) |
|||
/** OS Wi-Fi connectivity is display-only; it does not imply a prepared DCT transport. */ |
|||
data class ConnectedWifiNetwork(val key: String, val ssid: String? = null, val localAddress: String? = null) { |
|||
val displayName: String get() = ssid ?: "연결된 Wi-Fi" |
|||
} |
|||
enum class WifiApPhase { IDLE, SCANNING, CONNECTING, CONNECTED, ERROR } |
|||
data class WifiApUiState( |
|||
val phase: WifiApPhase = WifiApPhase.IDLE, |
|||
val enabled: Boolean = false, |
|||
val permissionGranted: Boolean = false, |
|||
val locationEnabled: Boolean = false, |
|||
val devices: List<WifiApDevice> = emptyList(), |
|||
val connectedNetworks: List<ConnectedWifiNetwork> = emptyList(), |
|||
val activeNetworkKey: String? = null, |
|||
val selectedSsid: String? = null, |
|||
val localAddress: String? = null, |
|||
val error: String? = null |
|||
) { |
|||
val networkReady: Boolean get() = phase == WifiApPhase.CONNECTED && localAddress != null |
|||
val busy: Boolean get() = phase == WifiApPhase.CONNECTING |
|||
val canScan: Boolean get() = enabled && permissionGranted && locationEnabled && !busy |
|||
} |
|||
|
|||
data class WifiApRoute(val network: Network, val localAddress: InetAddress, val ssid: String, |
|||
val endpoint: WifiApEndpoint = WifiApEndpoint()) { |
|||
fun requireTarget(host: String, port: Int) { |
|||
if (!endpoint.isValid || host != endpoint.host || port != endpoint.port || !DctWifiAp.isUsableWifiAddress(localAddress)) |
|||
throw IOException("설정된 TCP 주소 또는 Wi-Fi 연결 경로가 올바르지 않습니다 (${endpoint.host}:${endpoint.port}).") |
|||
} |
|||
fun samePath(other: WifiApRoute): Boolean = network == other.network && localAddress == other.localAddress && endpoint == other.endpoint |
|||
} |
|||
@ -0,0 +1,400 @@ |
|||
package com.dct.app.presentation.dialog |
|||
|
|||
import android.content.Intent |
|||
import android.net.Uri |
|||
import android.os.Build |
|||
import android.provider.Settings |
|||
import androidx.activity.compose.rememberLauncherForActivityResult |
|||
import androidx.activity.result.contract.ActivityResultContracts |
|||
import androidx.compose.foundation.clickable |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.lazy.LazyColumn |
|||
import androidx.compose.foundation.lazy.items |
|||
import androidx.compose.foundation.rememberScrollState |
|||
import androidx.compose.foundation.verticalScroll |
|||
import androidx.compose.foundation.text.KeyboardOptions |
|||
import androidx.compose.material.icons.Icons |
|||
import androidx.compose.material.icons.filled.Lock |
|||
import androidx.compose.material.icons.filled.MoreVert |
|||
import androidx.compose.material.icons.filled.Refresh |
|||
import androidx.compose.material.icons.filled.Visibility |
|||
import androidx.compose.material.icons.filled.VisibilityOff |
|||
import androidx.compose.material.icons.filled.Wifi |
|||
import androidx.compose.material3.* |
|||
import androidx.compose.runtime.* |
|||
import androidx.compose.ui.Modifier |
|||
import androidx.compose.ui.platform.LocalContext |
|||
import androidx.compose.ui.platform.testTag |
|||
import androidx.compose.ui.text.input.PasswordVisualTransformation |
|||
import androidx.compose.ui.text.input.KeyboardType |
|||
import androidx.compose.ui.unit.dp |
|||
import androidx.compose.ui.window.Dialog |
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|||
import com.dct.app.data.communication.* |
|||
import com.dct.app.presentation.main.MainViewModel |
|||
|
|||
@Composable |
|||
fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { |
|||
val context = LocalContext.current |
|||
val state by viewModel.wifiApState.collectAsStateWithLifecycle() |
|||
val ui by viewModel.uiState.collectAsStateWithLifecycle() |
|||
val socket by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle() |
|||
val endpoint by viewModel.wifiApEndpoint.collectAsStateWithLifecycle() |
|||
val connectedEndpoint by viewModel.wifiSocketManager.connectedEndpoint.collectAsStateWithLifecycle() |
|||
var requested by remember { mutableStateOf(false) } |
|||
var searchOnReady by remember { mutableStateOf(true) } |
|||
var settingsError by remember { mutableStateOf<String?>(null) } |
|||
val permissions = remember { DctWifiAp.runtimePermissions(Build.VERSION.SDK_INT) } |
|||
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current |
|||
DisposableEffect(lifecycleOwner) { |
|||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> |
|||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) viewModel.refreshWifiAp() |
|||
} |
|||
lifecycleOwner.lifecycle.addObserver(observer) |
|||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } |
|||
} |
|||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { |
|||
viewModel.refreshWifiAp(); searchOnReady = it.values.all { granted -> granted } |
|||
} |
|||
fun settings(action: String) { |
|||
try { context.startActivity(Intent(action).apply { |
|||
if (action == Settings.ACTION_APPLICATION_DETAILS_SETTINGS) data = Uri.parse("package:${context.packageName}") |
|||
}) } catch (_: android.content.ActivityNotFoundException) { settingsError = "설정 화면을 열 수 없습니다." } |
|||
} |
|||
LaunchedEffect(Unit) { viewModel.refreshWifiAp() } |
|||
LaunchedEffect(state.permissionGranted) { |
|||
if (!state.permissionGranted && !requested) { requested = true; launcher.launch(permissions) } |
|||
} |
|||
LaunchedEffect(searchOnReady, state.canScan) { |
|||
if (searchOnReady && state.canScan) { searchOnReady = false; viewModel.scanWifiAp() } |
|||
} |
|||
// No onDispose disconnect: the application, not this sheet, owns the AP lease. |
|||
Dialog(onDismissRequest = onDismiss) { |
|||
WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket, |
|||
onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp, |
|||
onCancel = viewModel::cancelWifiApConnection, onDisconnect = { viewModel.disconnectCommunication() }, |
|||
onRetry = viewModel::retryWifiApTcp, onCurrent = viewModel::connectCurrentWifiAp, |
|||
onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss, |
|||
endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint, |
|||
canEditEndpoint = !ui.isMeasuring && ui.recordingState !in setOf( |
|||
com.dct.app.presentation.main.MeasurementRecordingState.STARTING, |
|||
com.dct.app.presentation.main.MeasurementRecordingState.RECORDING, |
|||
com.dct.app.presentation.main.MeasurementRecordingState.STOPPING) && !state.busy) |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
internal fun WifiApConnectionContent( |
|||
state: WifiApUiState, communicationReady: Boolean, socketConnected: Boolean, |
|||
onScan: () -> Unit, onConnect: (WifiApDevice, String) -> Unit, onCancel: () -> Unit, |
|||
onDisconnect: () -> Unit, onRetry: () -> Unit, onCurrent: () -> Unit, |
|||
onPermissions: () -> Unit, onSettings: (String) -> Unit, onDismiss: () -> Unit, |
|||
endpoint: WifiApEndpoint = WifiApEndpoint(), canEditEndpoint: Boolean = true, |
|||
onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null |
|||
) { |
|||
var selected by remember { mutableStateOf<WifiApDevice?>(null) } |
|||
var addingNetwork by remember { mutableStateOf(false) } |
|||
var menuOpen by remember { mutableStateOf(false) } |
|||
var showDetails by remember { mutableStateOf(false) } |
|||
var connectedDetailsKey by remember { mutableStateOf<String?>(null) } |
|||
var editEndpoint by remember { mutableStateOf(false) } |
|||
val endpointEditable = canEditEndpoint && !state.busy |
|||
// This shortcut uses the existing single-IPv4 Wi-Fi path, never an arbitrary second network. |
|||
val canConnectCurrent = endpointEditable && endpoint.isValid && state.enabled && state.permissionGranted && |
|||
!communicationReady && !socketConnected && state.connectedNetworks.count { it.localAddress != null } == 1 |
|||
val connectedStatus = when { |
|||
communicationReady -> "연결됨" |
|||
socketConnected -> "장비 초기화 중…" |
|||
else -> "통신 연결 대기" |
|||
} |
|||
val configuredAddress = "접속 설정: ${endpoint.host}:${endpoint.port}" |
|||
val currentAddress = if (!state.busy && (socketConnected || communicationReady)) connectedEndpoint?.let { |
|||
"통신 주소: ${it.host}:${it.port}" |
|||
} ?: "통신 주소 확인 중…" else configuredAddress |
|||
Surface(shape = MaterialTheme.shapes.extraLarge, |
|||
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth()) { |
|||
Column(Modifier.heightIn(max = 520.dp)) { |
|||
Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 8.dp, top = 12.dp), |
|||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { |
|||
Text("Wi-Fi", Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall) |
|||
IconButton(onClick = onScan, enabled = state.canScan) { |
|||
Icon(Icons.Default.Refresh, contentDescription = "새로고침") |
|||
} |
|||
Box { |
|||
IconButton(onClick = { menuOpen = true }) { |
|||
Icon(Icons.Default.MoreVert, contentDescription = "더보기") |
|||
} |
|||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { |
|||
DropdownMenuItem(text = { Text("네트워크 추가") }, enabled = state.canScan, |
|||
onClick = { menuOpen = false; addingNetwork = true; selected = null }) |
|||
DropdownMenuItem(text = { Text("현재 Wi-Fi로 연결") }, |
|||
enabled = state.enabled && state.permissionGranted && !state.busy, |
|||
onClick = { menuOpen = false; onCurrent() }) |
|||
DropdownMenuItem(text = { Text("IP / 포트 설정") }, enabled = endpointEditable, |
|||
onClick = { menuOpen = false; editEndpoint = true }) |
|||
DropdownMenuItem(text = { Text("Wi-Fi 설정") }, |
|||
onClick = { menuOpen = false; onSettings(Settings.ACTION_WIFI_SETTINGS) }) |
|||
if (!state.permissionGranted) DropdownMenuItem(text = { Text("앱 설정") }, |
|||
onClick = { menuOpen = false; onSettings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) }) |
|||
DropdownMenuItem(text = { Text("연결 정보") }, |
|||
onClick = { menuOpen = false; showDetails = true }) |
|||
} |
|||
} |
|||
} |
|||
if (state.phase == WifiApPhase.SCANNING || state.busy) |
|||
LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) |
|||
val blocked = when { |
|||
!state.enabled -> "Wi-Fi를 켜 주세요." |
|||
!state.permissionGranted -> "주변 Wi-Fi 검색 권한이 필요합니다." |
|||
!state.locationEnabled -> "Wi-Fi 검색을 위해 위치 서비스를 켜 주세요." |
|||
else -> null |
|||
} |
|||
if (blocked != null) { |
|||
Text(blocked, Modifier.padding(horizontal = 24.dp, vertical = 12.dp), |
|||
style = MaterialTheme.typography.bodyMedium) |
|||
TextButton(onClick = { |
|||
when { |
|||
!state.enabled -> onSettings(Settings.ACTION_WIFI_SETTINGS) |
|||
!state.permissionGranted -> onPermissions() |
|||
else -> onSettings(Settings.ACTION_LOCATION_SOURCE_SETTINGS) |
|||
} |
|||
}, modifier = Modifier.padding(horizontal = 16.dp)) { |
|||
Text(when { |
|||
!state.enabled -> "Wi-Fi 설정" |
|||
!state.permissionGranted -> "권한 허용" |
|||
else -> "위치 설정" |
|||
}) |
|||
} |
|||
} |
|||
state.error?.let { |
|||
Text(it, Modifier.padding(horizontal = 24.dp, vertical = 8.dp), |
|||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) |
|||
} |
|||
LazyColumn(Modifier.fillMaxWidth().weight(1f, fill = false).heightIn(max = 320.dp) |
|||
.testTag("wifi:ap-list")) { |
|||
if (state.networkReady || state.busy) item(key = "current") { |
|||
WifiApNetworkRow(state.selectedSsid ?: "DCT Wi-Fi", |
|||
status = if (state.busy) "연결 중…" else connectedStatus, |
|||
address = currentAddress, |
|||
secured = false, enabled = state.networkReady, |
|||
tag = "wifi:ap-current", onClick = { showDetails = true }) |
|||
} |
|||
items(state.connectedNetworks.filterNot { it.key == state.activeNetworkKey }, |
|||
key = { "connected:${it.key}" }) { network -> |
|||
WifiApNetworkRow(network.displayName, status = "Wi-Fi 연결됨", |
|||
address = configuredAddress, |
|||
secured = false, enabled = true, tag = "wifi:connected:${network.key}", |
|||
onClick = { connectedDetailsKey = network.key }) |
|||
} |
|||
item(key = "available-title") { |
|||
Text("사용 가능한 네트워크", |
|||
Modifier.padding(horizontal = 24.dp, vertical = 12.dp), |
|||
style = MaterialTheme.typography.labelMedium, |
|||
color = MaterialTheme.colorScheme.onSurfaceVariant) |
|||
} |
|||
val connectedSsids = state.connectedNetworks.mapNotNull { it.ssid }.toSet() |
|||
val available = state.devices.filterNot { |
|||
it.ssid in connectedSsids || (state.networkReady && it.ssid == state.selectedSsid) |
|||
} |
|||
if (available.isEmpty()) item(key = "empty") { |
|||
Text(if (state.phase == WifiApPhase.SCANNING) "검색 중…" else "네트워크를 찾지 못했습니다.", |
|||
Modifier.padding(horizontal = 24.dp, vertical = 16.dp), |
|||
style = MaterialTheme.typography.bodyMedium, |
|||
color = MaterialTheme.colorScheme.onSurfaceVariant) |
|||
} |
|||
items(available, key = { "${it.ssid}:${it.security.name}" }) { device -> |
|||
WifiApNetworkRow(device.ssid, secured = device.security != WifiApSecurity.OPEN, |
|||
enabled = state.canScan, tag = "wifi:ap:${device.ssid}", |
|||
onClick = { selected = device; addingNetwork = false }) |
|||
} |
|||
} |
|||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), |
|||
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)) { |
|||
if (state.busy) TextButton(onClick = onCancel) { Text("연결 취소") } |
|||
Button(enabled = canConnectCurrent, onClick = onCurrent, |
|||
modifier = Modifier.testTag("wifi:connected-connect")) { Text("통신 연결") } |
|||
TextButton(onClick = onDismiss) { Text("닫기") } |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Credentials exist only while the selected network's prompt is open. |
|||
if (selected != null || addingNetwork) { |
|||
val device = selected |
|||
var ssid by remember(device, addingNetwork) { mutableStateOf(device?.ssid.orEmpty()) } |
|||
var password by remember(device, addingNetwork) { mutableStateOf("") } |
|||
var passwordVisible by remember(device, addingNetwork) { mutableStateOf(false) } |
|||
val security = device?.security ?: WifiApSecurity.WPA2 |
|||
val supported = security != WifiApSecurity.UNSUPPORTED |
|||
fun closePrompt() { password = ""; selected = null; addingNetwork = false } |
|||
AlertDialog( |
|||
onDismissRequest = { closePrompt() }, |
|||
title = { Text(device?.ssid ?: "네트워크 추가") }, |
|||
text = { |
|||
Column(Modifier.verticalScroll(rememberScrollState()), |
|||
verticalArrangement = Arrangement.spacedBy(12.dp)) { |
|||
if (addingNetwork) OutlinedTextField(ssid, onValueChange = { ssid = it }, |
|||
label = { Text("네트워크 이름") }, singleLine = true, |
|||
modifier = Modifier.fillMaxWidth().testTag("wifi:ap-ssid")) |
|||
when { |
|||
!supported -> Text("이 네트워크는 Wi-Fi 설정에서 연결해 주세요.") |
|||
security == WifiApSecurity.OPEN -> Text("이 네트워크에 연결하시겠습니까?") |
|||
else -> { |
|||
OutlinedTextField(password, onValueChange = { password = it }, |
|||
label = { Text("비밀번호") }, singleLine = true, |
|||
visualTransformation = if (passwordVisible) |
|||
androidx.compose.ui.text.input.VisualTransformation.None else PasswordVisualTransformation(), |
|||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), |
|||
trailingIcon = { |
|||
IconButton(onClick = { passwordVisible = !passwordVisible }) { |
|||
Icon(if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, |
|||
contentDescription = if (passwordVisible) "비밀번호 숨기기" else "비밀번호 보기") |
|||
} |
|||
}, modifier = Modifier.fillMaxWidth().testTag("wifi:ap-password")) |
|||
if (addingNetwork) Text("보안: WPA2", |
|||
style = MaterialTheme.typography.bodySmall, |
|||
color = MaterialTheme.colorScheme.onSurfaceVariant) |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
dismissButton = { TextButton(onClick = { closePrompt() }) { Text("취소") } }, |
|||
confirmButton = { |
|||
if (supported) TextButton( |
|||
enabled = state.canScan && ssid.isNotBlank() && security.acceptsPassword(password), |
|||
onClick = { |
|||
onConnect(device ?: WifiApDevice(ssid, "", 0, WifiApSecurity.WPA2), password) |
|||
closePrompt() |
|||
}, modifier = Modifier.testTag("wifi:ap-connect")) { Text("연결") } |
|||
else TextButton(onClick = { |
|||
closePrompt(); onSettings(Settings.ACTION_WIFI_SETTINGS) |
|||
}) { Text("Wi-Fi 설정") } |
|||
}) |
|||
} |
|||
state.connectedNetworks.firstOrNull { it.key == connectedDetailsKey }?.let { network -> |
|||
AlertDialog(onDismissRequest = { connectedDetailsKey = null }, |
|||
modifier = Modifier.testTag("wifi:connected-details"), |
|||
title = { Text(network.displayName) }, |
|||
text = { |
|||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|||
Text("Wi-Fi 연결됨") |
|||
network.localAddress?.let { Text("IP 주소: $it") } |
|||
Text("접속 설정: ${endpoint.host}:${endpoint.port}") |
|||
} |
|||
}, |
|||
confirmButton = { |
|||
Row { |
|||
TextButton(enabled = endpointEditable, onClick = { |
|||
connectedDetailsKey = null; editEndpoint = true |
|||
}) { Text("IP / 포트 설정") } |
|||
TextButton(onClick = { connectedDetailsKey = null }) { Text("확인") } |
|||
} |
|||
}) |
|||
} |
|||
if (showDetails) AlertDialog( |
|||
onDismissRequest = { showDetails = false }, |
|||
title = { Text(state.selectedSsid ?: "연결 정보") }, |
|||
text = { |
|||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|||
if (state.networkReady) { |
|||
Text(connectedStatus) |
|||
Text("IP 주소: ${state.localAddress}") |
|||
} |
|||
Text("접속 설정: ${endpoint.host}:${endpoint.port}", |
|||
Modifier.testTag("wifi:ap-endpoint")) |
|||
} |
|||
}, |
|||
dismissButton = { |
|||
if (state.networkReady) TextButton(onClick = { showDetails = false; onDisconnect() }) { |
|||
Text("연결 해제") |
|||
} |
|||
}, |
|||
confirmButton = { |
|||
Row { |
|||
TextButton(enabled = endpointEditable, onClick = { |
|||
showDetails = false; editEndpoint = true |
|||
}) { Text("IP / 포트 설정") } |
|||
if (state.networkReady && !communicationReady) TextButton(onClick = { |
|||
showDetails = false; onRetry() |
|||
}) { Text("통신 재연결") } |
|||
TextButton(onClick = { showDetails = false }) { Text("확인") } |
|||
} |
|||
}) |
|||
|
|||
if (editEndpoint) { |
|||
var ip by remember { mutableStateOf(endpoint.host) } |
|||
var port by remember { mutableStateOf(endpoint.port.toString()) } |
|||
var saveError by remember { mutableStateOf(false) } |
|||
val target = WifiApEndpoint.fromInput(WifiConnectionInput(ip, port)) |
|||
fun applyTarget(connectNow: Boolean) { |
|||
if (target == null || !endpointEditable || !onEndpointChange(target)) { |
|||
saveError = true |
|||
return |
|||
} |
|||
editEndpoint = false |
|||
if (connectNow) { |
|||
if (state.networkReady) onRetry() else onCurrent() |
|||
} |
|||
} |
|||
AlertDialog(onDismissRequest = { editEndpoint = false }, |
|||
title = { Text("IP / 포트 설정") }, |
|||
text = { |
|||
Column(Modifier.verticalScroll(rememberScrollState()), |
|||
verticalArrangement = Arrangement.spacedBy(12.dp)) { |
|||
Text("PC와 같은 Wi-Fi에서 PC의 IPv4 주소를 입력하세요. 테스트 서버 기본 포트는 6000입니다.", |
|||
style = MaterialTheme.typography.bodySmall) |
|||
OutlinedTextField(ip, onValueChange = { ip = it; saveError = false }, |
|||
label = { Text("IP 주소") }, singleLine = true, |
|||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), |
|||
modifier = Modifier.fillMaxWidth().testTag("wifi:tcp-ip")) |
|||
OutlinedTextField(port, onValueChange = { port = it; saveError = false }, |
|||
label = { Text("포트") }, singleLine = true, |
|||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), |
|||
modifier = Modifier.fillMaxWidth().testTag("wifi:tcp-port")) |
|||
if (target == null) Text("올바른 IPv4 주소와 1~65535 포트를 입력하세요.", |
|||
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) |
|||
if (saveError) Text("연결 요청·계측·기록을 정지한 후 변경하세요.", |
|||
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) |
|||
Text("저장은 다음 연결부터 적용됩니다.", style = MaterialTheme.typography.bodySmall) |
|||
TextButton(onClick = { ip = DctWifiAp.HOST; port = DctWifiAp.PORT.toString(); saveError = false }) { |
|||
Text("장비 기본값") |
|||
} |
|||
} |
|||
}, |
|||
dismissButton = { TextButton(onClick = { editEndpoint = false }) { Text("취소") } }, |
|||
confirmButton = { |
|||
Row { |
|||
TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget(false) }, |
|||
modifier = Modifier.testTag("wifi:tcp-save")) { Text("저장") } |
|||
TextButton(enabled = target != null && endpointEditable && state.enabled && |
|||
state.permissionGranted && !state.busy, onClick = { applyTarget(true) }, |
|||
modifier = Modifier.testTag("wifi:tcp-connect")) { Text("저장 후 연결") } |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
private fun WifiApNetworkRow( |
|||
ssid: String, status: String? = null, secured: Boolean, enabled: Boolean, |
|||
tag: String, onClick: () -> Unit, address: String? = null |
|||
) { |
|||
Row(Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick) |
|||
.padding(horizontal = 24.dp, vertical = 14.dp).testTag(tag), |
|||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, |
|||
horizontalArrangement = Arrangement.spacedBy(16.dp)) { |
|||
val tint = if (enabled) MaterialTheme.colorScheme.onSurface |
|||
else MaterialTheme.colorScheme.onSurfaceVariant |
|||
Icon(Icons.Default.Wifi, contentDescription = null, tint = tint) |
|||
Column(Modifier.weight(1f)) { |
|||
Text(ssid, style = MaterialTheme.typography.bodyLarge, color = tint, |
|||
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) |
|||
status?.let { Text(it, style = MaterialTheme.typography.bodySmall, |
|||
color = MaterialTheme.colorScheme.primary) } |
|||
address?.let { Text(it, style = MaterialTheme.typography.bodySmall, |
|||
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("$tag:endpoint")) } |
|||
} |
|||
if (secured) Icon(Icons.Default.Lock, contentDescription = "보안 네트워크", |
|||
modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) |
|||
} |
|||
} |
|||
@ -0,0 +1,287 @@ |
|||
package com.dct.app.tests |
|||
|
|||
import android.content.Context |
|||
import android.content.pm.PackageManager |
|||
import android.location.LocationManager |
|||
import android.net.* |
|||
import android.net.wifi.* |
|||
import android.os.Handler |
|||
import androidx.core.content.ContextCompat |
|||
import androidx.core.location.LocationManagerCompat |
|||
import com.dct.app.data.communication.* |
|||
import io.mockk.* |
|||
import org.junit.jupiter.api.* |
|||
import org.junit.jupiter.api.Assertions.* |
|||
import java.net.InetAddress |
|||
|
|||
class WifiApManagerTest { |
|||
private val context = mockk<Context>(relaxed = true) |
|||
private val wifi = mockk<WifiManager>(relaxed = true) |
|||
private val connectivity = mockk<ConnectivityManager>(relaxed = true) |
|||
private val location = mockk<LocationManager>() |
|||
private val callbacks = mutableListOf<ConnectivityManager.NetworkCallback>() |
|||
private val timeouts = mutableListOf<Runnable>() |
|||
private var ready = 0; private var lost = 0 |
|||
private lateinit var manager: WifiApManager |
|||
private val device = WifiApDevice("DCT", "", -40, WifiApSecurity.WPA2) |
|||
|
|||
@BeforeEach fun setup() { |
|||
mockkStatic(ContextCompat::class, LocationManagerCompat::class) |
|||
every { context.applicationContext } returns context |
|||
every { context.getSystemService(WifiManager::class.java) } returns wifi |
|||
every { context.getSystemService(ConnectivityManager::class.java) } returns connectivity |
|||
every { context.getSystemService(LocationManager::class.java) } returns location |
|||
every { wifi.isWifiEnabled } returns true |
|||
every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_GRANTED |
|||
every { ContextCompat.registerReceiver(context, any(), any(), ContextCompat.RECEIVER_EXPORTED) } returns null |
|||
every { LocationManagerCompat.isLocationEnabled(location) } returns true |
|||
mockkConstructor(Handler::class, WifiNetworkSpecifier.Builder::class, NetworkRequest.Builder::class) |
|||
every { anyConstructed<Handler>().postDelayed(any(), any()) } answers { timeouts += firstArg<Runnable>(); true } |
|||
every { anyConstructed<Handler>().removeCallbacks(any()) } just Runs |
|||
every { anyConstructed<WifiNetworkSpecifier.Builder>().setSsid(any()) } answers { self as WifiNetworkSpecifier.Builder } |
|||
every { anyConstructed<WifiNetworkSpecifier.Builder>().setWpa2Passphrase(any()) } answers { self as WifiNetworkSpecifier.Builder } |
|||
every { anyConstructed<WifiNetworkSpecifier.Builder>().build() } returns mockk() |
|||
every { anyConstructed<NetworkRequest.Builder>().addTransportType(any()) } answers { self as NetworkRequest.Builder } |
|||
every { anyConstructed<NetworkRequest.Builder>().clearCapabilities() } answers { self as NetworkRequest.Builder } |
|||
every { anyConstructed<NetworkRequest.Builder>().removeCapability(any()) } answers { self as NetworkRequest.Builder } |
|||
every { anyConstructed<NetworkRequest.Builder>().setNetworkSpecifier(any<NetworkSpecifier>()) } answers { self as NetworkRequest.Builder } |
|||
every { anyConstructed<NetworkRequest.Builder>().build() } returns mockk() |
|||
every { connectivity.requestNetwork(any(), any<ConnectivityManager.NetworkCallback>()) } answers { callbacks += secondArg<ConnectivityManager.NetworkCallback>() } |
|||
every { connectivity.registerNetworkCallback(any<NetworkRequest>(), any<ConnectivityManager.NetworkCallback>()) } answers { callbacks += secondArg<ConnectivityManager.NetworkCallback>() } |
|||
manager = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34) |
|||
} |
|||
@AfterEach fun teardown() { manager.close(); unmockkAll() } |
|||
private fun properties(address: String): LinkProperties { |
|||
val link = mockk<LinkAddress>(); every { link.address } returns InetAddress.getByName(address) |
|||
val properties = mockk<LinkProperties>(); every { properties.linkAddresses } returns listOf(link) |
|||
return properties |
|||
} |
|||
@Test fun tcpStartsOnlyAfterEquipmentIpAndCancelIgnoresLateCallbacks() { |
|||
manager.connect(device, "12345678") |
|||
val callback = callbacks.single(); val network = mockk<Network>() |
|||
callback.onAvailable(network) |
|||
assertEquals(0, ready) |
|||
callback.onLinkPropertiesChanged(network, properties("::1")) |
|||
assertEquals(0, ready) |
|||
callback.onLinkPropertiesChanged(network, properties("192.168.4.20")) |
|||
assertEquals(1, ready); assertTrue(manager.uiState.value.networkReady) |
|||
manager.disconnect() |
|||
callback.onLinkPropertiesChanged(network, properties("192.168.4.20")) |
|||
callback.onUnavailable(); callback.onLost(network) |
|||
assertEquals(1, ready); assertEquals(0, lost) |
|||
assertEquals(WifiApPhase.IDLE, manager.uiState.value.phase) |
|||
verify(exactly = 1) { connectivity.unregisterNetworkCallback(callback) } |
|||
} |
|||
@Test fun wrongSubnetTimeoutAndDeniedPasswordCannotStartTcp() { |
|||
manager.connect(device, "bad") |
|||
assertTrue(callbacks.isEmpty()) |
|||
manager.connect(device, "12345678") |
|||
val callback = callbacks.single(); val network = mockk<Network>() |
|||
callback.onLinkPropertiesChanged(network, properties("127.0.0.1")) |
|||
timeouts.last().run() |
|||
assertEquals(WifiApPhase.ERROR, manager.uiState.value.phase) |
|||
assertEquals(0, ready); assertEquals(1, lost) |
|||
callback.onLinkPropertiesChanged(network, properties("192.168.4.20")) |
|||
assertEquals(0, ready) |
|||
} |
|||
@Test fun unavailableAndLostReleaseOnlyTheCurrentNetwork() { |
|||
manager.connect(device, "12345678") |
|||
val stale = callbacks.single() |
|||
manager.disconnect(); manager.connect(device, "12345678") |
|||
val current = callbacks.last(); val network = mockk<Network>() |
|||
stale.onUnavailable(); stale.onLinkPropertiesChanged(network, properties("192.168.4.30")) |
|||
assertEquals(0, ready); assertEquals(WifiApPhase.CONNECTING, manager.uiState.value.phase) |
|||
current.onAvailable(network); current.onLinkPropertiesChanged(network, properties("192.168.4.20")) |
|||
current.onLost(mockk()) // An unrelated Network must not kill this session. |
|||
assertTrue(manager.uiState.value.networkReady) |
|||
current.onLost(network) |
|||
assertEquals(1, lost); assertFalse(manager.uiState.value.networkReady) |
|||
current.onUnavailable(); assertEquals(1, lost) |
|||
} |
|||
@Test fun losingTheEquipmentSubnetEndsTheLease() { |
|||
manager.connect(device, "12345678") |
|||
val current = callbacks.single(); val network = mockk<Network>() |
|||
current.onLinkPropertiesChanged(network, properties("192.168.4.20")) |
|||
current.onLinkPropertiesChanged(network, properties("::1")) |
|||
assertEquals(1, ready); assertEquals(1, lost) |
|||
assertFalse(manager.uiState.value.networkReady) |
|||
} |
|||
@Test fun permissionDenialNeverRequestsAnAp() { |
|||
every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_DENIED |
|||
manager.connect(device, "12345678") |
|||
assertFalse(manager.uiState.value.permissionGranted) |
|||
assertTrue(callbacks.isEmpty()); assertEquals(0, ready) |
|||
} |
|||
@Test fun newLeaseInvalidatesOldTransportBeforeDhcpAndRepeatedPropertiesDoNotReconnect() { |
|||
val events = mutableListOf<String>() |
|||
val owner = WifiApManager(context, { events += "ready" }, { events += "lost" }, sdk = 34, |
|||
onConnecting = { events += "prepare" }) |
|||
try { |
|||
owner.connect(device, "12345678") |
|||
assertEquals(listOf("prepare"), events) |
|||
val first = callbacks.last(); val network = mockk<Network>() |
|||
first.onLinkPropertiesChanged(network, properties("192.168.4.20")) |
|||
first.onLinkPropertiesChanged(network, properties("192.168.4.20")) |
|||
assertEquals(listOf("prepare", "ready"), events) |
|||
owner.connect(device.copy(ssid = "DCT_2"), "12345678") |
|||
assertEquals(listOf("prepare", "ready", "prepare", "lost"), events) |
|||
first.onLost(network) |
|||
assertEquals(WifiApPhase.CONNECTING, owner.uiState.value.phase) |
|||
} finally { owner.close() } |
|||
} |
|||
@Test fun oldAndroidUsesSettingsFallbackWithoutRequestingUnsupportedApi() { |
|||
val old = WifiApManager(context, { ready++ }, { lost++ }, sdk = 28) |
|||
try { |
|||
old.connect(device, "12345678") |
|||
assertTrue(callbacks.isEmpty()) |
|||
assertTrue(old.uiState.value.error!!.contains("Android 10 미만")) |
|||
} finally { old.close() } |
|||
} |
|||
@Test fun currentNetworkFallbackRejectsCellularAndAmbiguousWifiPaths() { |
|||
val cellular = mockk<Network>(); val wifiNetwork = mockk<Network>() |
|||
val cellularCaps = mockk<NetworkCapabilities>(); val wifiCaps = mockk<NetworkCapabilities>() |
|||
every { cellularCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false |
|||
every { wifiCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true |
|||
every { wifiCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false |
|||
every { wifiCaps.transportInfo } returns null |
|||
every { connectivity.getNetworkCapabilities(cellular) } returns cellularCaps |
|||
every { connectivity.getNetworkCapabilities(wifiNetwork) } returns wifiCaps |
|||
every { connectivity.allNetworks } returns arrayOf(cellular) |
|||
manager.connectCurrent() |
|||
assertEquals(0, ready) |
|||
every { connectivity.getLinkProperties(wifiNetwork) } returns properties("192.168.1.20") |
|||
every { connectivity.allNetworks } returns arrayOf(cellular, wifiNetwork) |
|||
manager.connectCurrent() |
|||
assertEquals(1, ready) |
|||
assertTrue(manager.uiState.value.networkReady) |
|||
assertEquals("192.168.1.20", manager.currentRoute().localAddress.hostAddress) |
|||
manager.disconnect() |
|||
every { connectivity.getLinkProperties(wifiNetwork) } returns properties("192.168.4.20") |
|||
every { connectivity.allNetworks } returns arrayOf(wifiNetwork, wifiNetwork) |
|||
manager.connectCurrent() |
|||
assertEquals(1, ready) |
|||
assertFalse(manager.uiState.value.networkReady) |
|||
} |
|||
|
|||
@Test fun configuredPcTargetUsesLanAndSavingDoesNotReconnectOnDhcpRefresh() { |
|||
var endpoint = WifiApEndpoint("192.168.1.100", 6000) |
|||
val owner = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34, |
|||
endpointProvider = { endpoint }) |
|||
try { |
|||
owner.connect(device, "12345678") |
|||
val callback = callbacks.last(); val network = mockk<Network>() |
|||
callback.onLinkPropertiesChanged(network, properties("192.168.1.20")) |
|||
val original = owner.currentRoute() |
|||
original.requireTarget("192.168.1.100", 6000) |
|||
endpoint = endpoint.copy(port = 7000) |
|||
callback.onLinkPropertiesChanged(network, properties("192.168.1.20")) |
|||
assertEquals(1, ready); assertEquals(0, lost) |
|||
assertFalse(original.samePath(owner.currentRoute())) |
|||
owner.currentRoute().requireTarget("192.168.1.100", 7000) |
|||
} finally { owner.close() } |
|||
} |
|||
|
|||
@Test fun currentNetworkCannotAdoptWifiBackedVpnOrIpv6OnlyWifi() { |
|||
val network = mockk<Network>(); val capabilities = mockk<NetworkCapabilities>() |
|||
every { connectivity.allNetworks } returns arrayOf(network) |
|||
every { connectivity.getNetworkCapabilities(network) } returns capabilities |
|||
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true |
|||
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true |
|||
every { capabilities.transportInfo } returns null |
|||
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") |
|||
manager.connectCurrent() |
|||
assertEquals(0, ready) |
|||
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false |
|||
every { connectivity.getLinkProperties(network) } returns properties("::1") |
|||
manager.connectCurrent() |
|||
assertEquals(0, ready) |
|||
assertFalse(manager.uiState.value.networkReady) |
|||
} |
|||
|
|||
private fun wifiCapabilities(name: String?): NetworkCapabilities { |
|||
val info = mockk<WifiInfo>(); every { info.ssid } returns name |
|||
val capabilities = mockk<NetworkCapabilities>() |
|||
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true |
|||
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false |
|||
every { capabilities.transportInfo } returns info |
|||
return capabilities |
|||
} |
|||
|
|||
@Test fun existingSystemWifiIsShownWithoutRequestingAnApOrStartingTcp() { |
|||
val network = mockk<Network>() |
|||
every { connectivity.allNetworks } returns arrayOf(network) |
|||
every { connectivity.getNetworkCapabilities(network) } returns wifiCapabilities("\"Office Wi-Fi\"") |
|||
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") |
|||
manager.start(); manager.start() |
|||
assertEquals(listOf(ConnectedWifiNetwork(network.toString(), "Office Wi-Fi", "192.168.1.20")), |
|||
manager.uiState.value.connectedNetworks) |
|||
assertEquals(0, ready); assertEquals(0, lost) |
|||
assertFalse(manager.uiState.value.networkReady) |
|||
assertEquals(WifiApPhase.IDLE, manager.uiState.value.phase) |
|||
verify(exactly = 0) { connectivity.requestNetwork(any(), any<ConnectivityManager.NetworkCallback>()) } |
|||
verify(exactly = 1) { connectivity.registerNetworkCallback(any<NetworkRequest>(), any<ConnectivityManager.NetworkCallback>()) } |
|||
} |
|||
|
|||
@Test fun systemWifiObserverUpdatesDhcpAndLossWithoutOwningTheTransportLease() { |
|||
manager.start() |
|||
val observer = callbacks.single(); val network = mockk<Network>() |
|||
observer.onCapabilitiesChanged(network, wifiCapabilities("\"DCT\"")) |
|||
observer.onLinkPropertiesChanged(network, properties("192.168.4.30")) |
|||
assertEquals("192.168.4.30", manager.uiState.value.connectedNetworks.single().localAddress) |
|||
verify(exactly = 0) { connectivity.getNetworkCapabilities(network) } |
|||
verify(exactly = 0) { connectivity.getLinkProperties(network) } |
|||
manager.disconnect() |
|||
assertEquals("DCT", manager.uiState.value.connectedNetworks.single().ssid) |
|||
verify(exactly = 0) { connectivity.unregisterNetworkCallback(observer) } |
|||
observer.onLost(network) |
|||
assertTrue(manager.uiState.value.connectedNetworks.isEmpty()) |
|||
assertEquals(0, ready); assertEquals(0, lost) |
|||
manager.close(); manager.close() |
|||
observer.onCapabilitiesChanged(network, wifiCapabilities("DCT")) |
|||
assertTrue(manager.uiState.value.connectedNetworks.isEmpty()) |
|||
verify(exactly = 1) { connectivity.unregisterNetworkCallback(observer) } |
|||
} |
|||
|
|||
@Test fun redactedWifiNamesAreNotGuessedAndPermissionRevocationClearsCachedNames() { |
|||
val network = mockk<Network>() |
|||
every { connectivity.allNetworks } returns arrayOf(network) |
|||
every { connectivity.getNetworkCapabilities(network) } returns wifiCapabilities(WifiManager.UNKNOWN_SSID) |
|||
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") |
|||
every { wifi.connectionInfo } returns null |
|||
manager.start() |
|||
assertNull(manager.uiState.value.connectedNetworks.single().ssid) |
|||
assertEquals("연결된 Wi-Fi", manager.uiState.value.connectedNetworks.single().displayName) |
|||
callbacks.single().onCapabilitiesChanged(network, wifiCapabilities("Office")) |
|||
manager.refresh() |
|||
assertEquals("Office", manager.uiState.value.connectedNetworks.single().ssid) |
|||
every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_DENIED |
|||
manager.refresh() |
|||
assertNull(manager.uiState.value.connectedNetworks.single().ssid) |
|||
callbacks.single().onCapabilitiesChanged(network, wifiCapabilities("Office")) |
|||
assertNull(manager.uiState.value.connectedNetworks.single().ssid) |
|||
assertEquals(0, ready) |
|||
} |
|||
|
|||
@Test fun passiveSnapshotExcludesCellularAndWifiBackedVpnAndSupportsOldAndroid() { |
|||
val network = mockk<Network>(); val vpn = mockk<Network>(); val cellular = mockk<Network>() |
|||
val caps = wifiCapabilities(null) |
|||
val vpnCaps = wifiCapabilities("VPN") |
|||
every { vpnCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true |
|||
val cellularCaps = mockk<NetworkCapabilities>() |
|||
every { cellularCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false |
|||
every { connectivity.allNetworks } returns arrayOf(vpn, cellular, network) |
|||
every { connectivity.getNetworkCapabilities(network) } returns caps |
|||
every { connectivity.getNetworkCapabilities(vpn) } returns vpnCaps |
|||
every { connectivity.getNetworkCapabilities(cellular) } returns cellularCaps |
|||
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20") |
|||
val info = mockk<WifiInfo>(); every { info.ssid } returns "\"Legacy AP\"" |
|||
every { wifi.connectionInfo } returns info |
|||
val old = WifiApManager(context, { ready++ }, { lost++ }, sdk = 28) |
|||
try { |
|||
old.start() |
|||
assertEquals("Legacy AP", old.uiState.value.connectedNetworks.single().ssid) |
|||
verify(exactly = 0) { caps.transportInfo } |
|||
assertEquals(0, ready); assertFalse(old.uiState.value.networkReady) |
|||
} finally { old.close() } |
|||
} |
|||
} |
|||
@ -0,0 +1,152 @@ |
|||
package com.dct.app.tests |
|||
|
|||
import android.Manifest |
|||
import android.net.Network |
|||
import com.dct.app.data.communication.* |
|||
import io.mockk.* |
|||
import kotlinx.coroutines.* |
|||
import org.junit.jupiter.api.Assertions.* |
|||
import org.junit.jupiter.api.Test |
|||
import java.io.ByteArrayOutputStream |
|||
import java.io.IOException |
|||
import java.net.InetAddress |
|||
import java.net.InetSocketAddress |
|||
import java.net.Socket |
|||
import java.net.SocketAddress |
|||
import java.util.concurrent.CountDownLatch |
|||
import java.util.concurrent.TimeUnit |
|||
import javax.net.SocketFactory |
|||
|
|||
class WifiApTest { |
|||
private fun ip(value: String) = InetAddress.getByName(value) |
|||
@Test fun fixedEndpointAndScanPermissionsMatchTheEquipmentContract() { |
|||
assertEquals("192.168.4.1", DctWifiAp.HOST); assertEquals(5000, DctWifiAp.PORT) |
|||
assertTrue(DctWifiAp.runtimePermissions(34).contains(Manifest.permission.ACCESS_FINE_LOCATION)) |
|||
assertTrue(DctWifiAp.runtimePermissions(34).contains(Manifest.permission.NEARBY_WIFI_DEVICES)) |
|||
assertFalse(DctWifiAp.runtimePermissions(32).contains(Manifest.permission.NEARBY_WIFI_DEVICES)) |
|||
listOf("192.168.4.2", "192.168.4.254").forEach { assertTrue(DctWifiAp.isEquipmentSubnet(ip(it))) } |
|||
listOf("192.168.4.0", "192.168.4.1", "192.168.4.255", "192.168.49.2", "10.0.0.2", "::1") |
|||
.forEach { assertFalse(DctWifiAp.isEquipmentSubnet(ip(it))) } |
|||
} |
|||
@Test fun securityAndPasswordsDoNotSilentlyDowngradeUnsupportedAps() { |
|||
assertEquals(WifiApSecurity.WPA2, WifiApSecurity.fromCapabilities("[WPA2-PSK-CCMP][SAE][ESS]")) |
|||
assertEquals(WifiApSecurity.WPA3, WifiApSecurity.fromCapabilities("[SAE][ESS]")) |
|||
assertEquals(WifiApSecurity.OPEN, WifiApSecurity.fromCapabilities("[ESS]")) |
|||
for (capabilities in listOf("[WEP]", "[EAP]", "[OWE]")) |
|||
assertEquals(WifiApSecurity.UNSUPPORTED, WifiApSecurity.fromCapabilities(capabilities)) |
|||
assertFalse(WifiApSecurity.WPA2.acceptsPassword("1234567")) |
|||
assertTrue(WifiApSecurity.WPA2.acceptsPassword("12345678")) |
|||
assertFalse(WifiApSecurity.WPA2.acceptsPassword("a".repeat(64))) |
|||
assertFalse(WifiApSecurity.WPA2.acceptsPassword("1234567\n")) |
|||
assertFalse(WifiApSecurity.UNSUPPORTED.acceptsPassword("password")) |
|||
} |
|||
@Test fun apRouteRejectsWrongPortsHostsAndSubnets() { |
|||
val route = WifiApRoute(mockk(), ip("192.168.4.20"), "DCT") |
|||
route.requireTarget(DctWifiAp.HOST, DctWifiAp.PORT) |
|||
assertThrows(IOException::class.java) { route.requireTarget(DctWifiAp.HOST, 6000) } |
|||
assertThrows(IOException::class.java) { route.requireTarget("192.168.49.1", 5000) } |
|||
assertThrows(IOException::class.java) { route.copy(localAddress = ip("0.0.0.0")).requireTarget(DctWifiAp.HOST, 5000) } |
|||
val pc = route.copy(localAddress = ip("192.168.1.20"), endpoint = WifiApEndpoint("192.168.1.100", 6000)) |
|||
pc.requireTarget("192.168.1.100", 6000) |
|||
assertThrows(IOException::class.java) { pc.requireTarget("192.168.1.100", 7000) } |
|||
} |
|||
|
|||
@Test fun editableEndpointValidatesNormalizesAndRejectsInvalidTargets() { |
|||
assertEquals(WifiApEndpoint("192.168.1.100", 6000), |
|||
WifiApEndpoint.fromInput(WifiConnectionInput(" 192.168.001.100 ", " 6000 "))) |
|||
for (host in listOf("", "pc.local", "256.1.2.3", "0.0.0.0", "127.0.0.1", "224.0.0.1", "255.255.255.255", "::1")) |
|||
assertNull(WifiApEndpoint.fromInput(WifiConnectionInput(host, "6000")), host) |
|||
for (port in listOf("", "0", "65536", "-1", "abc")) |
|||
assertNull(WifiApEndpoint.fromInput(WifiConnectionInput("192.168.1.100", port)), port) |
|||
} |
|||
|
|||
private class TestSocket(private val address: InetAddress) : Socket() { |
|||
val reading = CountDownLatch(1); val closed = CountDownLatch(1) |
|||
var target: InetSocketAddress? = null |
|||
var source: InetSocketAddress? = null |
|||
override fun bind(bindpoint: SocketAddress?) { source = bindpoint as InetSocketAddress } |
|||
override fun connect(endpoint: SocketAddress?, timeout: Int) { target = endpoint as InetSocketAddress } |
|||
override fun setTcpNoDelay(on: Boolean) {} |
|||
override fun setSoTimeout(timeout: Int) {} |
|||
override fun getLocalAddress(): InetAddress = address |
|||
override fun getInetAddress(): InetAddress? = target?.address |
|||
override fun getPort(): Int = target?.port ?: 0 |
|||
override fun getOutputStream() = ByteArrayOutputStream() |
|||
override fun getInputStream() = object : java.io.InputStream() { |
|||
override fun read(): Int { reading.countDown(); closed.await(5, TimeUnit.SECONDS); return -1 } |
|||
override fun read(bytes: ByteArray): Int = read() |
|||
} |
|||
override fun close() { super.close(); closed.countDown() } |
|||
} |
|||
@Test fun socketUsesOnlyTheSelectedApNetworkAndFixedEndpoint() = runBlocking { |
|||
val local = ip("192.168.4.20") |
|||
val socket = TestSocket(local) |
|||
val factory = mockk<SocketFactory>(); val network = mockk<Network>() |
|||
every { network.socketFactory } returns factory |
|||
every { factory.createSocket() } returns socket |
|||
val manager = WifiSocketManager() |
|||
manager.setWifiDirectRouteProvider { throw AssertionError("P2P must not be queried in AP mode") } |
|||
manager.setWifiApRouteProvider { WifiApRoute(network, local, "DCT") } |
|||
try { |
|||
val result = CompletableDeferred<String?>() |
|||
manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { result.complete(null) }, { result.complete(it) }) |
|||
assertNull(withTimeout(3000) { result.await() }) |
|||
assertTrue(manager.isConnected.value) |
|||
assertEquals(WifiApEndpoint(DctWifiAp.HOST, DctWifiAp.PORT), manager.connectedEndpoint.value) |
|||
assertEquals(InetSocketAddress(DctWifiAp.HOST, DctWifiAp.PORT), socket.target) |
|||
assertEquals(InetSocketAddress(local, 0), socket.source) |
|||
verify(exactly = 1) { factory.createSocket() } |
|||
} finally { manager.close() } |
|||
} |
|||
@Test fun missingOrChangedApRouteCannotFallBackToAnUnboundSocket() = runBlocking { |
|||
val manager = WifiSocketManager() |
|||
manager.setWifiApRouteProvider { throw IOException("no AP") } |
|||
try { |
|||
val failure = CompletableDeferred<String>() |
|||
manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { fail("must not connect") }, { failure.complete(it) }) |
|||
assertEquals("no AP", withTimeout(3000) { failure.await() }) |
|||
assertFalse(manager.isConnected.value) |
|||
assertNull(manager.connectedEndpoint.value) |
|||
val local = ip("192.168.4.20"); val network = mockk<Network>(); val replacement = mockk<Network>() |
|||
val factory = mockk<SocketFactory>(); val socket = TestSocket(local) |
|||
every { network.socketFactory } returns factory; every { factory.createSocket() } returns socket |
|||
var queries = 0 |
|||
manager.setWifiApRouteProvider { WifiApRoute(if (++queries == 1) network else replacement, local, "DCT") } |
|||
val changed = CompletableDeferred<String>() |
|||
manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { fail("stale AP") }, { changed.complete(it) }) |
|||
assertTrue(withTimeout(3000) { changed.await() }.contains("경로가 변경")) |
|||
assertTrue(socket.isClosed) |
|||
assertFalse(manager.isConnected.value) |
|||
} finally { manager.close() } |
|||
} |
|||
|
|||
@Test fun pcLanSocketUsesEditedPortAndRejectsPortChangeDuringConnection() = runBlocking { |
|||
val local = ip("192.168.1.20"); val network = mockk<Network>() |
|||
val factory = mockk<SocketFactory>(); every { network.socketFactory } returns factory |
|||
var socket = TestSocket(local); every { factory.createSocket() } answers { socket } |
|||
val manager = WifiSocketManager() |
|||
var endpoint = WifiApEndpoint("192.168.1.100", 6000) |
|||
manager.setWifiApRouteProvider { WifiApRoute(network, local, "PC LAN", endpoint) } |
|||
try { |
|||
val first = CompletableDeferred<String?>() |
|||
manager.connect(endpoint.host, endpoint.port, { first.complete(null) }, { first.complete(it) }) |
|||
assertNull(withTimeout(3000) { first.await() }) |
|||
assertEquals(InetSocketAddress("192.168.1.100", 6000), socket.target) |
|||
assertEquals(endpoint, manager.connectedEndpoint.value) |
|||
assertEquals(InetSocketAddress(local, 0), socket.source) |
|||
manager.disconnectSuspend() |
|||
assertNull(manager.connectedEndpoint.value) |
|||
socket = TestSocket(local) |
|||
var queries = 0 |
|||
manager.setWifiApRouteProvider { |
|||
WifiApRoute(network, local, "PC LAN", if (++queries == 1) endpoint else endpoint.copy(port = 7000)) |
|||
} |
|||
val changed = CompletableDeferred<String>() |
|||
manager.connect(endpoint.host, endpoint.port, { fail("stale port") }, { changed.complete(it) }) |
|||
assertTrue(withTimeout(3000) { changed.await() }.contains("경로가 변경")) |
|||
assertTrue(socket.isClosed) |
|||
assertFalse(manager.isConnected.value) |
|||
assertNull(manager.connectedEndpoint.value) |
|||
} finally { manager.close() } |
|||
} |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
param([switch]$ValidateOnly) |
|||
|
|||
$ErrorActionPreference = 'Stop' |
|||
Add-Type -AssemblyName PresentationFramework |
|||
try { |
|||
# Only Windows' active mobile-hotspot virtual NIC is eligible. A physical |
|||
# Wi-Fi/Ethernet NIC or an ambiguous pair must never be reconfigured. |
|||
$hotspotAdapters = @(Get-NetAdapter -IncludeHidden | Where-Object { |
|||
$_.Status -eq 'Up' -and $_.InterfaceDescription -like '*Wi-Fi Direct Virtual Adapter*' |
|||
} | Where-Object { |
|||
@(Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | |
|||
Where-Object { $_.IPAddress -in @('192.168.137.1', '192.168.4.1') }).Count -gt 0 |
|||
}) |
|||
if ($hotspotAdapters.Count -ne 1) { |
|||
throw 'Windows 모바일 핫스팟을 먼저 켜 주세요. 192.168.137.1 또는 192.168.4.1을 가진 활성 핫스팟 어댑터가 정확히 하나여야 합니다.' |
|||
} |
|||
$hotspotAdapter = $hotspotAdapters[0] |
|||
$existingAddresses = @(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -eq '192.168.4.1' }) |
|||
if (@($existingAddresses | Where-Object { $_.InterfaceIndex -ne $hotspotAdapter.ifIndex }).Count -gt 0) { |
|||
throw '192.168.4.1이 다른 어댑터에서 사용 중입니다. 기존 네트워크 설정을 변경하지 않았습니다.' |
|||
} |
|||
if (@($existingAddresses | Where-Object { $_.PrefixLength -ne 24 }).Count -gt 0) { |
|||
throw '기존 192.168.4.1의 접두사가 24가 아닙니다. 기존 설정을 변경하지 않았습니다.' |
|||
} |
|||
$hotspotIpInterface = Get-NetIPInterface -InterfaceIndex $hotspotAdapter.ifIndex -AddressFamily IPv4 |
|||
if ($hotspotIpInterface.Dhcp -ne 'Disabled') { |
|||
throw '핫스팟 어댑터가 DHCP 클라이언트로 설정되어 있습니다. 자동 변경하지 않았습니다.' |
|||
} |
|||
if ($ValidateOnly) { return } |
|||
$addedAddress = $false |
|||
try { |
|||
if ($existingAddresses.Count -eq 0) { |
|||
New-NetIPAddress -InterfaceIndex $hotspotAdapter.ifIndex -IPAddress '192.168.4.1' -PrefixLength 24 -SkipAsSource $true | Out-Null |
|||
$addedAddress = $true |
|||
} |
|||
$testExecutable = Join-Path $PSScriptRoot 'DCT_test.exe' |
|||
if (-not (Test-Path -LiteralPath $testExecutable)) { throw 'DCT_test.exe 옆의 스크립트로 실행하세요.' } |
|||
$ruleName = 'DCT-Test-Hotspot-AP-5000' |
|||
$existingRule = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue |
|||
if ($existingRule) { |
|||
$portFilter = $existingRule | Get-NetFirewallPortFilter |
|||
$addressFilter = $existingRule | Get-NetFirewallAddressFilter |
|||
$appFilter = $existingRule | Get-NetFirewallApplicationFilter |
|||
if ($existingRule.Direction -ne 'Inbound' -or $existingRule.Action -ne 'Allow' -or $existingRule.Enabled -ne 'True' -or |
|||
$portFilter.Protocol -ne 'TCP' -or $portFilter.LocalPort -ne '5000' -or |
|||
$addressFilter.LocalAddress -ne '192.168.4.1' -or $addressFilter.RemoteAddress -notin @('192.168.4.0/255.255.255.0', '192.168.4.0/24') -or |
|||
$appFilter.Program -ne $testExecutable) { |
|||
throw '기존 DCT AP 방화벽 규칙이 현재 실행 파일/주소와 다릅니다. 기존 규칙을 변경하지 않았습니다.' |
|||
} |
|||
} else { |
|||
New-NetFirewallRule -Name $ruleName -DisplayName 'DCT test hotspot AP TCP 5000' -Direction Inbound -Action Allow ` |
|||
-Protocol TCP -LocalAddress '192.168.4.1' -LocalPort 5000 -RemoteAddress '192.168.4.0/24' ` |
|||
-Program $testExecutable -Profile Any | Out-Null |
|||
} |
|||
} catch { |
|||
if ($addedAddress) { |
|||
Remove-NetIPAddress -InterfaceIndex $hotspotAdapter.ifIndex -IPAddress '192.168.4.1' -Confirm:$false |
|||
} |
|||
throw |
|||
} |
|||
[void][System.Windows.MessageBox]::Show("AP IP 설정 완료: 192.168.4.1/24`n태블릿 Wi-Fi를 수동 IP 192.168.4.2 / 접두사 24 / 게이트웨이·DNS 192.168.4.1로 설정하세요.`n현재 앱에서 '현재 AP로 통신 연결'을 누르세요.`n핫스팟을 다시 켠 뒤 주소가 사라지면 이 도구를 다시 실행하세요.", 'DCT AP 설정') |
|||
} catch { |
|||
if ($ValidateOnly) { throw } |
|||
[void][System.Windows.MessageBox]::Show($_.Exception.Message, 'DCT AP 설정 실패', 'OK', 'Error') |
|||
exit 1 |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
param([string]$BuildFolder = 'ApMode') |
|||
|
|||
if ($PSVersionTable.PSEdition -ne 'Desktop') { |
|||
& "$env:SystemRoot/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile -STA -ExecutionPolicy Bypass -File $PSCommandPath -BuildFolder $BuildFolder |
|||
exit $LASTEXITCODE |
|||
} |
|||
$ErrorActionPreference = 'Stop' |
|||
function Assert-Ap($condition, $message) { if (-not $condition) { throw $message } } |
|||
Add-Type -AssemblyName PresentationFramework |
|||
$apAssemblyPath = Join-Path $PSScriptRoot "DCT_test/bin/$BuildFolder/DCT_test.exe" |
|||
[void][Reflection.Assembly]::LoadFrom($apAssemblyPath) |
|||
$apWindow = New-Object DCT_test.MainWindow |
|||
try { |
|||
$mode = $apWindow.FindName('ComboNetworkMode') |
|||
$port = $apWindow.FindName('TextPort') |
|||
$controls = $apWindow.FindName('ApControls') |
|||
$externalGuide = $apWindow.FindName('ExternalApGuide') |
|||
Assert-Ap ($mode.SelectedIndex -eq 0 -and $port.Text -eq '6000' -and $port.IsEnabled) 'Direct default must remain editable 6000' |
|||
$mode.SelectedIndex = 1 |
|||
$ips = $apWindow.FindName('ComboIp') |
|||
Assert-Ap ($port.Text -eq '6000' -and $port.IsEnabled -and $ips.IsEnabled -and $controls.Visibility -eq 'Collapsed' -and $externalGuide.Visibility -eq 'Visible') 'External AP current IP and editable port, no hotspot controls' |
|||
$freePortListener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) |
|||
$freePortListener.Start() |
|||
$freePort = $freePortListener.LocalEndpoint.Port |
|||
$freePortListener.Stop() |
|||
$ips.Items.Add('127.0.0.1') | Out-Null |
|||
$ips.SelectedItem = '127.0.0.1' |
|||
$port.Text = $freePort.ToString() |
|||
$toggle = $apWindow.GetType().GetMethod('BtnToggleServer_Click', [Reflection.BindingFlags]'Instance,NonPublic') |
|||
$toggle.Invoke($apWindow, @($null, [Windows.RoutedEventArgs]::new())) | Out-Null |
|||
Assert-Ap ($apWindow.FindName('TextServerStatus').Text -eq "AP 대기 (127.0.0.1:$freePort)") 'AP must use the selected current IP and entered port' |
|||
Assert-Ap (-not $mode.IsEnabled -and -not $ips.IsEnabled -and -not $port.IsEnabled) 'Running server must lock mode/address/port' |
|||
$toggle.Invoke($apWindow, @($null, [Windows.RoutedEventArgs]::new())) | Out-Null |
|||
Assert-Ap ($mode.IsEnabled -and $ips.IsEnabled -and $port.IsEnabled) 'Stopped external AP must restore endpoint editing' |
|||
$mode.SelectedIndex = 2 |
|||
Assert-Ap ($externalGuide.Visibility -eq 'Collapsed') 'Hotspot must hide external router guide' |
|||
Assert-Ap ($port.Text -eq '5000' -and -not $port.IsEnabled -and $controls.Visibility -eq 'Visible') 'AP controls/fixed port' |
|||
Assert-Ap ($apWindow.FindName('ComboIp').SelectedItem -eq '192.168.4.1') 'AP fixed IP' |
|||
$mode.SelectedIndex = 0 |
|||
Assert-Ap ($port.Text -eq '6000' -and $port.IsEnabled -and $controls.Visibility -eq 'Collapsed' -and $externalGuide.Visibility -eq 'Collapsed') 'Direct restore' |
|||
} finally { $apWindow.Close() } |
|||
Write-Output 'PASS: WPF external AP current IP/custom port/start/stop locks, fixed hotspot and Direct restore' |
|||
|
|||
# Validate the actual helper with mocked network inventory: no UAC, NIC or |
|||
# firewall mutations are made by these checks. |
|||
$global:apVerificationAdapters = @([pscustomobject]@{Status='Up'; InterfaceDescription='Microsoft Wi-Fi Direct Virtual Adapter'; ifIndex=10}) |
|||
$global:apVerificationAddresses = @([pscustomobject]@{IPAddress='192.168.137.1'; InterfaceIndex=10; PrefixLength=24}) |
|||
$global:apVerificationDhcp = 'Disabled' |
|||
function Get-NetAdapter { param([switch]$IncludeHidden) $global:apVerificationAdapters } |
|||
function Get-NetIPAddress { |
|||
param($InterfaceIndex, $AddressFamily) |
|||
$global:apVerificationAddresses | Where-Object { -not $InterfaceIndex -or $_.InterfaceIndex -eq $InterfaceIndex } |
|||
} |
|||
function Get-NetIPInterface { param($InterfaceIndex, $AddressFamily) [pscustomobject]@{Dhcp=$global:apVerificationDhcp} } |
|||
$apHelper = Join-Path $PSScriptRoot 'DCT_test/Configure-HotspotAp.ps1' |
|||
& $apHelper -ValidateOnly |
|||
function Assert-Rejected($message) { |
|||
$rejected = $false |
|||
try { & $apHelper -ValidateOnly } catch { $rejected = $true } |
|||
Assert-Ap $rejected $message |
|||
} |
|||
$global:apVerificationAdapters[0].InterfaceDescription = 'Intel Wi-Fi physical adapter' |
|||
Assert-Rejected 'Physical adapter must be rejected' |
|||
$global:apVerificationAdapters[0].InterfaceDescription = 'Microsoft Wi-Fi Direct Virtual Adapter' |
|||
$global:apVerificationAdapters += [pscustomobject]@{Status='Up'; InterfaceDescription='Microsoft Wi-Fi Direct Virtual Adapter #2'; ifIndex=11} |
|||
$global:apVerificationAddresses += [pscustomobject]@{IPAddress='192.168.137.1'; InterfaceIndex=11; PrefixLength=24} |
|||
Assert-Rejected 'Ambiguous hotspot adapters must be rejected' |
|||
$global:apVerificationAdapters = @($global:apVerificationAdapters[0]) |
|||
$global:apVerificationAddresses = @($global:apVerificationAddresses[0], [pscustomobject]@{IPAddress='192.168.4.1'; InterfaceIndex=12; PrefixLength=24}) |
|||
Assert-Rejected 'Address owned by another adapter must be rejected' |
|||
$global:apVerificationAddresses = @($global:apVerificationAddresses[0]) |
|||
$global:apVerificationDhcp = 'Enabled' |
|||
Assert-Rejected 'DHCP client adapter must be rejected' |
|||
$global:apVerificationDhcp = 'Disabled' |
|||
$global:apVerificationAddresses += [pscustomobject]@{IPAddress='192.168.4.1'; InterfaceIndex=10; PrefixLength=16} |
|||
Assert-Rejected 'Wrong existing subnet prefix must be rejected' |
|||
$global:apVerificationAddresses[1].PrefixLength = 24 |
|||
& $apHelper -ValidateOnly |
|||
Write-Output 'PASS: hotspot helper valid/repeated configuration, physical/ambiguous adapters, conflicting address, DHCP and subnet rejection (no system changes)' |
|||
Loading…
Reference in new issue