diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt index ba37114..b6bc6bd 100644 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt @@ -125,17 +125,21 @@ class WifiApConnectionUiTest { compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() } - @Test fun connectedRowOpensDetailsForWifiDisconnectOnly() { + @Test fun connectedRowShowsInlineDisconnectWithoutOpeningDetails() { 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() + val disconnect = compose.onNodeWithTag("wifi:ap-current:disconnect").assertIsDisplayed() + val name = compose.onNodeWithText(device.ssid, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertTrue(name.right <= disconnect.fetchSemanticsNode().boundsInRoot.left) compose.onNodeWithTag("wifi:ap-current").performClick() compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed() compose.onNodeWithText("통신 재연결").assertDoesNotExist() compose.runOnIdle { assertEquals(0, disconnected) } - compose.onNodeWithText("연결 해제").performClick() + compose.onNodeWithText("확인").performClick() + disconnect.performClick() + compose.onNodeWithText("IP 주소: 192.168.4.20").assertDoesNotExist() compose.runOnIdle { assertEquals(1, disconnected) } } @@ -150,8 +154,11 @@ class WifiApConnectionUiTest { compose.onNodeWithTag("wifi:connected:101").performClick() compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed() compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() - compose.onNodeWithText("연결 해제").assertDoesNotExist() + compose.onNode(hasText("연결 해제") and hasAnyAncestor(hasTestTag("wifi:connected-details"))) + .assertDoesNotExist() compose.onNodeWithText("확인").performClick() + compose.onNodeWithTag("wifi:connected:101:disconnect").assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals(android.provider.Settings.ACTION_WIFI_SETTINGS, settingsAction) } compose.runOnIdle { assertEquals(0, current); assertNull(connected) } } diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt new file mode 100644 index 0000000..8bb5159 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt @@ -0,0 +1,69 @@ +package com.dct.app.presentation.dialog + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.SideEffect +import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.dct.app.data.communication.WifiConnectionMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Rule +import org.junit.Test + +class WifiConnectionModeUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun changingTabsKeepsTheSameDialogWindowBoundsAndTabPosition() { + var mode by mutableStateOf(WifiConnectionMode.TCP) + var windowView: android.view.View? = null + compose.setContent { MaterialTheme { + WifiConnectionDialogFrame(mode, true, { mode=it }, {}) { + val rootView = LocalView.current.rootView + SideEffect { windowView=rootView } + // Different content heights must not resize or replace the parent window. + if (mode == WifiConnectionMode.TCP) Text("AP 목록") else Column { + repeat(10) { Text("Direct 기기 $it") } + } + } + } } + val initialWindow = windowView + val initialBounds = compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot + val tabBounds = compose.onNodeWithTag("wifi-mode:TCP").fetchSemanticsNode().boundsInRoot + repeat(3) { + compose.onNodeWithTag("wifi-mode:DIRECT").performClick().assertIsSelected() + compose.onNodeWithText("Direct 기기 0").assertIsDisplayed() + compose.onNodeWithText("AP 목록").assertDoesNotExist() + assertEquals(initialBounds, compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot) + assertEquals(tabBounds, compose.onNodeWithTag("wifi-mode:TCP").fetchSemanticsNode().boundsInRoot) + compose.runOnIdle { assertSame(initialWindow, windowView) } + compose.onNodeWithTag("wifi-mode:TCP").performClick().assertIsSelected() + compose.onNodeWithText("AP 목록").assertIsDisplayed() + assertEquals(initialBounds, compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot) + compose.runOnIdle { assertSame(initialWindow, windowView) } + } + compose.onAllNodes(isDialog()).assertCountEquals(1) + } + + @Test fun tabsSwitchModesAndLockDuringAnActiveLink() { + var mode by mutableStateOf(WifiConnectionMode.TCP) + var enabled by mutableStateOf(true) + var clicks=0 + compose.setContent { MaterialTheme { + WifiConnectionModeSelector(mode,enabled) { mode=it; clicks++ } + } } + compose.onNodeWithTag("wifi-mode:TCP").assertIsSelected() + compose.onNodeWithTag("wifi-mode:DIRECT").performClick().assertIsSelected() + compose.runOnIdle { enabled=false } + compose.onNodeWithTag("wifi-mode:TCP").assertIsNotEnabled() + compose.onNodeWithText("Wi-Fi 해제 후 전환할 수 있습니다.").assertIsDisplayed() + compose.runOnIdle { assertEquals(1,clicks); enabled=true } + compose.onNodeWithTag("wifi-mode:TCP").performClick().assertIsSelected() + compose.runOnIdle { assertEquals(2,clicks) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt new file mode 100644 index 0000000..144f40c --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt @@ -0,0 +1,94 @@ +package com.dct.app.presentation.dialog + +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.dct.app.data.communication.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import java.io.File + +class WifiDirectDeviceListUiTest { + @get:Rule val compose = createComposeRule() + private val peer = WifiP2pDevice().apply { + deviceName = "DCT_TEST_PEER" + deviceAddress = "02:00:00:00:00:01" + status = WifiP2pDevice.AVAILABLE + } + private val state = mutableStateOf(WifiDirectUiState(phase = WifiDirectPhase.IDLE, enabled = true, + permissionGranted = true, locationEnabled = true, synchronizedWithOs = true, peers = listOf(peer))) + private var disconnected = 0 + private var portSettings = 0 + private var cancelled = 0 + private var selected: WifiP2pDevice? = null + private var refreshed = 0 + private var dismissed = 0 + + private fun link() = WifiDirectLink(WifiP2pInfo().apply { groupFormed = true; isGroupOwner = true }, WifiP2pGroup()) + private fun show() { + compose.setContent { MaterialTheme { + WifiConnectionDialogFrame(WifiConnectionMode.DIRECT, state.value.link == null, {}, {}) { + WifiDirectDeviceListContent(state.value, 5000, state.value.link == null, + onPortSettings = { portSettings++ }, onPermissions = {}, onSettings = {}, + onDisconnect = { disconnected++ }, onCancel = { cancelled++ }, onConnect = { selected=it }, + onRefresh = { refreshed++ }, onDismiss = { dismissed++ }) + } + } } + } + + @Test fun connectedSummaryKeepsStatusUnderNameAndDisconnectOnRightWithRoomForDiscovery() { + state.value = state.value.copy(phase = WifiDirectPhase.CONNECTED, link = link(), peers = (1..10).map { i -> + WifiP2pDevice().apply { deviceName="DCT_PEER_$i"; deviceAddress="peer_$i"; status=WifiP2pDevice.AVAILABLE } + }) + show() + val name = compose.onNodeWithText("Wi-Fi Direct 그룹").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val status = compose.onNodeWithTag("wifi:state").assertTextEquals("연결됨").fetchSemanticsNode().boundsInRoot + val disconnect = compose.onNodeWithTag("wifi:direct-disconnect").assertIsEnabled().assertIsDisplayed() + val row = compose.onNodeWithTag("wifi:direct-current").fetchSemanticsNode().boundsInRoot + val list = compose.onNodeWithTag("wifi:direct-list").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + assertTrue(status.top >= name.bottom) + assertTrue(name.right <= disconnect.fetchSemanticsNode().boundsInRoot.left) + with(compose.density) { + assertTrue("Connected summary should fit one compact row", row.height <= 64.dp.toPx()) + assertTrue("Discovery list must remain usable after connecting", list.height >= 200.dp.toPx()) + } + compose.onAllNodesWithText("연결됨").assertCountEquals(1) + compose.onNodeWithText("DCT_PEER_1").assertIsDisplayed() + compose.onNodeWithText("포트 설정").assertIsNotEnabled() + val bitmap = compose.onNodeWithTag("wifi:dialog").captureToImage().asAndroidBitmap() + File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "wifi-direct-compact.png") + .outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + disconnect.performClick() + compose.runOnIdle { assertEquals(1, disconnected); assertNull(selected) } + } + + @Test fun disconnectingShowsProgressInTheSameRowAndBlocksRepeatedDisconnect() { + state.value = state.value.copy(phase = WifiDirectPhase.DISCONNECTING, link = link()) + show() + compose.onNodeWithTag("wifi:state").assertTextEquals("연결 해제 중…") + compose.onNodeWithTag("wifi:direct-disconnect").assertIsNotEnabled().performClick() + compose.runOnIdle { assertEquals(0, disconnected) } + compose.onNodeWithText("닫기").assertIsDisplayed() + } + + @Test fun compactLayoutRetainsConnectCancelPortAndFooterActions() { + show() + compose.onNodeWithText("포트 설정").assertIsEnabled().performClick() + compose.onNodeWithText("연결").assertIsEnabled().performClick() + compose.runOnIdle { assertEquals(1, portSettings); assertSame(peer, selected) } + compose.runOnIdle { state.value=state.value.copy(phase=WifiDirectPhase.CONNECTING, target=peer) } + compose.onNodeWithText("취소").assertIsEnabled().performClick() + compose.runOnIdle { assertEquals(1, cancelled); state.value=state.value.copy(phase=WifiDirectPhase.IDLE, target=null) } + compose.onNodeWithText("새로고침").assertIsEnabled().performClick() + compose.onNodeWithText("닫기").performClick() + compose.runOnIdle { assertEquals(1, refreshed); assertEquals(1, dismissed) } + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt new file mode 100644 index 0000000..db95b66 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt @@ -0,0 +1,51 @@ +package com.dct.app.presentation.dialog + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class WifiDirectPortUiTest { + @get:Rule val compose=createComposeRule() + @Test fun default5000CanBeChangedAndSaved() { + var visible by mutableStateOf(true) + var saved:Int?=null + compose.setContent { MaterialTheme { + if (visible) WifiDirectPortDialog(5000,true,{ saved=it; true },{ visible=false }) + } } + compose.onNodeWithTag("wifi-direct-port").assertTextContains("5000").performTextReplacement("6000") + compose.onNodeWithText("저장").performClick() + compose.onNodeWithText("Direct 포트 설정").assertDoesNotExist() + compose.runOnIdle { assertEquals(6000,saved) } + } + + @Test fun invalidPortsAreRejectedAndCancelDoesNotSave() { + var calls=0 + var visible by mutableStateOf(true) + compose.setContent { MaterialTheme { + if (visible) WifiDirectPortDialog(5000,true,{ calls++; true },{ visible=false }) + } } + for (invalid in listOf("","0","65536","abc","999999999999999")) { + compose.onNodeWithTag("wifi-direct-port").performTextReplacement(invalid) + compose.onNodeWithText("저장").assertIsNotEnabled() + compose.onNodeWithText("1~65535 사이의 숫자를 입력하세요.").assertIsDisplayed() + } + compose.onNodeWithText("취소").performClick() + compose.runOnIdle { assertEquals(0,calls) } + } + + @Test fun activeCommunicationDisablesSavingAndRejectedSaveKeepsDialogOpen() { + var canEdit by mutableStateOf(false) + compose.setContent { MaterialTheme { WifiDirectPortDialog(5000,canEdit,{ false },{}) } } + compose.onNodeWithText("저장").assertIsNotEnabled() + compose.runOnIdle { canEdit=true } + compose.onNodeWithText("저장").performClick() + compose.onNodeWithText("통신 상태가 변경되어 저장하지 못했습니다. 연결을 해제한 뒤 다시 시도하세요.").assertIsDisplayed() + compose.onNodeWithText("Direct 포트 설정").assertIsDisplayed() + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt new file mode 100644 index 0000000..95db0f5 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt @@ -0,0 +1,32 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class DeveloperOptionsDialogUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun switchShowsCurrentSettingAndChangesItWithoutClosingTheDialog() { + var enabled by mutableStateOf(true) + var visible by mutableStateOf(true) + val changes = mutableListOf() + compose.setContent { + if (visible) DeveloperOptionsDialog(enabled, { enabled = it; changes += it }, { visible = false }) + } + compose.onNodeWithText("개발자 옵션").assertIsDisplayed() + compose.onNodeWithTag("ReceiveAutoRecoverySwitch").assertIsOn().performClick() + compose.onNodeWithTag("ReceiveAutoRecoverySwitch").assertIsOff() + compose.onNodeWithText("꺼짐").assertIsDisplayed() + compose.onNodeWithTag("ReceiveAutoRecoverySwitch").performClick().assertIsOn() + compose.onNodeWithText("켜짐").assertIsDisplayed() + compose.runOnIdle { assertEquals(listOf(false, true), changes) } + compose.onNodeWithText("닫기").performClick() + compose.onNodeWithText("개발자 옵션").assertDoesNotExist() + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt new file mode 100644 index 0000000..3b3e3a2 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt @@ -0,0 +1,80 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.dct.app.presentation.main.MainUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.atomic.AtomicLong + +class MeasurementElapsedTimeUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun timerIsLeftOfPacketsAndTicksWithoutNewPacketsThenClearsAndRestarts() { + val ui = mutableStateOf(MainUiState(isMeasuring = true, measurementStartedAtElapsedMillis = 1_000L)) + val clock = AtomicLong(6_000L) + compose.setContent { + Row(verticalAlignment = Alignment.CenterVertically) { + MeasurementElapsedTime(ui.value, nowMillis = clock::get) + Text("Packets: 0") + } + } + val timer = compose.onNodeWithTag("MeasurementElapsedTime").assertIsDisplayed() + .assertTextEquals("재생 00:00:05").fetchSemanticsNode().boundsInRoot + val packets = compose.onNodeWithText("Packets: 0").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + assertTrue(timer.right <= packets.left) + assertEquals(timer.center.y, packets.center.y, 2f) + clock.set(8_500L) + compose.mainClock.advanceTimeBy(1_100) + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:07") + // The label must reset as soon as measuring ends, even before ViewModel cleanup clears the start. + compose.runOnIdle { ui.value = ui.value.copy(isMeasuring = false) } + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00") + clock.set(20_000L) + compose.mainClock.advanceTimeBy(1_100) + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00") + clock.set(22_000L) + compose.runOnIdle { ui.value = ui.value.copy(isMeasuring = true, + measurementStartedAtElapsedMillis = 20_000L) } + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:02") + } + + @Test fun noSuccessfulStartShowsZero() { + compose.setContent { MeasurementElapsedTime(MainUiState(), nowMillis = { 99_000L }) } + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00") + } + + @Test fun backgroundStopsUiTicksAndResumeIncludesTimeAsleep() { + class Owner : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = registry + } + lateinit var owner: Owner + compose.runOnUiThread { owner = Owner().also { it.registry.currentState = Lifecycle.State.RESUMED } } + val clock = AtomicLong(6_000L) + compose.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + MeasurementElapsedTime(MainUiState(isMeasuring = true, measurementStartedAtElapsedMillis = 1_000L), + nowMillis = clock::get) + } + } + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:05") + compose.runOnIdle { owner.registry.currentState = Lifecycle.State.CREATED } + clock.set(30_000L) + compose.mainClock.advanceTimeBy(1_100) + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:05") + compose.runOnIdle { owner.registry.currentState = Lifecycle.State.RESUMED } + compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:29") + } +} diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index cd50693..5942cfe 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -16,7 +16,7 @@ - + diff --git a/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt b/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt index c5bab75..0d2e3e1 100644 --- a/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt +++ b/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt @@ -71,25 +71,39 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner { val mdi = MdiViewModel(dispatchers) val calibration = CalibrationViewModel(mdb, dispatchers) val wifi = WifiSocketManager(metrics, operationalLogger) + val developerOptions = com.dct.app.data.preferences.DeveloperOptionsPreferences( + appContext.getSharedPreferences( + com.dct.app.data.preferences.DeveloperOptionsPreferences.FILE_NAME, Context.MODE_PRIVATE)) val packets = PacketRepositoryImpl(wifiSocketManager = wifi, - ioDispatcher = dispatchers.io, metrics = metrics) + ioDispatcher = dispatchers.io, metrics = metrics, + initialReceiveAutoRecoveryEnabled = developerOptions.receiveAutoRecoveryEnabled()) return MainViewModel(mdb, chart, tree, mdi, calibration, EventBus(), dispatchers, wifiSocketManager = wifi, packetRepository = packets, pipelineMetrics = metrics, ensureBackgroundExecution = ::ensureForegroundService, - operationalLogger = operationalLogger) as T + operationalLogger = operationalLogger, developerOptionsPreferences = developerOptions) as T } } ViewModelProvider(this, factory)[MainViewModel::class.java] } val executionState: StateFlow by lazy { + val links = combine(viewModel.wifiDirectState, viewModel.wifiApState, viewModel.wifiConnectionMode) { direct, ap, mode -> + if (mode == com.dct.app.data.communication.WifiConnectionMode.DIRECT) + direct to com.dct.app.data.communication.WifiApUiState() + else com.dct.app.data.communication.WifiDirectUiState(phase = com.dct.app.data.communication.WifiDirectPhase.IDLE) to ap + } combine(viewModel.uiState, viewModel.packetRepository.connectionState, - viewModel.wifiSocketManager.isConnected, viewModel.wifiDirectState, viewModel.wifiApState) { ui, connection, socket, p2p, ap -> - MeasurementExecutionState.from(ui, connection, socket, p2p.phase, p2p.link != null, ap.phase, ap.networkReady) + viewModel.wifiSocketManager.isConnected, links) { ui, connection, socket, link -> + MeasurementExecutionState.from(ui, connection, socket, link.first.phase, link.first.link != null, + link.second.phase, link.second.networkReady) }.stateIn(scope, SharingStarted.Eagerly, MeasurementExecutionState.from(viewModel.uiState.value, viewModel.packetRepository.connectionState.value, viewModel.wifiSocketManager.isConnected.value, - viewModel.wifiDirectState.value.phase, viewModel.wifiDirectState.value.link != null, - viewModel.wifiApState.value.phase, viewModel.wifiApState.value.networkReady)) + if (viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT) + viewModel.wifiDirectState.value.phase else com.dct.app.data.communication.WifiDirectPhase.IDLE, + viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT && viewModel.wifiDirectState.value.link != null, + if (viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP) + viewModel.wifiApState.value.phase else com.dct.app.data.communication.WifiApPhase.IDLE, + viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP && viewModel.wifiApState.value.networkReady)) } /** Start while handling a visible user connection request, before P2P/TCP negotiation begins. */ diff --git a/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt b/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt index 4eb8083..df41588 100644 --- a/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt +++ b/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt @@ -30,6 +30,7 @@ data class MeasurementExecutionState( apPhase == com.dct.app.data.communication.WifiApPhase.CONNECTING val title = when { ui.recordingState == MeasurementRecordingState.STOPPING -> "DCT · 기록 마무리 중" + ui.isStreamRecovering -> "DCT · 계측 데이터 수신 복구 중" measuring && recording -> "DCT · 계측 및 기록 중" measuring -> "DCT · 계측 중 (기록 안 함)" recording -> "DCT · 기록 처리 중" diff --git a/Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt b/Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt index 0dffcd3..5f8c5bd 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt @@ -38,7 +38,7 @@ internal class OrderedPacketWriter( private val worker = scope.launch(Dispatchers.IO) { try { - while (isActive) { + while (isActive && !closed.get()) { val request = controlQueue.tryReceive().getOrNull() ?: receiveNext() // An expired queued START/STOP must not change the peer later. if (request.result.isCancelled) continue @@ -49,9 +49,12 @@ internal class OrderedPacketWriter( request.result.complete(false) throw e } + // Match the PC sender's Sleep(5): pause after each completed + // write, including consecutive ACKs, without blocking the reader. + delay(FRAME_INTERVAL_MS) } } catch (e: Exception) { - if (e !is CancellationException) onFailure(e) + if (e !is CancellationException && !closed.get()) onFailure(e) } finally { close() } @@ -102,4 +105,8 @@ internal class OrderedPacketWriter( closeTransport() } } + + private companion object { + const val FRAME_INTERVAL_MS = 5L + } } diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt index b20f5d5..680dc2f 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt @@ -48,6 +48,11 @@ class WifiApManager(context: Context, private var scanDeadline: Runnable? = null private var route: WifiApRoute? = null private var availableNetwork: Network? = null + // Memory-only retry target. Explicit Disconnect clears credentials and intent. + private var recoveryDevice: WifiApDevice? = null + private var recoveryPassword: String? = null + private var recoverySsid: String? = null + private var recoveryNetwork: Network? = null private var observer: ConnectivityManager.NetworkCallback? = null private val connectedNetworks = linkedMapOf() private val receiver = object : BroadcastReceiver() { @@ -209,7 +214,7 @@ class WifiApManager(context: Context, } @SuppressLint("MissingPermission") - @Synchronized fun connect(device: WifiApDevice, password: String) { + @Synchronized fun connect(device: WifiApDevice, password: String, recovery: Boolean = false) { refresh() if (closed || !state.value.canScan) return if (device.ssid.isBlank() || !device.security.acceptsPassword(password)) { @@ -220,7 +225,11 @@ class WifiApManager(context: Context, 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. + if (!recovery) onConnecting() // Explicit network changes end the old measurement intent. + recoveryDevice = device + recoveryPassword = password + recoverySsid = device.ssid + recoveryNetwork = null val replacing = route != null release() if (replacing) onLost() @@ -247,7 +256,7 @@ class WifiApManager(context: Context, /** Fallback for Settings-connected APs; never use the default cellular/VPN network. */ @SuppressLint("MissingPermission") - @Synchronized fun connectCurrent() { + @Synchronized fun connectCurrent(recovery: Boolean = false) { refresh() if (closed || !state.value.permissionGranted || !state.value.enabled || state.value.busy) return val candidates = connectivity?.allNetworks.orEmpty().filter { @@ -260,9 +269,18 @@ class WifiApManager(context: Context, return } val network = candidates.single() + if (recovery) { + if (recoverySsid != null) { + if (networkSsid(connectivity?.getNetworkCapabilities(network)) != recoverySsid) return + } else if (network != recoveryNetwork) return + } val name = connectedNetworks[network]?.displayName ?: "현재 Wi-Fi" if (route?.network == network) { onReady(route!!.ssid); return } - onConnecting() + if (!recovery) onConnecting() + recoveryDevice = null + recoveryPassword = null + recoverySsid = networkSsid(connectivity?.getNetworkCapabilities(network)) + recoveryNetwork = network val replacing = route != null release() if (replacing) onLost() @@ -323,7 +341,21 @@ class WifiApManager(context: Context, @Synchronized fun currentRoute(): WifiApRoute = route?.copy(endpoint = endpointProvider()) ?: throw IOException("Wi-Fi 연결/IP 할당이 준비되지 않았습니다.") + + /** Restore only the previously selected AP; do not adopt an unrelated default Wi-Fi. */ + @Synchronized fun recoverLastConnection(): Boolean { + if (closed) return false + if (route != null) return true + refresh() + if (state.value.busy || !state.value.enabled || !state.value.permissionGranted) return false + val device = recoveryDevice + if (device != null) connect(device, recoveryPassword ?: "", recovery = true) + else if (recoveryNetwork != null) connectCurrent(recovery = true) + return route != null + } + @Synchronized fun disconnect() { + recoveryDevice = null; recoveryPassword = null; recoverySsid = null; recoveryNetwork = null release() state.value = state.value.copy(phase = WifiApPhase.IDLE, localAddress = null, selectedSsid = null, activeNetworkKey = null, error = null) } @@ -344,6 +376,7 @@ class WifiApManager(context: Context, @Synchronized override fun close() { if (closed) return closed = true + recoveryDevice = null; recoveryPassword = null; recoverySsid = null; recoveryNetwork = null release() observer?.let { try { connectivity?.unregisterNetworkCallback(it) } catch (_: IllegalArgumentException) { } } observer = null diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionMode.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionMode.kt new file mode 100644 index 0000000..33b65cc --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionMode.kt @@ -0,0 +1,7 @@ +package com.dct.app.data.communication + +/** One selected network path owns the application's TCP session. */ +enum class WifiConnectionMode(val label: String) { + TCP("Wi-Fi TCP"), + DIRECT("Wi-Fi Direct") +} diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt index b4bcc69..304a8e0 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt @@ -16,6 +16,14 @@ data class WifiConnectionInput(val ip: String = "", val port: String = "") { /** App-local manual connection draft, independent of project and connection success. */ class WifiConnectionPreferences(private val preferences: SharedPreferences) { + fun loadDirectPort(): Int = preferences.getInt("direct_port", DEFAULT_DIRECT_PORT) + .takeIf { it in 1..65535 } ?: DEFAULT_DIRECT_PORT + + fun saveDirectPort(port: Int) { + require(port in 1..65535) { "TCP port must be between 1 and 65535" } + preferences.edit().putInt("direct_port", port).apply() + } + fun load() = WifiConnectionInput(preferences.getString("manual_ip", "").orEmpty(), preferences.getString("manual_port", "").orEmpty()) @@ -23,5 +31,8 @@ class WifiConnectionPreferences(private val preferences: SharedPreferences) { preferences.edit().putString("manual_ip", input.ip).putString("manual_port", input.port).apply() } - companion object { const val FILE_NAME = "wifi_connection_input" } + companion object { + const val FILE_NAME = "wifi_connection_input" + const val DEFAULT_DIRECT_PORT = 5000 + } } diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt index 679416e..835b6ae 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt @@ -38,9 +38,13 @@ class WifiSocketManager( private var serverJob: Job? = null private var readJob: Job? = null private val connectionGeneration = java.util.concurrent.atomic.AtomicLong() + val recoveryGeneration: Long get() = connectionGeneration.get() + @Volatile private var clientRecoveryEndpoint: WifiApEndpoint? = null + val recoveryEndpoint: WifiApEndpoint? get() = clientRecoveryEndpoint private val ingressSequenceCounter = java.util.concurrent.atomic.AtomicLong() @Volatile private var wifiDirectRouteProvider: (() -> WifiDirectRoute)? = null @Volatile private var wifiApRouteProvider: (() -> WifiApRoute)? = null + @Volatile private var recoveryNetworkProvider: (() -> Boolean)? = null private var connectingSocket: Socket? = null /** Production sockets must use a verified current P2P group interface/address. */ @@ -53,8 +57,14 @@ class WifiSocketManager( wifiApRouteProvider = provider } + fun setRecoveryNetworkProvider(provider: (() -> Boolean)?) { recoveryNetworkProvider = provider } + + /** A temporary AP loss keeps the measurement owner's generation and recovery target. */ + @Synchronized fun pauseForNetworkRecovery() { disconnectActiveSocketOnly() } + @Synchronized private fun nextConnectionGeneration(): Long { + clientRecoveryEndpoint = null connectingSocket?.close() connectingSocket = null return connectionGeneration.incrementAndGet() @@ -86,6 +96,10 @@ class WifiSocketManager( ) val packetStream: SharedFlow = _packetStream + fun diagnosticEvent(sessionId: Long, message: String) { + Log.i("WifiSocketManager", "session=$sessionId $message") + } + companion object { const val DEFAULT_PORT = 6000 // Retained P2P/test API; AP production calls use DctWifiAp.PORT. const val CONNECT_TIMEOUT_MS = 5000 @@ -256,9 +270,10 @@ class WifiSocketManager( } } - private fun connectClientInternal(host: String, port: Int, generation: Long, timeoutMs: Int = CONNECT_TIMEOUT_MS) { + private fun connectClientInternal(host: String, port: Int, generation: Long, timeoutMs: Int = CONNECT_TIMEOUT_MS, + allowed: () -> Boolean = { true }) { synchronized(this) { - if (generation != connectionGeneration.get()) throw CancellationException("Superseded connection") + if (generation != connectionGeneration.get() || !allowed()) throw CancellationException("Superseded connection") disconnectActiveSocketOnly() } val apRoute = wifiApRouteProvider?.invoke() @@ -295,7 +310,11 @@ class WifiSocketManager( "Wi-Fi TCP 연결 완료: local=${socket.localAddress.hostAddress}:${socket.localPort}, " + "remote=$host:$port, interface=${route?.interfaceName}, p2pAddressVerified=${route != null}, networkBound=${apRoute != null || route?.network != null}" ) - if (!attachSocket(socket, host, generation)) throw CancellationException("Superseded connection") + synchronized(this) { + if (!allowed()) throw CancellationException("Recovery cancelled") + if (!attachSocket(socket, host, generation)) throw CancellationException("Superseded connection") + clientRecoveryEndpoint = WifiApEndpoint(host, port) + } } catch (e: Exception) { socket.close() throw e @@ -304,6 +323,34 @@ class WifiSocketManager( } } + /** Reuse only the session owner's verified AP/P2P route; never supersede a manual connection. */ + suspend fun reconnectForRecovery(generation: Long, previousSession: Long, endpoint: WifiApEndpoint): Boolean = + withContext(Dispatchers.IO) { + val owner = currentCoroutineContext()[Job]!! + synchronized(this@WifiSocketManager) { + if (generation != connectionGeneration.get() || + (currentSessionId != 0L && currentSessionId != previousSession)) return@withContext false + } + try { + if (wifiApRouteProvider != null && recoveryNetworkProvider?.invoke() == false) return@withContext false + connectClientInternal(endpoint.host, endpoint.port, generation, allowed = { owner.isActive }) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("WifiSocketManager", "Recovery TCP connect failed: ${e.message}") + false + } + } + + @Synchronized + fun cancelRecoveryConnection(generation: Long) { + if (generation == connectionGeneration.get()) { + connectingSocket?.close() + connectingSocket = null + } + } + /** * 연결된 소켓을 활성 통신 소켓으로 등록하고 수신 루프 가동 */ @@ -344,11 +391,38 @@ class WifiSocketManager( // 실제 하드웨어/원격 기기 바이트 스트림 수신 루프 readJob = scope.launch { val buffer = ByteArray(4096) + val reads = java.util.concurrent.atomic.AtomicLong() + val rawBytes = java.util.concurrent.atomic.AtomicLong() + val lastRead = java.util.concurrent.atomic.AtomicLong() + val lastParsed = java.util.concurrent.atomic.AtomicLong() + val lastStream = java.util.concurrent.atomic.AtomicLong() + val emittingSince = java.util.concurrent.atomic.AtomicLong() + val healthJob = launch { + while (isActive) { + delay(5000) + val state = synchronized(this@WifiSocketManager) { + if (activeSocket !== socket) null else parser.diagnostics() + } ?: break + val now = System.nanoTime() + fun age(value: Long) = if (value == 0L) -1L else (now - value) / 1_000_000L + val pipeline = metrics.snapshot() + val message = "HEALTH reads=${reads.get()} bytes=${rawBytes.get()} " + + "rawAgeMs=${age(lastRead.get())} parsedAgeMs=${age(lastParsed.get())} streamAgeMs=${age(lastStream.get())} " + + "emitBlockedMs=${age(emittingSince.get())} subscribers=${_packetStream.subscriptionCount.value} " + + "parser=$state ack=${pipeline.ackEnqueued}/${pipeline.ackRejected} " + + "queue=${pipeline.dispatchQueueDepth} processed=${pipeline.processedPackets}" + Log.i("WifiSocketManager", "RX session=$sessionId $message") + } + } try { val inputStream = socket.getInputStream() while (isActive && activeSocket === socket && !socket.isClosed) { val bytesRead = inputStream.read(buffer) if (bytesRead > 0) { + val rawAt = System.nanoTime() + reads.incrementAndGet() + rawBytes.addAndGet(bytesRead.toLong()) + lastRead.set(rawAt) metrics.tcpReadCalls.incrementAndGet() metrics.tcpBytesRead.addAndGet(bytesRead.toLong()) val packets = synchronized(this@WifiSocketManager) { @@ -356,7 +430,11 @@ class WifiSocketManager( else parser.parseStream(buffer, bytesRead) } metrics.parsedPackets.addAndGet(packets.size.toLong()) + if (packets.isNotEmpty()) lastParsed.set(System.nanoTime()) for (packet in packets) { + if (packet.command == DctCommand.SEND_CAN_FRAME.code || + packet.command == DctCommand.SEND_SM_DATA.code || + packet.command == DctCommand.SEND_XCP_MEASUREMENT_DATA.code) lastStream.set(System.nanoTime()) val receivedAtNanos = System.nanoTime() val receivedAtMillis = timestampBaseMillis + ((receivedAtNanos - timestampBaseNanos) / 1_000_000L) @@ -368,6 +446,7 @@ class WifiSocketManager( packet.command == DctCommand.RES_STOP_TRANSMIT_DATA.code) { Log.i("WifiSocketManager", "P2P RX control: session=$sessionId, command=${DctCommand.toHexString(packet.command)}, payload=${packet.payload.size}B") } + emittingSince.set(System.nanoTime()) _packetStream.emit(packet.copy( transportSessionId = sessionId, receivedAtMillis = receivedAtMillis, @@ -375,16 +454,26 @@ class WifiSocketManager( receivedAtSec = receivedAtSec, ingressSequence = ingressSequence )) + emittingSince.set(0) } metrics.maybeLog("WifiSocketManager") } else if (bytesRead < 0) { + diagnosticEvent(sessionId, "EOF") Log.i("WifiSocketManager", "TCP peer EOF: session=$sessionId, host=$hostName") break } } } catch (e: IOException) { + diagnosticEvent(sessionId, "READ_ERROR localClosed=${socket.isClosed} reason=${e.message}") Log.w("WifiSocketManager", "TCP read closed: session=$sessionId, localClosed=${socket.isClosed}, reason=${e.message}", e) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + diagnosticEvent(sessionId, "RX_WORKER_ERROR error=${e.javaClass.simpleName}: ${e.message}") + Log.e("WifiSocketManager", "TCP RX worker failed: session=$sessionId", e) + throw e } finally { + healthJob.cancel() synchronized(this@WifiSocketManager) { if (activeSocket === socket) { disconnectActiveSocketOnly() @@ -460,6 +549,7 @@ class WifiSocketManager( @Synchronized private fun disconnectActiveSocketOnly() { + if (activeSocket != null) diagnosticEvent(currentSessionId, "CLEANUP parser=${parser.diagnostics()}") if (activeSocket != null) Log.i("WifiSocketManager", "TCP local cleanup: session=$currentSessionId", Throwable("Socket cleanup caller")) val job = readJob diff --git a/Android/app/src/main/java/com/dct/app/data/preferences/DeveloperOptionsPreferences.kt b/Android/app/src/main/java/com/dct/app/data/preferences/DeveloperOptionsPreferences.kt new file mode 100644 index 0000000..52e37ea --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/data/preferences/DeveloperOptionsPreferences.kt @@ -0,0 +1,14 @@ +package com.dct.app.data.preferences + +import android.content.SharedPreferences + +/** Settings survive app exit; access to the dialog is deliberately never persisted. */ +class DeveloperOptionsPreferences(private val preferences: SharedPreferences) { + fun receiveAutoRecoveryEnabled(): Boolean = preferences.getBoolean("receive_auto_recovery", false) + + fun setReceiveAutoRecoveryEnabled(enabled: Boolean) { + preferences.edit().putBoolean("receive_auto_recovery", enabled).apply() + } + + companion object { const val FILE_NAME = "developer_options" } +} diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt index 4d45405..5b8de61 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt @@ -40,6 +40,32 @@ class SlidingRingBufferParser( val recoveredCommand: Byte ) + enum class DiscardReason { NO_HEADER, UNKNOWN_COMMAND, STOP_LENGTH, INVALID_LENGTH, FOOTER, CHECKSUM, IDLE_RECOVERY, CAPACITY } + data class Diagnostics( + val inputBytes: Long, val frameBytes: Long, val frames: Long, + val pendingBytes: Int, val expectedBytes: Int?, val headerHex: String, + val discarded: Map + ) { + val discardedBytes: Long get() = discarded.values.sum() + } + private var inputBytes = 0L + private var frameBytes = 0L + private var frames = 0L + private val discarded = LongArray(DiscardReason.entries.size) + + /** On-demand snapshot only: no per-byte diagnostic allocation while scanning noise. */ + @Synchronized fun diagnostics(): Diagnostics = Diagnostics( + inputBytes, frameBytes, frames, availableBytes, + if (availableBytes >= 4) (((peek(1).toInt() and 255) shl 8) or (peek(2).toInt() and 255)) + DctPacketCodec.OVERHEAD_LENGTH else null, + (0 until minOf(8, availableBytes)).joinToString("") { "%02X".format(peek(it).toInt() and 255) }, + DiscardReason.entries.associateWith { discarded[it.ordinal] } + ) + + private fun discard(count: Int, reason: DiscardReason) { + discarded[reason.ordinal] += count.coerceAtMost(availableBytes).toLong() + skipBytes(count) + } + private val ringBuffer = ByteArray(bufferCapacity) private val scratchFrameBuffer = ByteArray(maxPacketSize) private var head = 0 @@ -58,6 +84,7 @@ class SlidingRingBufferParser( if (length <= 0) return result require(length <= buffer.size) { "Input length exceeds buffer size" } + inputBytes += length val now = clockNanos() if (availableBytes > 0 && hasReceivedInput && recoveryBoundary < 0 && now - lastInputNanos >= RECOVERY_IDLE_NANOS) { @@ -72,7 +99,7 @@ class SlidingRingBufferParser( // Drain between chunks: a coalesced read larger than the ring must not erase older frames. var offset = 0 while (offset < length) { - if (availableBytes == bufferCapacity) skipBytes(1) // Only an unrepresentable/noisy frame can fill it. + if (availableBytes == bufferCapacity) discard(1, DiscardReason.CAPACITY) // Only an unrepresentable/noisy frame can fill it. val count = minOf(length - offset, bufferCapacity - availableBytes) writeToRingBuffer(buffer, offset, count) offset += count @@ -96,13 +123,13 @@ class SlidingRingBufferParser( // START_BYTE가 없으면 버퍼 내 전체 데이터가 가비지이므로 폐기 후 종료 if (headerOffset == -1) { - skipBytes(availableBytes) + discard(availableBytes, DiscardReason.NO_HEADER) break } // START_BYTE 앞쪽의 가비지 바이트 폐기 (tail을 0xA1 위치로 이동) if (headerOffset > 0) { - skipBytes(headerOffset) + discard(headerOffset, DiscardReason.NO_HEADER) } // 2.2 최소 헤더 길이 확인: 0xA1(1) + LenH(1) + LenL(1) + CMD(1) = 4바이트 이상 필요 @@ -119,14 +146,14 @@ class SlidingRingBufferParser( val cmdByte = peek(3) if (DctCommand.fromByte(cmdByte) == null) { // 커맨드가 유효하지 않은 가짜 0xA1 헤더 노이즈 -> 1바이트 스킵 - skipBytes(1) + discard(1, DiscardReason.UNKNOWN_COMMAND) continue } // STOP ACK has only an optional one-byte status, never bulk data. // Without this bound repeated A1 noise looks like a valid 41 KB STOP // frame after enabling the full UInt16 telemetry length range. if (cmdByte == DctCommand.RES_STOP_TRANSMIT_DATA.code && packLen > 2) { - skipBytes(1) + discard(1, DiscardReason.STOP_LENGTH) continue } @@ -134,7 +161,7 @@ class SlidingRingBufferParser( if (packLen < 1 || expectedTotalLength < DctPacketCodec.MIN_PACKET_LENGTH || expectedTotalLength > minOf(maxPacketSize, bufferCapacity)) { // 잘못된 길이 정보이므로 현재 0xA1은 가짜 헤더(노이즈)임! // 1바이트만 스킵(백트래킹)하고 0xA1 다음 바이트부터 다시 재탐색 - skipBytes(1) + discard(1, DiscardReason.INVALID_LENGTH) continue } @@ -149,7 +176,7 @@ class SlidingRingBufferParser( val footerByte = peek(expectedTotalLength - 1) if (footerByte != DctPacketCodec.END_BYTE) { // 푸터가 0x7E가 아니면 가짜 헤더로 판정 -> 1바이트만 스킵하고 백트래킹 - skipBytes(1) + discard(1, DiscardReason.FOOTER) continue } @@ -166,11 +193,13 @@ class SlidingRingBufferParser( if (decoded != null) { // 디코딩 및 체크섬 검증 성공 -> 실제 프레임 길이만큼 링버퍼에서 소비 skipBytes(expectedTotalLength) + frameBytes += expectedTotalLength + frames++ sequenceCounter++ result.add(decoded) } else { // 체크섬 검증 실패 등 -> 가짜 헤더로 판정하고 1바이트만 스킵하여 백트래킹 - skipBytes(1) + discard(1, DiscardReason.CHECKSUM) } } @@ -204,7 +233,7 @@ class SlidingRingBufferParser( peekBytes(header, 0, header.size) val recovery = FrameRecovery(availableBytes, expectedLength, offset, recoveryIdleNanos / 1_000_000L, header, command) - skipBytes(offset) + discard(offset, DiscardReason.IDLE_RECOVERY) onFrameRecovery(recovery) return true } @@ -226,6 +255,7 @@ class SlidingRingBufferParser( recoveryBoundary = -1 recoveryScanOffset = 0 recoveryIdleNanos = 0L + inputBytes = 0; frameBytes = 0; frames = 0; discarded.fill(0) } val availableByteCount: Int @@ -247,7 +277,7 @@ class SlidingRingBufferParser( // 버퍼 오버플로우 방지: 초과분만큼 tail을 이동하여 가장 오래된 데이터 폐기 val overflow = (availableBytes + length) - bufferCapacity if (overflow > 0) { - skipBytes(overflow) + discard(overflow, DiscardReason.CAPACITY) } val firstChunk = (bufferCapacity - head).coerceAtMost(length) diff --git a/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt index d79e0ad..dedcd6a 100644 --- a/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt +++ b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt @@ -14,6 +14,7 @@ import com.dct.app.domain.model.protocol.DctMeasurementConfiguration import com.dct.app.domain.model.protocol.DctPacket import com.dct.app.domain.repository.DeviceConnectionState import com.dct.app.domain.repository.PacketRepository +import com.dct.app.domain.repository.StreamRecoveryState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -49,14 +51,46 @@ class PacketRepositoryImpl( private val heartbeatFailureThreshold: Int = DEFAULT_HEARTBEAT_FAILURE_THRESHOLD, private val enableHeartbeat: Boolean = true, private val heartbeatDispatcher: CoroutineDispatcher = Dispatchers.IO, - private val monotonicTimeMillis: () -> Long = { System.nanoTime() / 1_000_000L } + private val monotonicTimeMillis: () -> Long = { System.nanoTime() / 1_000_000L }, + private val streamSilenceTimeoutMs: Long = 15_000L, + private val recoveryDataTimeoutMs: Long = 15_000L, + private val recoveryDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()), + initialReceiveAutoRecoveryEnabled: Boolean = false ) : PacketRepository { - private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) private val requestMutex = Mutex() private val pendingResponses = ConcurrentHashMap>() /** Ambiguous response codes are blocked for this session until the late reply is drained. */ private val responseQuarantineSession = ConcurrentHashMap() private var heartbeatJob: Job? = null + private val enableRefreshLock = Any() + private var enableRefreshJob: Job? = null + private var enableRefreshGeneration = 0L + @Volatile private var enableRefreshBlocked = false + @Volatile private var measurementRequested = false + private val recoveryEpoch = java.util.concurrent.atomic.AtomicLong() + private val validStreamGeneration = java.util.concurrent.atomic.AtomicLong() + private val lastValidStreamMillis = java.util.concurrent.atomic.AtomicLong() + private val recoveryLock = Any() + private var streamWatchdogJob: Job? = null + private var streamRecoveryJob: Job? = null + private var recoveryTransportGeneration = 0L + private var recoveryEndpoint: com.dct.app.data.communication.WifiApEndpoint? = null + @Volatile private var appliedConfiguration: DctMeasurementConfiguration? = null + private val _streamRecovery = MutableStateFlow(StreamRecoveryState.IDLE) + override val streamRecovery: StateFlow = _streamRecovery.asStateFlow() + private val _receiveAutoRecoveryEnabled = MutableStateFlow(initialReceiveAutoRecoveryEnabled) + override val receiveAutoRecoveryEnabled: StateFlow = _receiveAutoRecoveryEnabled.asStateFlow() + + override fun setReceiveAutoRecoveryEnabled(enabled: Boolean) { + synchronized(recoveryLock) { + if (_receiveAutoRecoveryEnabled.value == enabled) return + _receiveAutoRecoveryEnabled.value = enabled + // Invalidate in-flight setup as well as retries, while keeping the user's measurement intent. + cancelStreamRecoveryWork() + if (enabled && measurementRequested) startStreamWatchdog() + } + } private val inboundPacketGeneration = java.util.concurrent.atomic.AtomicLong(0L) private val lastInboundMillis = java.util.concurrent.atomic.AtomicLong(0L) /** @@ -110,6 +144,16 @@ class PacketRepositoryImpl( } scope.launch(start = CoroutineStart.UNDISPATCHED) { manager.isConnected.drop(1).collect { transportConnected -> + if (transportConnected && measurementRequested) { + scheduleStreamRecovery("TRANSPORT_REATTACHED") + return@collect + } + if (!transportConnected) cancelEnableRefresh() + if (!transportConnected && measurementRequested && _receiveAutoRecoveryEnabled.value) { + failPendingRequests("TCP 단절로 계측 수신을 복구합니다.") + scheduleStreamRecovery("TRANSPORT_LOST") + return@collect + } if (!transportConnected && _connectionState.value !in setOf( DeviceConnectionState.DISCONNECTED, DeviceConnectionState.CONNECTING @@ -125,10 +169,16 @@ class PacketRepositoryImpl( } override suspend fun connect(target: String): DctResult = withContext(ioDispatcher) { + cancelStreamRecovery() + appliedConfiguration = null + cancelEnableRefresh() val sessionId = wifiSocketManager?.currentSessionId ?: 0L var completed = false try { - performHandshake().also { completed = it is DctResult.Success } + performHandshake().also { + completed = it is DctResult.Success + if (completed) enableRefreshBlocked = false + } } finally { // A failed/cancelled handshake cannot leave an apparently connected // socket behind. Never invalidate a newer connection's session. @@ -141,39 +191,47 @@ class PacketRepositoryImpl( } } - private suspend fun performHandshake(): DctResult = withContext(ioDispatcher) { + private suspend fun performHandshake(recovery: Long? = null): DctResult = withContext(ioDispatcher) { + val session = wifiSocketManager?.currentSessionId ?: 0L + fun state(value: DeviceConnectionState) { if (recovery == null) transition(value) } + fun allowed(): Boolean = recovery == null || recoveryAllowed(recovery, session) + suspend fun exchange(request: DctPacket) = sendCommandWithTimeout( + request.copy(transportSessionId = session), responseTimeoutMs, isAllowed = ::allowed) // Consume the snapshot once. MainViewModel supplies it immediately before // each Wi-Fi callback, so a failed/stale session cannot leak its settings // into a later reconnect. - val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null } - transition(DeviceConnectionState.CONNECTING) - _isStreaming.value = false - startMayHaveReachedDevice = false - playbackTransportSessionId = null + val initialConfiguration = if (recovery != null) appliedConfiguration + else pendingInitialConfiguration.also { pendingInitialConfiguration = null } + state(DeviceConnectionState.CONNECTING) + if (recovery == null) { + _isStreaming.value = false + startMayHaveReachedDevice = false + playbackTransportSessionId = null + } if (!hasLiveTransport()) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("Wi-Fi TCP 전송 계층이 연결되지 않았습니다.") } - transition(DeviceConnectionState.HANDSHAKING) + state(DeviceConnectionState.HANDSHAKING) // 1. SW -> HW: 0x00 (COMM_ENABLE) - val enableRes = sendCommand(DctProtocolRequests.enable()) + val enableRes = exchange(DctProtocolRequests.enable()) if (enableRes !is DctResult.Success || enableRes.data == null) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext if (enableRes is DctResult.Failure) enableRes else failure("Handshake response missing for 0x00") } try { com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.enable(), enableRes.data) } catch (e: IllegalArgumentException) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("연결 초기화 0x00: ${e.message}") } // 2. Register before sending: the version response may arrive immediately // or while the following environment/settings exchanges are in flight. if (responseQuarantineSession[DctCommand.RES_VERSION.code] == wifiSocketManager?.currentSessionId) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 다시 연결하세요.") } val versionDeferred = CompletableDeferred() @@ -181,9 +239,10 @@ class PacketRepositoryImpl( val versionReq = DctProtocolRequests.version() val versionSessionCommand = versionReq.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0) try { + if (!allowed()) throw CancellationException("Recovery superseded") if (!sendLive(versionSessionCommand)) { pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred) - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("버전 요청(0x01) 송신에 실패했습니다.") } @@ -195,20 +254,11 @@ class PacketRepositoryImpl( // 3. 파란색 박스 인터리빙 시퀀스. 0x09/0x12는 payload 없이 // 보낼 수 있는 조회가 아니므로, 프로젝트 스냅샷이 있을 때만 // PC의 완전한 CAN 모드/SM 설정 payload를 추가합니다. - val envQueries = buildList { - add(DctProtocolRequests.dctEnvQuery(0x00.toByte())) - add(DctProtocolRequests.dctEnvQuery(0x03.toByte())) - initialConfiguration?.let { config -> - add(DctProtocolRequests.canMode(config.isHighSpeedCan, config.isOldModel)) - if (config.sensorModules.isNotEmpty()) { - add(DctProtocolRequests.sensorModuleInfo(config.sensorModules)) - } - } - } + val envQueries = environmentQueries(initialConfiguration) for (query in envQueries) { - val res = sendCommand(query) + val res = exchange(query) if (res !is DctResult.Success || res.data == null) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext if (res is DctResult.Failure) res else failure( "Handshake response missing for ${DctCommand.toHexString(query.command)}" ) @@ -218,7 +268,7 @@ class PacketRepositoryImpl( } catch (e: IllegalArgumentException) { // connect() closes the failed TCP session without releasing // the Wi-Fi AP, so a clean retry does not require pairing. - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("연결 초기화 ${DctCommand.toHexString(query.command)}: ${e.message}") } } @@ -231,7 +281,7 @@ class PacketRepositoryImpl( } catch (e: Exception) { if (e is CancellationException && e !is kotlinx.coroutines.TimeoutCancellationException) throw e pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred) - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("버전 응답(0x81) 시간 초과: ${e.message}") } finally { pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred) @@ -239,7 +289,7 @@ class PacketRepositoryImpl( try { com.dct.app.data.protocol.DctResponseValidator.validateSetup(versionReq, versionResult) } catch (e: IllegalArgumentException) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("연결 초기화 0x01: ${e.message}") } @@ -249,9 +299,9 @@ class PacketRepositoryImpl( DctProtocolRequests.syncTime() ) for (request in postHandshake) { - val result = sendCommand(request) + val result = exchange(request) if (result !is DctResult.Success || result.data == null) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext if (result is DctResult.Failure) result else failure( "Handshake response missing for ${DctCommand.toHexString(request.command)}" ) @@ -259,12 +309,12 @@ class PacketRepositoryImpl( try { com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data) } catch (e: IllegalArgumentException) { - transition(DeviceConnectionState.ERROR) + state(DeviceConnectionState.ERROR) return@withContext failure("연결 초기화 ${DctCommand.toHexString(request.command)}: ${e.message}") } } - transition(DeviceConnectionState.READY) + state(DeviceConnectionState.READY) DctResult.Success(Unit) } finally { if (!versionDeferred.isCompleted) { @@ -275,6 +325,11 @@ class PacketRepositoryImpl( } override suspend fun disconnect(): DctResult = withContext(ioDispatcher) { + cancelStreamRecovery() + // STOP/DISABLE can still be waiting for I/O while the state is MEASURING. + // Do not let another ENABLE ACK restart queries during explicit teardown. + enableRefreshBlocked = true + cancelEnableRefresh() stopHeartbeatLoop() try { if (_isStreaming.value || startMayHaveReachedDevice) stopStreaming() @@ -291,6 +346,9 @@ class PacketRepositoryImpl( } override suspend fun sendCommand(command: DctPacket): DctResult = withContext(ioDispatcher) { + if (measurementRequested && _streamRecovery.value == StreamRecoveryState.RECOVERING) { + return@withContext failure("계측 데이터 수신 복구 중입니다. 복구 완료 후 다시 시도하세요.") + } sendCommandWithTimeout(command, responseTimeoutMs) } @@ -301,11 +359,18 @@ class PacketRepositoryImpl( */ private suspend fun sendCommandWithTimeout( command: DctPacket, - timeoutMs: Long + timeoutMs: Long, + onWritten: (() -> Unit)? = null, + isAllowed: () -> Boolean = { true } ): DctResult = withContext(ioDispatcher) { val expectedResponse = expectedResponse(command.command) val sessionCommand = command.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0) + if (command.transportSessionId != 0L && command.transportSessionId != sessionCommand.transportSessionId) { + return@withContext failure("명령의 TCP 세션이 변경되었습니다.") + } + + if (!isAllowed()) throw CancellationException("Recovery superseded") if (!hasLiveTransport()) return@withContext failure("전송 가능한 실장비 연결이 없습니다.") if (expectedResponse == null) { @@ -314,6 +379,10 @@ class PacketRepositoryImpl( } requestMutex.withLock { + if (!isAllowed()) throw CancellationException("Recovery superseded while waiting for command") + if (!hasLiveTransport() || wifiSocketManager?.currentSessionId != sessionCommand.transportSessionId) { + return@withLock failure("명령 대기 중 TCP 세션이 변경되었습니다.") + } val quarantinedSession = responseQuarantineSession[expectedResponse] if (quarantinedSession == sessionCommand.transportSessionId) { return@withLock failure("이전 ${DctCommand.toHexString(command.command)} 응답 지연을 정리하는 중입니다. 지연 응답 수신 후 재시도하거나 연결을 명시적으로 다시 설정하세요.") @@ -327,6 +396,7 @@ class PacketRepositoryImpl( responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId failure("명령 송신 실패: ${DctCommand.toHexString(command.command)}") } else { + onWritten?.invoke() DctResult.Success(deferred.await()) } } @@ -358,118 +428,129 @@ class PacketRepositoryImpl( if (_connectionState.value != DeviceConnectionState.READY) { return@withContext failure("장비 설정은 READY 상태에서만 적용할 수 있습니다.") } - val sessionId = wifiSocketManager?.currentSessionId ?: 0L - - val requests = try { - fun packet(step: String, build: () -> DctPacket): DctPacket = try { - build() - } catch (e: IllegalArgumentException) { - throw IllegalArgumentException("$step: ${e.message}", e) - } - buildList { - // 1. 상단 공통: 차종 코드 (0x06) - if (configuration.vehicleCode.isNotBlank()) { - add(packet("차종 설정") { - DctProtocolRequests.carType(configuration.vehicleCode, configuration.maker) - }) - } - // 2. 상단 공통: 프로젝트 DB 코드 (0x08 0x00) - if (configuration.projectName.isNotBlank()) { - add(packet("프로젝트 DB 설정") { - DctProtocolRequests.dbInfo(configuration.projectName) - }) - } + applyMeasurementConfiguration(configuration) + } - // 3. 조건부 배타 분기 (MDB vs A2L) - if (configuration.mdbSignals.isNotEmpty()) { - // [MDB 분기]: 0x10 0x00 (MDB Info) -> 0x13 (Selected IDs) - add(packet("MDB 정의") { DctProtocolRequests.mdbInfo(configuration.mdbSignals) }) - add(packet("MDB 선택 ID") { - DctProtocolRequests.mdbSelectedIds(configuration.selectedMdbSignals) - }) - } else if (configuration.a2lSignals.isNotEmpty()) { - // [A2L 분기]: 0x40 (XCP Use) -> 0x41 (XCP IFDATA) -> 0x42 (XCP Measurements) - // PC SendA2LUseState sends 0x01 when an A2L project is - // loaded and 0x02 only when no A2L path is selected. - add(packet("XCP 사용 모드") { DctProtocolRequests.xcpUse(0x01) }) - val ifData = configuration.xcpIfData ?: com.dct.app.data.parser.A2lParser.XcpIfData() - add(packet("XCP CAN 설정") { DctProtocolRequests.xcpIfData(ifData) }) - add(packet("XCP 측정 목록") { DctProtocolRequests.xcpMeasurements(configuration.a2lSignals, ifData.isMsbFirst) }) - } else { - // No MDB/A2L source means XCP must explicitly be - // disabled, otherwise a preceding A2L project can keep - // streaming stale DAQ data on the next project. - add(packet("XCP 미사용 모드") { DctProtocolRequests.xcpUse(0x02) }) - } + private suspend fun applyMeasurementConfiguration(configuration: DctMeasurementConfiguration, + recovery: Long? = null): DctResult = withContext(ioDispatcher) { + val sessionId = wifiSocketManager?.currentSessionId ?: 0L - // PC registers CAN first, sends the DID preset after a 100ms - // gap, and refreshes the environment after RES_CANDBC_INFO. - // Keep one outstanding request per response code: older peers - // can omit the subtype needed to correlate overlapping 0x89s. - add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) }) - add(packet("DID 사전 설정") { - com.dct.app.data.maintenance.DeviceMaintenanceProtocol.setupDid(configuration.didPreSetting) + val requests = try { + fun packet(step: String, build: () -> DctPacket): DctPacket = try { + build() + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("$step: ${e.message}", e) + } + buildList { + // 1. 상단 공통: 차종 코드 (0x06) + if (configuration.vehicleCode.isNotBlank()) { + add(packet("차종 설정") { + DctProtocolRequests.carType(configuration.vehicleCode, configuration.maker) }) - add(packet("저장 주기 조회") { DctProtocolRequests.dctEnvQuery(0) }) - add(packet("장비 환경 조회") { DctProtocolRequests.dctEnvQuery(3) }) - add(packet("CAN 모드") { - DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel) + } + // 2. 상단 공통: 프로젝트 DB 코드 (0x08 0x00) + if (configuration.projectName.isNotBlank()) { + add(packet("프로젝트 DB 설정") { + DctProtocolRequests.dbInfo(configuration.projectName) }) - // SM setup is also sent with its module/channel bitmasks. - // An empty list intentionally emits [01 00] to clear stale - // module selection from the previous project. - add(packet("센서 모듈") { - DctProtocolRequests.sensorModuleInfo(configuration.sensorModules) + } + + // 3. 조건부 배타 분기 (MDB vs A2L) + if (configuration.mdbSignals.isNotEmpty()) { + // [MDB 분기]: 0x10 0x00 (MDB Info) -> 0x13 (Selected IDs) + add(packet("MDB 정의") { DctProtocolRequests.mdbInfo(configuration.mdbSignals) }) + add(packet("MDB 선택 ID") { + DctProtocolRequests.mdbSelectedIds(configuration.selectedMdbSignals) }) + } else if (configuration.a2lSignals.isNotEmpty()) { + // [A2L 분기]: 0x40 (XCP Use) -> 0x41 (XCP IFDATA) -> 0x42 (XCP Measurements) + // PC SendA2LUseState sends 0x01 when an A2L project is + // loaded and 0x02 only when no A2L path is selected. + add(packet("XCP 사용 모드") { DctProtocolRequests.xcpUse(0x01) }) + val ifData = configuration.xcpIfData ?: com.dct.app.data.parser.A2lParser.XcpIfData() + add(packet("XCP CAN 설정") { DctProtocolRequests.xcpIfData(ifData) }) + add(packet("XCP 측정 목록") { DctProtocolRequests.xcpMeasurements(configuration.a2lSignals, ifData.isMsbFirst) }) + } else { + // No MDB/A2L source means XCP must explicitly be + // disabled, otherwise a preceding A2L project can keep + // streaming stale DAQ data on the next project. + add(packet("XCP 미사용 모드") { DctProtocolRequests.xcpUse(0x02) }) } - } catch (e: IllegalArgumentException) { - return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}") + + // PC registers CAN first, sends the DID preset after a 100ms + // gap, and refreshes the environment after RES_CANDBC_INFO. + // Keep one outstanding request per response code: older peers + // can omit the subtype needed to correlate overlapping 0x89s. + add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) }) + add(packet("DID 사전 설정") { + com.dct.app.data.maintenance.DeviceMaintenanceProtocol.setupDid(configuration.didPreSetting) + }) + add(packet("저장 주기 조회") { DctProtocolRequests.dctEnvQuery(0) }) + add(packet("장비 환경 조회") { DctProtocolRequests.dctEnvQuery(3) }) + add(packet("CAN 모드") { + DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel) + }) + // SM setup is also sent with its module/channel bitmasks. + // An empty list intentionally emits [01 00] to clear stale + // module selection from the previous project. + add(packet("센서 모듈") { + DctProtocolRequests.sensorModuleInfo(configuration.sensorModules) + }) } + } catch (e: IllegalArgumentException) { + return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}") + } - for (request in requests) { - if (request.command == DctCommand.CMD_SETUP_DID_INFO.code) { - kotlinx.coroutines.delay(100) - // PC does not wait for 0x97 in its connection setup chain. - // Await the actual write, but do not invent a mandatory ACK. - if (!sendLive(request.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0L))) { - return@withContext failure("DID 사전 설정 송신에 실패했습니다.") - } - // Controlled hardware comparison: separate the unacknowledged - // DID write from the following environment query, not from enqueue time. - kotlinx.coroutines.delay(DID_ENV_GAP_MS) - continue - } - val result = sendCommand(request) - if (result !is DctResult.Success || result.data == null) { - // Setup cannot resume after its response budget expires, even - // if heartbeat replies keep arriving. Close only this session - // so a late ACK cannot leave an unusable READY connection. - if (result is DctResult.Failure && result.error is DctError.ProtocolError.ResponseTimeout && - playbackTransportSessionId != sessionId && - wifiSocketManager?.invalidateConnectionIfSession(sessionId) == true) { - _isStreaming.value = false - failPendingRequests("장비 설정 응답 시간 초과로 TCP 연결을 종료했습니다.") - transition(DeviceConnectionState.ERROR) - android.util.Log.w("DctPacketRepository", - "Configuration response timeout; TCP session closed: session=$sessionId, command=${DctCommand.toHexString(request.command)}") - } - return@withContext if (result is DctResult.Failure) result else failure( - "장비 설정 응답 누락: ${DctCommand.toHexString(request.command)}" - ) - } - try { - com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data) - } catch (e: IllegalArgumentException) { - return@withContext failure("장비 설정 ${DctCommand.toHexString(request.command)}: ${e.message}") + for (request in requests) { + if (recovery != null && !recoveryAllowed(recovery, sessionId)) throw CancellationException("Recovery superseded") + if (request.command == DctCommand.CMD_SETUP_DID_INFO.code) { + kotlinx.coroutines.delay(100) + // PC does not wait for 0x97 in its connection setup chain. + // Await the actual write, but do not invent a mandatory ACK. + if (!sendLive(request.copy(transportSessionId = sessionId))) { + return@withContext failure("DID 사전 설정 송신에 실패했습니다.") } - if (request.command == DctCommand.CMD_XCP_IFDATAS.code) { - // Calibration must use the byte order the device actually acknowledged. - // Commit here, not after all setup: a later failure does not undo IFData. - xcpIsMsbFirst = request.payload[0] == 0x01.toByte() + // Controlled hardware comparison: separate the unacknowledged + // DID write from the following environment query, not from enqueue time. + kotlinx.coroutines.delay(DID_ENV_GAP_MS) + continue + } + val result = sendCommandWithTimeout(request.copy(transportSessionId = sessionId), responseTimeoutMs, + isAllowed = { recovery == null || recoveryAllowed(recovery, sessionId) }) + if (result !is DctResult.Success || result.data == null) { + // Setup cannot resume after its response budget expires, even + // if heartbeat replies keep arriving. Close only this session + // so a late ACK cannot leave an unusable READY connection. + if (recovery == null && result is DctResult.Failure && result.error is DctError.ProtocolError.ResponseTimeout && + playbackTransportSessionId != sessionId && + wifiSocketManager?.invalidateConnectionIfSession(sessionId) == true) { + _isStreaming.value = false + failPendingRequests("장비 설정 응답 시간 초과로 TCP 연결을 종료했습니다.") + transition(DeviceConnectionState.ERROR) + android.util.Log.w("DctPacketRepository", + "Configuration response timeout; TCP session closed: session=$sessionId, command=${DctCommand.toHexString(request.command)}") } + return@withContext if (result is DctResult.Failure) result else failure( + "장비 설정 응답 누락: ${DctCommand.toHexString(request.command)}" + ) + } + try { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data) + } catch (e: IllegalArgumentException) { + return@withContext failure("장비 설정 ${DctCommand.toHexString(request.command)}: ${e.message}") + } + if (request.command == DctCommand.CMD_XCP_IFDATAS.code) { + // Calibration must use the byte order the device actually acknowledged. + // Commit here, not after all setup: a later failure does not undo IFData. + xcpIsMsbFirst = request.payload[0] == 0x01.toByte() } - DctResult.Success(Unit) } + appliedConfiguration = configuration.copy(mdbSignals = configuration.mdbSignals.toList(), + selectedMdbSignals = configuration.selectedMdbSignals.toList(), a2lSignals = configuration.a2lSignals.toList(), + canMessages = configuration.canMessages.toList(), + sensorModules = configuration.sensorModules.map { it.copy(channels = it.channels.toList()) }) + DctResult.Success(Unit) + } override suspend fun startStreaming(frequencyHz: Int): DctResult = withContext(ioDispatcher) { if (_connectionState.value != DeviceConnectionState.READY) { @@ -495,6 +576,7 @@ class PacketRepositoryImpl( }.isSuccess) { _isStreaming.value = true startMayHaveReachedDevice = false + armStreamWatchdog() transition(DeviceConnectionState.MEASURING) DctResult.Success(Unit) } else if (response?.payload?.let { it.size == 2 && it[1] == 1.toByte() && it[0] != 1.toByte() } == true) { @@ -509,6 +591,14 @@ class PacketRepositoryImpl( } override suspend fun stopStreaming(): DctResult = withContext(ioDispatcher) { + val wasRequested = measurementRequested + cancelStreamRecovery() + if (wasRequested && !hasLiveTransport()) { + _isStreaming.value = false + startMayHaveReachedDevice = false + transition(DeviceConnectionState.DISCONNECTED) + return@withContext DctResult.Success(Unit) + } if (_connectionState.value != DeviceConnectionState.MEASURING && !startMayHaveReachedDevice) { return@withContext failure("현재 계측 중이 아닙니다.") } @@ -637,9 +727,21 @@ class PacketRepositoryImpl( } private suspend fun collectTransportPackets(stream: SharedFlow) { + var lastPacket: DctPacket? = null + var failures = 0L + var failedAt = 0L while (true) { try { - stream.collect { packet -> + stream.onSubscription { + if (failedAt != 0L) { + val message = "COLLECTOR_RESUBSCRIBED failures=$failures gapMs=${(System.nanoTime() - failedAt) / 1_000_000L} " + + "failedSession=${lastPacket?.transportSessionId} currentSession=${wifiSocketManager?.currentSessionId}" + android.util.Log.w("DctPacketRepository", message) + wifiSocketManager?.let { it.diagnosticEvent(it.currentSessionId, message) } + failedAt = 0 + } + }.collect { packet -> + lastPacket = packet if (packet.transportSessionId != 0L && packet.transportSessionId != wifiSocketManager?.currentSessionId) return@collect // PC는 패킷 종류와 무관하게 정상 수신이 계속되면 장비가 @@ -676,6 +778,13 @@ class PacketRepositoryImpl( else -> null } if (ack != null) { + if (decodedStreamPayload != null) { + validStreamGeneration.incrementAndGet() + lastValidStreamMillis.set(monotonicTimeMillis()) + // Cancelling recovery must not hide telemetry that subsequently resumes on its own. + if (measurementRequested && !_receiveAutoRecoveryEnabled.value && + _connectionState.value == DeviceConnectionState.MEASURING) _isStreaming.value = true + } val offered = offerStreamAck( ack.copy(transportSessionId = packet.transportSessionId) ) @@ -704,6 +813,9 @@ class PacketRepositoryImpl( "DctPacketRepository", "RX response without waiter: session=${packet.transportSessionId}, command=${DctCommand.toHexString(packet.command)}, payload=${packet.payload.size}B" ) + if (!quarantined && packet.command == DctCommand.RES_ENABLE.code) { + scheduleEnableRefresh(packet) + } } // Do not discard packets when the graph is busy. The // bounded queue makes sustained overload observable and @@ -715,13 +827,107 @@ class PacketRepositoryImpl( } } catch (e: CancellationException) { throw e - } catch (_: Throwable) { - // 예외 발생 시 잠시 대기 후 스트림 수집 루프 재개 + } catch (error: Throwable) { + failedAt = System.nanoTime() + failures++ + val message = "COLLECTOR_ERROR failures=$failures session=${lastPacket?.transportSessionId} " + + "ingress=${lastPacket?.ingressSequence} command=${lastPacket?.command?.let(DctCommand::toHexString)} " + + "error=${error.javaClass.simpleName}: ${error.message}; retry=100ms replay=0" + android.util.Log.e("DctPacketRepository", message, error) + wifiSocketManager?.let { it.diagnosticEvent(it.currentSessionId, message) } + // Preserve the existing retry policy, but expose the replay=0 loss window. kotlinx.coroutines.delay(100) } } } + private fun environmentQueries(configuration: DctMeasurementConfiguration? = null): List = buildList { + add(DctProtocolRequests.dctEnvQuery(0x00.toByte())) + add(DctProtocolRequests.dctEnvQuery(0x03.toByte())) + configuration?.let { + add(DctProtocolRequests.canMode(it.isHighSpeedCan, it.isOldModel)) + if (it.sensorModules.isNotEmpty()) add(DctProtocolRequests.sensorModuleInfo(it.sensorModules)) + } + } + + private fun cancelEnableRefresh() { + val job = synchronized(enableRefreshLock) { + enableRefreshGeneration++ + enableRefreshJob.also { enableRefreshJob = null } + } + job?.cancel() + } + + private fun isCurrentEnableRefresh(sessionId: Long, generation: Long): Boolean = + synchronized(enableRefreshLock) { generation == enableRefreshGeneration } && + !enableRefreshBlocked && wifiSocketManager?.currentSessionId == sessionId && hasLiveTransport() && + _connectionState.value in setOf(DeviceConnectionState.READY, DeviceConnectionState.MEASURING) + + private fun scheduleEnableRefresh(packet: DctPacket) { + // A duplicate initial handshake ACK, NACK or malformed reply must not + // recursively restart initialization. Only an unsolicited live ACK qualifies. + if (runCatching { + com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.enable(), packet) + }.isFailure) return + val sessionId = wifiSocketManager?.currentSessionId ?: return + if (packet.transportSessionId != 0L && packet.transportSessionId != sessionId) return + if (measurementRequested) { + scheduleStreamRecovery("UNSOLICITED_ENABLE") + return + } + val job = synchronized(enableRefreshLock) { + if (_connectionState.value !in setOf(DeviceConnectionState.READY, DeviceConnectionState.MEASURING) || + enableRefreshBlocked || !hasLiveTransport() || enableRefreshJob?.isCompleted == false) return + val generation = ++enableRefreshGeneration + scope.launch(start = CoroutineStart.LAZY) { + fun ensureCurrent() { + if (!isCurrentEnableRefresh(sessionId, generation)) throw CancellationException("Enable refresh superseded") + } + fun diagnostic(message: String) { + android.util.Log.i("DctPacketRepository", message) + wifiSocketManager?.diagnosticEvent(sessionId, message) + } + try { + ensureCurrent() + diagnostic("ENABLE_REFRESH_START session=$sessionId") + var writtenAt = 0L + suspend fun query(request: DctPacket, version: Boolean = false) { + ensureCurrent() + val result = sendCommandWithTimeout(request.copy(transportSessionId = sessionId), responseTimeoutMs, + onWritten = if (version) ({ writtenAt = monotonicTimeMillis() }) else null) + ensureCurrent() + val response = (result as? DctResult.Success)?.data + ?: throw IllegalStateException((result as? DctResult.Failure)?.error?.message ?: "재조회 응답 누락") + com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, response) + } + query(DctProtocolRequests.version(), version = true) + // Match the PC's VERSION -> 200ms -> environment query gap + // from write completion, even if the VERSION reply is delayed. + val elapsed = (monotonicTimeMillis() - writtenAt).coerceAtLeast(0L) + kotlinx.coroutines.delay((VERSION_ENV_GAP_MS - elapsed).coerceAtLeast(0L)) + // Re-read only: do not rewrite project/CAN/SM settings, send + // ENABLE again, or change an ongoing measurement/recording. + for (request in environmentQueries()) query(request) + ensureCurrent() + diagnostic("ENABLE_REFRESH_COMPLETE session=$sessionId") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isCurrentEnableRefresh(sessionId, generation)) { + diagnostic("ENABLE_REFRESH_FAILED session=$sessionId reason=${e.message}") + } + } finally { + synchronized(enableRefreshLock) { + if (generation == enableRefreshGeneration) enableRefreshJob = null + } + } + }.also { enableRefreshJob = it } + } + // Never await the refresh in the collector: it must keep matching replies + // and sending stream ACKs while these requests are in flight. + job.start() + } + /** * Stream ACKs are normally accepted immediately. If the bounded writer * queue is briefly full, retry for a few milliseconds so a transient write @@ -737,6 +943,147 @@ class PacketRepositoryImpl( return false } + /** The measurement intent owns recovery; heartbeat/control replies never renew this clock. */ + private fun armStreamWatchdog() { + cancelStreamRecovery() + measurementRequested = true + recoveryTransportGeneration = wifiSocketManager?.recoveryGeneration ?: 0L + recoveryEndpoint = wifiSocketManager?.recoveryEndpoint + lastValidStreamMillis.set(monotonicTimeMillis()) + startStreamWatchdog() + } + + private fun startStreamWatchdog() { + if (!_receiveAutoRecoveryEnabled.value) return + val epoch = recoveryEpoch.get() + if (!hasLiveTransport()) scheduleStreamRecovery("TRANSPORT_LOST_AFTER_START") + streamWatchdogJob = scope.launch(recoveryDispatcher) { + while (isActive && measurementRequested && recoveryEpoch.get() == epoch) { + kotlinx.coroutines.delay((streamSilenceTimeoutMs / 3).coerceIn(1L, 1_000L)) + if (monotonicTimeMillis() - lastValidStreamMillis.get() >= streamSilenceTimeoutMs) { + scheduleStreamRecovery("STREAM_SILENCE") + } + } + } + } + + private fun cancelStreamRecovery() { + measurementRequested = false + cancelStreamRecoveryWork() + } + + private fun cancelStreamRecoveryWork() { + recoveryEpoch.incrementAndGet() + streamWatchdogJob?.cancel() + streamWatchdogJob = null + synchronized(recoveryLock) { + streamRecoveryJob?.cancel() + streamRecoveryJob = null + } + wifiSocketManager?.cancelRecoveryConnection(recoveryTransportGeneration) + _streamRecovery.value = StreamRecoveryState.IDLE + } + + private fun recoveryAllowed(epoch: Long, session: Long? = null): Boolean = + _receiveAutoRecoveryEnabled.value && measurementRequested && recoveryEpoch.get() == epoch && + wifiSocketManager?.recoveryGeneration == recoveryTransportGeneration && + (session == null || (hasLiveTransport() && wifiSocketManager?.currentSessionId == session)) + + private fun scheduleStreamRecovery(reason: String) { + val epoch = recoveryEpoch.get() + if (!recoveryAllowed(epoch)) return + val before = validStreamGeneration.get() + val job = synchronized(recoveryLock) { + if (streamRecoveryJob?.isCompleted == false || !recoveryAllowed(epoch)) return + scope.launch(recoveryDispatcher, start = CoroutineStart.LAZY) { + if (!recoveryAllowed(epoch)) return@launch + cancelEnableRefresh() + _isStreaming.value = false + _streamRecovery.value = StreamRecoveryState.RECOVERING + var attempt = 0 + var failedSession: Long? = null + fun diagnostic(message: String) { + android.util.Log.i("DctPacketRepository", message) + wifiSocketManager?.let { it.diagnosticEvent(it.currentSessionId, message) } + } + try { + diagnostic("STREAM_RECOVERY_START reason=$reason") + // A duplicate ENABLE during otherwise healthy streaming needs no reset. + if (reason == "UNSOLICITED_ENABLE") kotlinx.coroutines.delay(1_000) + if (!recoveryAllowed(epoch)) return@launch + if (validStreamGeneration.get() != before && hasLiveTransport()) { + _isStreaming.value = true + _streamRecovery.value = StreamRecoveryState.RECEIVING + diagnostic("STREAM_RECOVERY_COMPLETE reason=DATA_RESUMED_WITHOUT_RESET") + return@launch + } + while (isActive && recoveryAllowed(epoch)) { + attempt++ + val manager = wifiSocketManager ?: break + diagnostic("STREAM_RECOVERY_ATTEMPT attempt=$attempt") + try { + var session = manager.currentSessionId + if (!hasLiveTransport() || session == failedSession) { + val endpoint = recoveryEndpoint + check(endpoint != null) { "TCP 서버 모드는 상대 장비의 재접속을 기다립니다." } + check(manager.reconnectForRecovery(recoveryTransportGeneration, session, endpoint)) { + "TCP 재접속 실패" + } + session = manager.currentSessionId + } + fun allowed() = recoveryAllowed(epoch, session) + suspend fun query(request: DctPacket, timeout: Long = responseTimeoutMs) { + if (!allowed()) throw CancellationException("Recovery superseded") + val result = sendCommandWithTimeout(request.copy(transportSessionId = session), + timeout, isAllowed = ::allowed) + if (!allowed()) throw CancellationException("Recovery superseded") + val response = (result as? DctResult.Success)?.data + ?: throw IllegalStateException((result as? DctResult.Failure)?.error?.message ?: "응답 없음") + if (request.command != DctCommand.CMD_STOP_TRANSMIT_DATA.code || response.payload.isNotEmpty()) + com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, response) + } + // Establish a device-side boundary without ending the app's chart/recording session. + query(DctProtocolRequests.stop()) + check(performHandshake(epoch) is DctResult.Success) { "장비 재초기화 실패" } + appliedConfiguration?.let { + check(applyMeasurementConfiguration(it, epoch) is DctResult.Success) { "계측 설정 복원 실패" } + } + val dataBeforeStart = validStreamGeneration.get() + try { + query(DctProtocolRequests.startAll(), START_STREAM_RESPONSE_TIMEOUT_MS) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Real telemetry is stronger evidence than a missing START ACK. + if (!allowed() || validStreamGeneration.get() == dataBeforeStart || + monotonicTimeMillis() - lastValidStreamMillis.get() >= streamSilenceTimeoutMs) throw e + } + val received = kotlinx.coroutines.withTimeoutOrNull(recoveryDataTimeoutMs.coerceAtLeast(1)) { + while (allowed() && validStreamGeneration.get() == dataBeforeStart) kotlinx.coroutines.delay(25) + allowed() && validStreamGeneration.get() != dataBeforeStart + } == true + check(received) { "START 응답 후 실제 계측 데이터 없음" } + _isStreaming.value = true + _streamRecovery.value = StreamRecoveryState.RECEIVING + playbackTransportSessionId = session + diagnostic("STREAM_RECOVERY_COMPLETE attempt=$attempt session=$session") + break + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + failedSession = manager.currentSessionId + diagnostic("STREAM_RECOVERY_FAILED attempt=$attempt reason=${e.message}") + kotlinx.coroutines.delay((2_000L shl (attempt - 1).coerceAtMost(4)).coerceAtMost(30_000L)) + } + } + } finally { + if (recoveryEpoch.get() == epoch && !measurementRequested) _streamRecovery.value = StreamRecoveryState.IDLE + } + }.also { streamRecoveryJob = it } + } + job.start() + } + private suspend fun sendLive(packet: DctPacket): Boolean { wifiSocketManager?.let { manager -> if (manager.isConnected.value && manager.sendCommand(packet)) return true @@ -867,6 +1214,10 @@ class PacketRepositoryImpl( } private fun closeUnresponsiveSession(sessionId: Long, consecutiveFailures: Int): Boolean { + if (measurementRequested) { + scheduleStreamRecovery("HEARTBEAT_TIMEOUT") + return false + } if (playbackTransportSessionId == sessionId) { android.util.Log.w("DctPacketRepository", "재생 요청 이후 수신 지연: session=$sessionId, 정상 패킷/Heartbeat 응답이 없어도 TCP 연결을 유지합니다.") diff --git a/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt b/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt index 64824e1..79878e8 100644 --- a/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt +++ b/Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt @@ -23,6 +23,14 @@ interface PacketRepository { */ val isStreaming: StateFlow + /** Recovery completes only on a valid measurement frame, never on a heartbeat/START ACK. */ + val streamRecovery: StateFlow get() = noStreamRecovery + + val receiveAutoRecoveryEnabled: StateFlow get() = defaultReceiveAutoRecovery + + /** Controls automatic recovery only; manual commands and stream ACKs are unaffected. */ + fun setReceiveAutoRecoveryEnabled(enabled: Boolean) {} + /** * 수신된 바이너리 패킷 실시간 스트림. */ @@ -105,6 +113,11 @@ interface PacketRepository { suspend fun readXcpCharacteristic(address: Long, dataType: com.dct.app.domain.model.a2l.A2lDataType): DctResult } +enum class StreamRecoveryState { IDLE, RECOVERING, RECEIVING } +private val noStreamRecovery: StateFlow = + kotlinx.coroutines.flow.MutableStateFlow(StreamRecoveryState.IDLE) +private val defaultReceiveAutoRecovery: StateFlow = kotlinx.coroutines.flow.MutableStateFlow(false) + enum class DeviceConnectionState { DISCONNECTED, CONNECTING, diff --git a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt index 911619b..4f7b72f 100644 --- a/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt +++ b/Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt @@ -877,18 +877,19 @@ class ChartBatchRenderer( // panning would move an endpoint out of view. Switch to Manual first // when historical browsing is required. if (xAxisPreset.isHold) return - // Already following: a forward drag must not freeze or replace the live preset. - if (deltaSec > 0.0 && isXAxisAuto) return rememberNavigationHome() if (isXAxisAuto) setXAxisManualMode() val baseRight = timeViewport.currentRange?.max ?: (navigationRange().max - panOffsetSec) val latest = latestTimestamp() - val maxOffset = ((latest ?: baseRight) - baseRight).coerceAtLeast(0.0) + val wasBeforeLatest = latest != null && baseRight + panOffsetSec < latest val nextOffset = panOffsetSec + deltaSec if (!nextOffset.isFinite()) return - panOffsetSec = nextOffset.coerceAtMost(maxOffset) - val resumedFollowing = deltaSec > 0.0 && followLatestIfReached(baseRight + panOffsetSec, latest) + panOffsetSec = nextOffset + // Returning from history still resumes live following. A drag starting + // at or beyond the live edge may move into empty future time instead. + val resumedFollowing = deltaSec > 0.0 && wasBeforeLatest && + followLatestIfReached(baseRight + panOffsetSec, latest) // Measurement stop/disconnect cancels the render loop. Navigation must still publish // frames from retained samples; markDirty alone has no consumer in that state. if (resumedFollowing || renderJob?.isActive != true) { @@ -921,14 +922,16 @@ class ChartBatchRenderer( val newSpan = (oldSpan / factor).coerceIn(0.05, 86400.0) val anchor = range.min + oldSpan * anchorFraction.coerceIn(0.0, 1.0) val newMin = anchor - newSpan * anchorFraction.coerceIn(0.0, 1.0) - timeViewport.pin(newMin, newMin + newSpan, latestTimestamp(), lockToRange = true) + val latest = latestTimestamp() + val canResumeFollowing = isXAxisAuto || (latest != null && range.max <= latest) + timeViewport.pin(newMin, newMin + newSpan, latest, lockToRange = true) panOffsetSec = 0.0 timeWindowSec = newSpan isAutoTimeWindow = false isXAxisAuto = false xAxisPreset = TimeWindowPreset.MANUAL - // Keep the pinch anchor in history; snap to live only when its right edge is reached. - followLatestIfReached(newMin + newSpan, latestTimestamp()) + // A manually panned future range keeps its anchor instead of snapping back. + if (canResumeFollowing) followLatestIfReached(newMin + newSpan, latest) requestSnapshot() markDirty() } diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt index c77f5d8..67a95e8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt @@ -29,13 +29,12 @@ 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) { +internal fun WifiApConnectionPane(viewModel: MainViewModel, onDismiss: () -> Unit) { val context = LocalContext.current val state by viewModel.wifiApState.collectAsStateWithLifecycle() val ui by viewModel.uiState.collectAsStateWithLifecycle() @@ -71,20 +70,19 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { 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(releaseWifiAp = true) }, - onCurrent = viewModel::connectCurrentWifiAp, - onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss, - endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint, - communicationFailed = protocolState == com.dct.app.domain.repository.DeviceConnectionState.ERROR, - canEditEndpoint = !ui.isCommunicationConnecting && !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) - } + WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket, + onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp, + onCancel = viewModel::cancelWifiApConnection, + onDisconnect = { viewModel.disconnectCommunication(releaseWifiAp = true) }, + onCurrent = viewModel::connectCurrentWifiAp, + onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss, + endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint, + communicationFailed = protocolState == com.dct.app.domain.repository.DeviceConnectionState.ERROR, + canEditEndpoint = !ui.isCommunicationConnecting && !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, + modifier = Modifier.fillMaxSize()) } @Composable @@ -95,7 +93,8 @@ internal fun WifiApConnectionContent( onPermissions: () -> Unit, onSettings: (String) -> Unit, onDismiss: () -> Unit, endpoint: WifiApEndpoint = WifiApEndpoint(), canEditEndpoint: Boolean = true, onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null, - communicationFailed: Boolean = false + communicationFailed: Boolean = false, + modifier: Modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth().heightIn(max = 520.dp) ) { var selected by remember { mutableStateOf(null) } var addingNetwork by remember { mutableStateOf(false) } @@ -122,8 +121,8 @@ internal fun WifiApConnectionContent( "통신 주소: ${it.host}:${it.port}" } ?: "통신 주소 확인 중…" else configuredAddress Surface(shape = MaterialTheme.shapes.extraLarge, - modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth()) { - Column(Modifier.heightIn(max = 520.dp)) { + modifier = modifier) { + Column(Modifier.fillMaxSize()) { 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) @@ -180,21 +179,23 @@ internal fun WifiApConnectionContent( 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) + LazyColumn(Modifier.fillMaxWidth().weight(1f) .testTag("wifi:ap-list"), state = networkListState) { 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 }) + tag = "wifi:ap-current", onClick = { showDetails = true }, + onDisconnect = if (state.networkReady) onDisconnect else null) } 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 }) + onClick = { connectedDetailsKey = network.key }, + onDisconnect = { onSettings(Settings.ACTION_WIFI_SETTINGS) }) } item(key = "available-title") { Text("사용 가능한 네트워크", @@ -312,11 +313,6 @@ internal fun WifiApConnectionContent( Modifier.testTag("wifi:ap-endpoint")) } }, - dismissButton = { - if (state.networkReady) TextButton(onClick = { showDetails = false; onDisconnect() }) { - Text("연결 해제") - } - }, confirmButton = { Row { TextButton(enabled = endpointEditable, onClick = { @@ -376,7 +372,8 @@ internal fun WifiApConnectionContent( @Composable private fun WifiApNetworkRow( ssid: String, status: String? = null, secured: Boolean, enabled: Boolean, - tag: String, onClick: () -> Unit, address: String? = null + tag: String, onClick: () -> Unit, address: String? = null, + onDisconnect: (() -> Unit)? = null ) { Row(Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp).testTag(tag), @@ -395,5 +392,10 @@ private fun WifiApNetworkRow( } if (secured) Icon(Icons.Default.Lock, contentDescription = "보안 네트워크", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + onDisconnect?.let { disconnect -> + TextButton(onClick = disconnect, modifier = Modifier.testTag("$tag:disconnect")) { + Text("연결 해제", style = MaterialTheme.typography.labelMedium) + } + } } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiConnectionDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiConnectionDialog.kt new file mode 100644 index 0000000..a1be8a5 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiConnectionDialog.kt @@ -0,0 +1,62 @@ +package com.dct.app.presentation.dialog + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.dct.app.data.communication.WifiConnectionMode +import com.dct.app.presentation.main.MainViewModel + +@Composable +fun WifiConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { + val mode by viewModel.wifiConnectionMode.collectAsState() + val canChange by viewModel.canChangeWifiConnectionMode.collectAsState() + WifiConnectionDialogFrame(mode, canChange, { viewModel.setWifiConnectionMode(it) }, onDismiss) { + when (mode) { + WifiConnectionMode.TCP -> WifiApConnectionPane(viewModel, onDismiss) + WifiConnectionMode.DIRECT -> WifiDirectDeviceListPane(viewModel, onDismiss) + } + } +} + +/** Keep the window, size and tabs stable when only the selected network pane changes. */ +@Composable +internal fun WifiConnectionDialogFrame(mode: WifiConnectionMode, canChange: Boolean, + onSelect: (WifiConnectionMode) -> Unit, onDismiss: () -> Unit, content: @Composable () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Surface(shape = MaterialTheme.shapes.extraLarge, + modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth() + .heightIn(max = 560.dp).fillMaxHeight().testTag("wifi:dialog")) { + Column(Modifier.fillMaxSize()) { + WifiConnectionModeSelector(mode, canChange, onSelect) + Box(Modifier.weight(1f).fillMaxWidth()) { content() } + } + } + } +} + +@Composable +internal fun WifiConnectionModeSelector(mode: WifiConnectionMode, enabled: Boolean, + onSelect: (WifiConnectionMode) -> Unit) { + Column { + TabRow(selectedTabIndex = mode.ordinal) { + WifiConnectionMode.entries.forEach { option -> + Tab(selected = mode == option, enabled = enabled || mode == option, + onClick = { onSelect(option) }, text = { Text(option.label) }, + modifier = Modifier.testTag("wifi-mode:${option.name}")) + } + } + if (!enabled) Text("Wi-Fi 해제 후 전환할 수 있습니다.", + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt index 27e3b7b..81a3598 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt @@ -13,25 +13,35 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog +import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dct.app.data.communication.* import com.dct.app.presentation.main.MainViewModel /** App-owned Material 3 UI; OS commands go exclusively through the ViewModel. */ @Composable -fun WifiDirectDeviceListDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { +internal fun WifiDirectDeviceListPane(viewModel: MainViewModel, onDismiss: () -> Unit) { val context = LocalContext.current val state by viewModel.wifiDirectState.collectAsStateWithLifecycle() - val mainState by viewModel.uiState.collectAsStateWithLifecycle() - val tcpConnected by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle() + val port by viewModel.wifiDirectPort.collectAsStateWithLifecycle() + val canEditPort by viewModel.canEditWifiDirectPort.collectAsStateWithLifecycle() + var showPortSettings by remember { mutableStateOf(false) } var searchOnReady by remember { mutableStateOf(true) } var permissionRequested by remember { mutableStateOf(false) } var settingsError by remember { mutableStateOf(null) } val permissions = remember { WifiDirectRequirements.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.refreshWifiDirect() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { viewModel.refreshWifiDirect() searchOnReady = it.values.all { granted -> granted } @@ -62,91 +72,125 @@ fun WifiDirectDeviceListDialog(viewModel: MainViewModel, onDismiss: () -> Unit) // Closing the sheet does not cancel an invitation or remove a formed group. onDispose { viewModel.stopWifiDiscovery() } } - Dialog(onDismissRequest = onDismiss) { - Surface(shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.widthIn(max = 560.dp).fillMaxWidth()) { - Column(Modifier.padding(24.dp).heightIn(max = 660.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Wi-Fi Direct", style = MaterialTheme.typography.headlineSmall) - state.thisDevice?.let { Text("내 기기: ${it.deviceName}", style = MaterialTheme.typography.bodySmall) } - if (state.busy || state.discovering) LinearProgressIndicator(Modifier.fillMaxWidth()) - Text(when (state.phase) { - WifiDirectPhase.CHECKING -> "Wi-Fi Direct 상태 확인 중..." - WifiDirectPhase.UNSUPPORTED -> "이 기기는 Wi-Fi Direct를 지원하지 않습니다." - WifiDirectPhase.DISABLED -> "Wi-Fi가 꺼져 있습니다." - WifiDirectPhase.PERMISSION_REQUIRED -> "주변 기기 권한이 필요합니다." - WifiDirectPhase.CONNECTING -> "연결 중... 상대 기기의 연결 요청을 승인해 주세요." - WifiDirectPhase.CANCELLING -> "연결 취소 확인 중..." - WifiDirectPhase.DISCONNECTING -> "연결 해제 확인 중..." - WifiDirectPhase.CONNECTED -> "연결됨" - WifiDirectPhase.DISCOVERING -> "검색 중..." - WifiDirectPhase.ERROR -> "작업을 완료하지 못했습니다." - WifiDirectPhase.IDLE -> "연결할 기기를 선택하세요." - }, Modifier.testTag("wifi:state")) - (settingsError ?: state.error)?.let { Text(it, color = MaterialTheme.colorScheme.error) } - if (state.supported && !state.permissionGranted) { - Row { - TextButton(onClick = { permissionLauncher.launch(permissions) }) { Text("권한 허용") } - TextButton(onClick = { settings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, true) }) { Text("앱 설정") } - } - } else if (state.supported && !state.enabled) { - TextButton(onClick = { settings(Settings.ACTION_WIFI_SETTINGS) }) { Text("Wi-Fi 설정") } + WifiDirectDeviceListContent(state.copy(error = settingsError ?: state.error), port, canEditPort, + onPortSettings = { showPortSettings = true }, onPermissions = { permissionLauncher.launch(permissions) }, + onSettings = { settings(it, it == Settings.ACTION_APPLICATION_DETAILS_SETTINGS) }, + onDisconnect = viewModel::removeWifiDirectGroup, onCancel = viewModel::cancelWifiDirectConnection, + onConnect = viewModel::connectWifiDirect, + onRefresh = { searchOnReady = true; viewModel.refreshWifiDirect() }, onDismiss = onDismiss) + if (showPortSettings) WifiDirectPortDialog(port, canEditPort, viewModel::setWifiDirectPort) { showPortSettings=false } +} + +@Composable +internal fun WifiDirectDeviceListContent(state: WifiDirectUiState, port: Int, canEditPort: Boolean, + onPortSettings: () -> Unit, onPermissions: () -> Unit, onSettings: (String) -> Unit, + onDisconnect: () -> Unit, onCancel: () -> Unit, onConnect: (WifiP2pDevice) -> Unit, + onRefresh: () -> Unit, onDismiss: () -> Unit) { + val connectedAddresses = state.link?.peers.orEmpty().map { it.deviceAddress }.toSet() + val availablePeers = state.peers.filterNot { it.deviceAddress in connectedAddresses } + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Wi-Fi Direct", style = MaterialTheme.typography.titleMedium) + Text("TCP 포트: $port", style = MaterialTheme.typography.bodySmall) + } + TextButton(onClick = onPortSettings, enabled = canEditPort) { + Text("포트 설정", style = MaterialTheme.typography.labelMedium) + } + } + if (state.busy || state.discovering) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (state.link == null) Text(when (state.phase) { + WifiDirectPhase.CHECKING -> "Wi-Fi Direct 상태 확인 중..." + WifiDirectPhase.UNSUPPORTED -> "이 기기는 Wi-Fi Direct를 지원하지 않습니다." + WifiDirectPhase.DISABLED -> "Wi-Fi가 꺼져 있습니다." + WifiDirectPhase.PERMISSION_REQUIRED -> "주변 기기 권한이 필요합니다." + WifiDirectPhase.CONNECTING -> "연결 중..." + WifiDirectPhase.CANCELLING -> "연결 취소 확인 중..." + WifiDirectPhase.DISCONNECTING -> "연결 해제 확인 중..." + WifiDirectPhase.CONNECTED -> "연결됨" + WifiDirectPhase.DISCOVERING -> "검색 중..." + WifiDirectPhase.ERROR -> "작업을 완료하지 못했습니다." + WifiDirectPhase.IDLE -> "연결 대기" + }, Modifier.testTag("wifi:state"), style = MaterialTheme.typography.bodySmall) + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) } + if (state.supported && !state.permissionGranted) { + Row { + TextButton(onClick = onPermissions) { Text("권한 허용", style = MaterialTheme.typography.labelMedium) } + TextButton(onClick = { onSettings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) }) { + Text("앱 설정", style = MaterialTheme.typography.labelMedium) } - if (state.permissionGranted && !state.locationEnabled) { - Text("기기 검색을 위해 위치 서비스를 켜 주세요.") - TextButton(onClick = { settings(Settings.ACTION_LOCATION_SOURCE_SETTINGS) }) { Text("위치 설정") } + } + } else if (state.supported && !state.enabled) { + TextButton(onClick = { onSettings(Settings.ACTION_WIFI_SETTINGS) }) { + Text("Wi-Fi 설정", style = MaterialTheme.typography.labelMedium) + } + } + if (state.permissionGranted && !state.locationEnabled) { + Text("기기 검색을 위해 위치 서비스를 켜 주세요.", style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { onSettings(Settings.ACTION_LOCATION_SOURCE_SETTINGS) }) { + Text("위치 설정", style = MaterialTheme.typography.labelMedium) + } + } + state.link?.let { link -> + Row(Modifier.fillMaxWidth().testTag("wifi:direct-current"), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(link.displayName, style = MaterialTheme.typography.bodyMedium, + maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(if (state.phase == WifiDirectPhase.DISCONNECTING) "연결 해제 중…" else "연결됨", + Modifier.testTag("wifi:state"), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary) } - state.link?.let { link -> - Text(link.displayName, style = MaterialTheme.typography.titleMedium) - Text("Group Owner IP: ${link.info.groupOwnerAddress?.hostAddress ?: "할당 대기 중"}") - Text("역할: ${if (link.info.isGroupOwner) "Group Owner" else "Client"}") - Text("인터페이스: ${link.group.`interface` ?: "확인 중"}", style = MaterialTheme.typography.bodySmall) - Text(when { - mainState.isConnected -> "DCT 통신 준비 완료 · 포트 ${WifiSocketManager.DEFAULT_PORT}" - tcpConnected -> "DCT 장비 초기화 중..." - else -> "DCT 통신 연결 대기 · 포트 ${WifiSocketManager.DEFAULT_PORT}" - }) - Row { - TextButton(onClick = viewModel::removeWifiDirectGroup, - enabled = state.phase != WifiDirectPhase.DISCONNECTING) { Text("연결 해제") } - if (!mainState.isConnected) TextButton(onClick = viewModel::retryWifiDirectTcp, - enabled = state.phase == WifiDirectPhase.CONNECTED) { Text("통신 재연결") } - } + TextButton(onClick = onDisconnect, modifier = Modifier.testTag("wifi:direct-disconnect"), + enabled = state.phase != WifiDirectPhase.DISCONNECTING) { + Text("연결 해제", style = MaterialTheme.typography.labelMedium) } - if (state.phase in setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING)) { - Text(state.target?.deviceName?.ifBlank { state.target?.deviceAddress.orEmpty() } ?: "상대 기기") - TextButton(onClick = viewModel::cancelWifiDirectConnection, - enabled = state.phase == WifiDirectPhase.CONNECTING) { Text("취소") } + } + } + if (state.phase in setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(state.target?.deviceName?.ifBlank { state.target?.deviceAddress.orEmpty() } ?: "상대 기기", + Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, + maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = onCancel, enabled = state.phase == WifiDirectPhase.CONNECTING) { + Text("취소", style = MaterialTheme.typography.labelMedium) } - HorizontalDivider() - LazyColumn(Modifier.weight(1f, fill = false).heightIn(min = 80.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (state.peers.isEmpty()) item { Text("주변 Wi-Fi Direct 기기를 찾을 수 없습니다.") } - items(state.peers, key = { it.deviceAddress }) { device -> - val connected = state.link?.peers?.any { it.deviceAddress == device.deviceAddress } == true - val pending = state.target?.deviceAddress == device.deviceAddress && state.busy - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column(Modifier.weight(1f)) { - Text(device.deviceName.ifBlank { device.deviceAddress }, style = MaterialTheme.typography.titleSmall) - Text(when { - connected -> "연결됨" - pending || device.status == WifiP2pDevice.INVITED -> "연결 중..." - device.status == WifiP2pDevice.FAILED -> "연결 실패 · 재시도 가능" - device.status == WifiP2pDevice.UNAVAILABLE -> "사용 불가" - device.status == WifiP2pDevice.CONNECTED -> "그룹 정보 확인 중..." - else -> "사용 가능" - }, style = MaterialTheme.typography.bodySmall) - } - if (!connected && !pending) TextButton( - onClick = { viewModel.connectWifiDirect(device) }, - enabled = state.canConnect && device.status in setOf(WifiP2pDevice.AVAILABLE, WifiP2pDevice.FAILED) - ) { Text(if (device.status == WifiP2pDevice.FAILED) "재시도" else "연결") } - } + } + } + HorizontalDivider() + LazyColumn(Modifier.weight(1f).fillMaxWidth().testTag("wifi:direct-list"), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (availablePeers.isEmpty()) item { + Text("주변 Wi-Fi Direct 기기를 찾을 수 없습니다.", style = MaterialTheme.typography.bodySmall) + } + items(availablePeers, key = { it.deviceAddress }) { device -> + val pending = state.target?.deviceAddress == device.deviceAddress && state.busy + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(device.deviceName.ifBlank { device.deviceAddress }, style = MaterialTheme.typography.bodyMedium, + maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(when { + pending || device.status == WifiP2pDevice.INVITED -> "연결 중..." + device.status == WifiP2pDevice.FAILED -> "연결 실패 · 재시도 가능" + device.status == WifiP2pDevice.UNAVAILABLE -> "사용 불가" + device.status == WifiP2pDevice.CONNECTED -> "그룹 정보 확인 중..." + else -> "사용 가능" + }, style = MaterialTheme.typography.bodySmall) } + if (!pending) TextButton( + onClick = { onConnect(device) }, + enabled = state.canConnect && device.status in setOf(WifiP2pDevice.AVAILABLE, WifiP2pDevice.FAILED) + ) { Text(if (device.status == WifiP2pDevice.FAILED) "재시도" else "연결", + style = MaterialTheme.typography.labelMedium) } } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { searchOnReady = true; viewModel.refreshWifiDirect() }, - enabled = state.supported && !state.busy && !state.discovering) { Text("새로고침") } - TextButton(onClick = onDismiss) { Text("닫기") } - } } } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onRefresh, enabled = state.supported && !state.busy && !state.discovering) { + Text("새로고침", style = MaterialTheme.typography.labelMedium) + } + TextButton(onClick = onDismiss) { Text("닫기", style = MaterialTheme.typography.labelMedium) } + } } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectPortDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectPortDialog.kt new file mode 100644 index 0000000..f7b3311 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectPortDialog.kt @@ -0,0 +1,40 @@ +package com.dct.app.presentation.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import com.dct.app.data.communication.WifiConnectionInput + +@Composable +internal fun WifiDirectPortDialog(port: Int, canEdit: Boolean, + onSave: (Int) -> Boolean, onDismiss: () -> Unit) { + var draft by remember { mutableStateOf(port.toString()) } + var saveRejected by remember { mutableStateOf(false) } + val parsed = WifiConnectionInput(port=draft).portNumber + AlertDialog( + onDismissRequest=onDismiss, + title={ Text("Direct 포트 설정") }, + text={ Column { + Text("장치와 같은 TCP 포트 번호를 입력하세요.", style=MaterialTheme.typography.bodySmall) + OutlinedTextField(value=draft, onValueChange={ draft=it; saveRejected=false }, + label={ Text("TCP 포트") }, singleLine=true, isError=parsed == null, + keyboardOptions=KeyboardOptions(keyboardType=KeyboardType.Number), + modifier=Modifier.testTag("wifi-direct-port")) + if (parsed == null) Text("1~65535 사이의 숫자를 입력하세요.", color=MaterialTheme.colorScheme.error) + if (saveRejected) Text("통신 상태가 변경되어 저장하지 못했습니다. 연결을 해제한 뒤 다시 시도하세요.", + color=MaterialTheme.colorScheme.error) + } }, + confirmButton={ TextButton(enabled=canEdit && parsed != null, onClick={ + parsed?.let { if (onSave(it)) onDismiss() else saveRejected=true } + }) { Text("저장") } }, + dismissButton={ TextButton(onClick=onDismiss) { Text("취소") } } + ) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/DeveloperOptionsAccess.kt b/Android/app/src/main/java/com/dct/app/presentation/main/DeveloperOptionsAccess.kt new file mode 100644 index 0000000..3fc371f --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/main/DeveloperOptionsAccess.kt @@ -0,0 +1,23 @@ +package com.dct.app.presentation.main + +/** Owned by the current app session, including rotation and temporary backgrounding. */ +class DeveloperOptionsAccess { + private var unlocked = false + private var taps = 0 + private var lastTapMillis: Long? = null + + fun lock() { + unlocked = false + taps = 0 + lastTapMillis = null + } + + fun onTitleTap(nowMillis: Long): Boolean { + if (unlocked) return true + val previous = lastTapMillis + taps = if (previous != null && nowMillis - previous in 0L..1_000L) taps + 1 else 1 + lastTapMillis = nowMillis + if (taps == 5) unlocked = true + return unlocked + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt index d1f7af7..c776d78 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt @@ -135,6 +135,8 @@ class MainActivity : ComponentActivity() { } override fun onDestroy() { + // Closing the UI task relocks entry even if a foreground measurement retains the runtime. + if (isFinishing) activitySession.viewModel.lockDeveloperOptions() super.onDestroy() if (isFinishing && programExitRequested) { (application as DctApplication).releaseSession(activitySession) diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt index 543ff5b..5680b3f 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt @@ -89,6 +89,8 @@ data class MainUiState( val projectFileName: String? = null, val isMdbLoading: Boolean = false, val isMeasuring: Boolean = false, + val measurementStartedAtElapsedMillis: Long? = null, + val isStreamRecovering: Boolean = false, val recordingState: MeasurementRecordingState = MeasurementRecordingState.IDLE, val recordingStartedAtMillis: Long? = null, val recordingSampleCount: Long = 0L, @@ -122,9 +124,23 @@ class MainViewModel( ), val pipelineMetrics: PacketPipelineMetrics = PacketPipelineMetrics(), private val ensureBackgroundExecution: () -> Boolean = { true }, - private val operationalLogger: com.dct.app.data.logger.PcOperationalLogger? = null + private val operationalLogger: com.dct.app.data.logger.PcOperationalLogger? = null, + private val developerOptionsPreferences: com.dct.app.data.preferences.DeveloperOptionsPreferences? = null, + private val measurementClock: () -> Long = android.os.SystemClock::elapsedRealtime ) : ViewModel() { + private val developerOptionsAccess = DeveloperOptionsAccess() + val receiveAutoRecoveryEnabled: StateFlow = packetRepository.receiveAutoRecoveryEnabled + + fun onDctTitleClick(): Boolean = developerOptionsAccess.onTitleTap(android.os.SystemClock.elapsedRealtime()) + + fun lockDeveloperOptions() = developerOptionsAccess.lock() + + fun setReceiveAutoRecoveryEnabled(enabled: Boolean) { + developerOptionsPreferences?.setReceiveAutoRecoveryEnabled(enabled) + packetRepository.setReceiveAutoRecoveryEnabled(enabled) + } + val scope: CoroutineScope = CoroutineScope(dispatchers.mainImmediate + SupervisorJob()) private val calibrationMutex = kotlinx.coroutines.sync.Mutex() private var calibrationJob: Job? = null @@ -232,6 +248,19 @@ class MainViewModel( } } } + scope.launch(dispatchers.mainImmediate) { + packetRepository.streamRecovery.collect { recovery -> + val recovering = recovery == com.dct.app.domain.repository.StreamRecoveryState.RECOVERING + if (recovering) markChartDataGap() + _uiState.update { current -> + current.copy(isStreamRecovering = recovering, statusMessage = when (recovery) { + com.dct.app.domain.repository.StreamRecoveryState.RECOVERING -> "계측 데이터 수신 복구 중입니다." + com.dct.app.domain.repository.StreamRecoveryState.RECEIVING -> "계측 데이터 수신이 복구되었습니다." + else -> current.statusMessage + }) + } + } + } scope.launch(dispatchers.mainImmediate) { packetRepository.connectionState.collect { state -> val transportReady = state == DeviceConnectionState.READY || state == DeviceConnectionState.MEASURING @@ -759,6 +788,12 @@ class MainViewModel( /** Debug-only display reset; never changes device settings, recording or project bindings. */ fun resetDebugValues() { if (!com.dct.app.BuildConfig.DEBUG) return + resetMeasuredValues(resetPacketCount = true) + setStatusMessage("값을 초기화했습니다. 계측 중에는 다음 수신부터 다시 표시됩니다.") + } + + /** Reset samples and live caches together while retaining the project and signal bindings. */ + private fun resetMeasuredValues(resetPacketCount: Boolean) { // A packet must finish as a whole before its values and graph samples are cleared. synchronized(telemetryResetLock) { clearFormulaTelemetry() @@ -766,12 +801,11 @@ class MainViewModel( resetGraphData() _uiState.update { current -> current.copy( - packetCount = 0L, + packetCount = if (resetPacketCount) 0L else current.packetCount, dashboardTiles = current.dashboardTiles.map { tile -> tile.historyBuffer.clear() tile.copy(value = 0.0) - }, - statusMessage = "값을 초기화했습니다. 계측 중에는 다음 수신부터 다시 표시됩니다." + } ) } } @@ -1599,6 +1633,7 @@ class MainViewModel( projectFileName = null, isMeasuring = false, packetCount = 0L, + measurementStartedAtElapsedMillis = null, dashboardTiles = emptyList(), currentSelection = null, statusMessage = null @@ -1656,7 +1691,9 @@ class MainViewModel( } wifiSocketManager.startDualMode( isGroupOwner = isGroupOwner, hostIp = hostIp, port = port, - onConnected = { _, remoteAddress -> + onConnected = connected@{ _, remoteAddress -> + // A returning server-mode peer belongs to the active recovery owner. + if (packetRepository.connectionState.value == DeviceConnectionState.MEASURING) return@connected launchSessionControl { sessionMutex.withLock { if (generation != connectionGeneration.get()) return@withLock @@ -1717,28 +1754,90 @@ class MainViewModel( val wifiApState = _wifiApState.asStateFlow() private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint()) val wifiApEndpoint = _wifiApEndpoint.asStateFlow() - val canConnectCommunication = combine( - _wifiApState, _wifiApEndpoint, _uiState, wifiSocketManager.isConnected - ) { ap, endpoint, ui, socket -> canStartWifiCommunication(ap, endpoint, ui, socket) } + private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState()) + val wifiDirectState: StateFlow = _wifiDirectState.asStateFlow() + private val _wifiConnectionMode = MutableStateFlow(com.dct.app.data.communication.WifiConnectionMode.TCP) + val wifiConnectionMode = _wifiConnectionMode.asStateFlow() + private val _wifiDirectPort = MutableStateFlow(com.dct.app.data.communication.WifiConnectionPreferences.DEFAULT_DIRECT_PORT) + val wifiDirectPort = _wifiDirectPort.asStateFlow() + val canEditWifiDirectPort = combine(_uiState, wifiSocketManager.isConnected) { ui, socket -> + isWifiCommunicationIdle(ui, socket) + }.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false) + private val wifiRouteReady = combine(_wifiApState, _wifiApEndpoint, _wifiDirectState, _wifiConnectionMode) { ap, endpoint, direct, mode -> + if (mode == com.dct.app.data.communication.WifiConnectionMode.TCP) isApCommunicationReady(ap, endpoint) + else isDirectCommunicationReady(direct) + } + val canConnectCommunication = combine(wifiRouteReady, _uiState, wifiSocketManager.isConnected) { ready, ui, socket -> + ready && isWifiCommunicationIdle(ui, socket) + } .stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false) + val canChangeWifiConnectionMode = combine(_wifiApState, _wifiDirectState, _wifiConnectionMode, + _uiState, wifiSocketManager.isConnected) { ap, direct, mode, ui, socket -> + canChangeWifiMode(ap, direct, mode, ui, socket) + }.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false) private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null - private fun canStartWifiCommunication( + private fun isApCommunicationReady( ap: com.dct.app.data.communication.WifiApUiState, - endpoint: com.dct.app.data.communication.WifiApEndpoint, - ui: MainUiState, - socket: Boolean + endpoint: com.dct.app.data.communication.WifiApEndpoint ): Boolean = ap.enabled && ap.permissionGranted && !ap.busy && endpoint.isValid && - (ap.networkReady || ap.connectedNetworks.count { it.localAddress != null } == 1) && + (ap.networkReady || ap.connectedNetworks.count { it.localAddress != null } == 1) + + private fun isDirectCommunicationReady(direct: com.dct.app.data.communication.WifiDirectUiState): Boolean = + direct.enabled && direct.permissionGranted && direct.synchronizedWithOs && + direct.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED && direct.link?.info?.groupOwnerAddress != null + + private fun isWifiCommunicationIdle(ui: MainUiState, socket: Boolean): Boolean = !ui.isConnected && !ui.isCommunicationConnecting && !socket && !ui.isMeasuring && ui.recordingState !in setOf(MeasurementRecordingState.STARTING, MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) + private fun canChangeWifiMode(ap: com.dct.app.data.communication.WifiApUiState, + direct: com.dct.app.data.communication.WifiDirectUiState, mode: com.dct.app.data.communication.WifiConnectionMode, + ui: MainUiState, socket: Boolean): Boolean = isWifiCommunicationIdle(ui, socket) && + if (mode == com.dct.app.data.communication.WifiConnectionMode.TCP) !ap.busy && !ap.networkReady + else direct.link == null && direct.phase !in setOf(com.dct.app.data.communication.WifiDirectPhase.CONNECTING, + com.dct.app.data.communication.WifiDirectPhase.CANCELLING, com.dct.app.data.communication.WifiDirectPhase.DISCONNECTING) + + fun setWifiConnectionMode(mode: com.dct.app.data.communication.WifiConnectionMode): Boolean { + if (mode == _wifiConnectionMode.value) return true + if (!canChangeWifiMode(_wifiApState.value, _wifiDirectState.value, _wifiConnectionMode.value, + _uiState.value, wifiSocketManager.isConnected.value)) return false + stopWifiDiscovery() + _wifiConnectionMode.value = mode + lastP2pKey = _wifiDirectState.value.link?.key + configureWifiRoutes() + return true + } + + private fun configureWifiRoutes() { + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP) { + wifiSocketManager.setWifiDirectRouteProvider(null) + wifiSocketManager.setWifiApRouteProvider(registeredWifiApManager?.let { manager -> { manager.currentRoute() } }) + wifiSocketManager.setRecoveryNetworkProvider(registeredWifiApManager?.let { manager -> { + packetRepository.streamRecovery.value == com.dct.app.domain.repository.StreamRecoveryState.RECOVERING && + manager.recoverLastConnection() + } }) + } else { + wifiSocketManager.setWifiApRouteProvider(null) + wifiSocketManager.setRecoveryNetworkProvider(null) + wifiSocketManager.setWifiDirectRouteProvider(registeredWifiDirectManager?.let { manager -> { manager.currentP2pRoute() } }) + } + } + /** Only the ribbon Connect action may start TCP and the DCT handshake. */ fun connectCommunication() { + if (!isWifiCommunicationIdle(_uiState.value, wifiSocketManager.isConnected.value)) return + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT) { + val direct = _wifiDirectState.value + if (!isDirectCommunicationReady(direct)) return + val link = direct.link ?: return + connectWifi(link.displayName, link.info.groupOwnerAddress.hostAddress!!, + port = _wifiDirectPort.value, isGroupOwner = link.info.isGroupOwner) + return + } val manager = registeredWifiApManager ?: return - if (!canStartWifiCommunication(manager.uiState.value, _wifiApEndpoint.value, - _uiState.value, wifiSocketManager.isConnected.value)) return + if (!isApCommunicationReady(manager.uiState.value, _wifiApEndpoint.value)) return // Adopting a Settings-connected network is synchronous when IPv4 is ready. // onReady itself deliberately never starts protocol traffic. if (!manager.uiState.value.networkReady) manager.connectCurrent() @@ -1748,6 +1847,13 @@ class MainViewModel( connectWifi(ap.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port) } + fun setWifiDirectPort(port: Int): Boolean { + if (port !in 1..65535 || !isWifiCommunicationIdle(_uiState.value, wifiSocketManager.isConnected.value)) return false + wifiEndpointPreferences?.saveDirectPort(port) + _wifiDirectPort.value = port + return true + } + fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean { if (!endpoint.isValid || _uiState.value.isCommunicationConnecting || _uiState.value.isMeasuring || _uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING, @@ -1758,7 +1864,7 @@ class MainViewModel( return true } - /** ESP32 AP is the production entry. P2P implementation below is retained, not initialized. */ + /** Observe AP state without starting TCP or the device protocol. */ fun initializeWifiAp(context: Context) { storageContext = context.applicationContext if (registeredWifiApManager != null) return @@ -1767,18 +1873,27 @@ class MainViewModel( com.dct.app.data.communication.WifiConnectionPreferences.FILE_NAME, Context.MODE_PRIVATE)) _wifiApEndpoint.value = com.dct.app.data.communication.WifiApEndpoint.fromInput(wifiEndpointPreferences!!.load()) ?: com.dct.app.data.communication.WifiApEndpoint() + _wifiDirectPort.value = wifiEndpointPreferences!!.loadDirectPort() scope.launch(dispatchers.io) { runCatching { com.dct.app.data.logger.MeasurementSafExports.exportReady(context.applicationContext) } .onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) } } val manager = com.dct.app.data.communication.WifiApManager(context.applicationContext, onReady = { /* Wi-Fi/IP readiness alone must not start TCP or the DCT protocol. */ }, - onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") }, - onConnecting = { closeWifiTransportAfterGroupLoss(null) }, + onLost = lost@{ + if (_wifiConnectionMode.value != com.dct.app.data.communication.WifiConnectionMode.TCP) return@lost + if (packetRepository.connectionState.value == DeviceConnectionState.MEASURING) { + // Preserve charts/recorders while the measurement owner restores its AP/TCP route. + wifiSocketManager.pauseForNetworkRecovery() + } else closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") + }, + onConnecting = { + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP) + closeWifiTransportAfterGroupLoss(null) + }, endpointProvider = { _wifiApEndpoint.value }) registeredWifiApManager = manager - wifiSocketManager.setWifiDirectRouteProvider(null) - wifiSocketManager.setWifiApRouteProvider(manager::currentRoute) + configureWifiRoutes() apObserver = scope.launch { manager.uiState.collect { _wifiApState.value = it } } manager.start() } @@ -1786,30 +1901,30 @@ class MainViewModel( fun refreshWifiAp() { registeredWifiApManager?.refresh() } fun scanWifiAp() { registeredWifiApManager?.scan() } fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) { - if (registeredWifiApManager?.uiState?.value?.canScan == true) + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP && + registeredWifiApManager?.uiState?.value?.canScan == true) registeredWifiApManager?.connect(device, password) } fun connectCurrentWifiAp() { - registeredWifiApManager?.connectCurrent() + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP) + registeredWifiApManager?.connectCurrent() } fun cancelWifiApConnection() { registeredWifiApManager?.disconnect() closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.") } - private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState()) - val wifiDirectState: StateFlow = _wifiDirectState.asStateFlow() private var p2pObserver: Job? = null private var lastP2pKey: String? = null /** Application context only; one manager and one receiver for this retained ViewModel. */ fun initializeWifiDirect(context: Context) { storageContext = context.applicationContext + if (registeredWifiDirectManager != null) return scope.launch(dispatchers.io) { runCatching { com.dct.app.data.logger.MeasurementSafExports.exportReady(context.applicationContext) } .onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) } } - if (registeredWifiDirectManager != null) return registerWifiDirectManager(com.dct.app.data.communication.WifiDirectManager(context.applicationContext)) registeredWifiDirectManager?.start() } @@ -1819,37 +1934,36 @@ class MainViewModel( fun stopWifiDiscovery() { registeredWifiDirectManager?.stopPeerDiscovery() } fun connectWifiDirect(device: android.net.wifi.p2p.WifiP2pDevice) { val manager = registeredWifiDirectManager ?: return - if (manager.uiState.value.canConnect && ensureBackgroundExecution()) manager.connect(device) + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT && + isWifiCommunicationIdle(_uiState.value, wifiSocketManager.isConnected.value) && + manager.uiState.value.canConnect && ensureBackgroundExecution()) manager.connect(device) } fun cancelWifiDirectConnection() { registeredWifiDirectManager?.cancelConnect() } - fun removeWifiDirectGroup() { registeredWifiDirectManager?.disconnect() } + fun removeWifiDirectGroup() { + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT) + disconnectCommunication(releaseWifiAp = true) + } fun retryWifiDirectTcp() { - val state = wifiDirectState.value - val link = state.link ?: return - if (state.synchronizedWithOs && state.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED) - link.info.groupOwnerAddress?.hostAddress?.let { connectWifi(link.displayName, it, isGroupOwner = link.info.isGroupOwner) } + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT) connectCommunication() } fun registerWifiDirectManager(manager: com.dct.app.data.communication.WifiDirectManager) { if (registeredWifiDirectManager === manager) return p2pObserver?.cancel() registeredWifiDirectManager = manager - // A future chip migration can restore the P2P initialization/UI entry. - wifiSocketManager.setWifiApRouteProvider(null) - wifiSocketManager.setWifiDirectRouteProvider { manager.currentP2pRoute() } + configureWifiRoutes() p2pObserver = scope.launch { manager.uiState.collect { state -> _wifiDirectState.value = state if (!state.synchronizedWithOs) return@collect val link = state.link - if (link != null && state.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED && - link.info.groupOwnerAddress != null && link.key != lastP2pKey) { - lastP2pKey = link.key - connectWifi(link.displayName, link.info.groupOwnerAddress.hostAddress!!, isGroupOwner = link.info.isGroupOwner) - } else if (link == null && lastP2pKey != null) { - lastP2pKey = null - closeWifiTransportAfterGroupLoss() + if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT && + lastP2pKey != null && link?.key != lastP2pKey && + (_uiState.value.isConnected || _uiState.value.isCommunicationConnecting || + _uiState.value.isMeasuring || wifiSocketManager.isConnected.value)) { + closeWifiTransportAfterGroupLoss("Wi-Fi Direct 그룹 연결이 해제되거나 변경되었습니다.") } + lastP2pKey = link?.key } } } @@ -1880,15 +1994,9 @@ class MainViewModel( */ @Synchronized fun disconnectCommunication(context: Context? = null, releaseWifiAp: Boolean = false): Job? { - if (releaseWifiAp && registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect() - registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager -> - when { - manager.uiState.value.link != null -> manager.disconnect() - manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect() - else -> closeWifiTransportAfterGroupLoss() - } - return null - } + val mode = _wifiConnectionMode.value + if (releaseWifiAp && mode == com.dct.app.data.communication.WifiConnectionMode.TCP && + registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect() val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately. cancelSessionControls() pendingWifiTarget.set(null) @@ -1906,7 +2014,14 @@ class MainViewModel( finishMeasurementSession() wifiSocketManager.disconnectSuspend() // Keep the AP lease until STOP and log finalization have completed. - if (releaseWifiAp) registeredWifiApManager?.disconnect() + if (releaseWifiAp) { + if (mode == com.dct.app.data.communication.WifiConnectionMode.DIRECT) { + val direct = registeredWifiDirectManager + if (direct?.uiState?.value?.link != null) direct.disconnect() + else if (direct?.uiState?.value?.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING) + direct.cancelConnect() + } else registeredWifiApManager?.disconnect() + } _uiState.update { it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") @@ -3241,6 +3356,9 @@ class MainViewModel( private val activeCsvLoggers = java.util.concurrent.CopyOnWriteArrayList() private val activeMf4Loggers = java.util.concurrent.CopyOnWriteArrayList() + // Protected by recordingSampleLock. CAN files use the PC's last-page boundary, independently of other sources. + private var activeCanCsvLogger: com.dct.app.data.logger.CsvMeasurementLogger? = null + private var activeCanMf4Logger: com.dct.app.data.logger.Mf4MeasurementLogger? = null // Protected by recordingSampleLock; seeds a newly started file from the PC-like live raw state. private val latestMf4RawValues = HashMap() private var latestMf4MdbSource: MdbDatabase? = null @@ -3463,31 +3581,37 @@ class MainViewModel( /** START ACK 성공 후에만 차트와 파일 로거를 활성화한다. */ private fun beginMeasurementSession(frequencyHz: Int) { - operationalLogger?.action("Started measuring") - // 같은 연결에서 계측을 다시 시작할 수 있다. 이전 세션에서 기록한 - // 단절 마커의 generation 가드를 새 세션 기준으로 되돌려, 이번 세션 - // 종료 시에도 반드시 마지막 샘플 뒤에 새 공백 경계를 남긴다. - gapMarkedGeneration = Long.MIN_VALUE - canReceivedFrames = 0L - canMatchedFrames = 0L - canUnmatchedFrames = 0L - canDecodedSignals = 0L - lastCanDiagnosticMillis = 0L - a3MdbPackets = 0L - a3CanPackets = 0L - a3MalformedPackets = 0L - graphMatchedSeries = 0L - lastA3DiagnosticMillis = 0L - unmatchedCanIds.clear() - chartRenderer.resume() - chartRenderLoopJob?.cancel() - chartRenderLoopJob = chartRenderer.start(scope) - for (renderer in _windowChartRenderers.values) { - renderer.resume() - renderer.start(scope) - } - _uiState.update { - it.copy(isMeasuring = true, measurementFrequencyHz = frequencyHz, statusMessage = "실시간 계측 중") + // Publish the new session and clear old samples as one boundary with packet processing. + synchronized(telemetryResetLock) { + resetMeasuredValues(resetPacketCount = false) + val startedAtElapsedMillis = measurementClock() + operationalLogger?.action("Started measuring") + // 같은 연결에서 계측을 다시 시작할 수 있다. 이전 세션에서 기록한 + // 단절 마커의 generation 가드를 새 세션 기준으로 되돌려, 이번 세션 + // 종료 시에도 반드시 마지막 샘플 뒤에 새 공백 경계를 남긴다. + gapMarkedGeneration = Long.MIN_VALUE + canReceivedFrames = 0L + canMatchedFrames = 0L + canUnmatchedFrames = 0L + canDecodedSignals = 0L + lastCanDiagnosticMillis = 0L + a3MdbPackets = 0L + a3CanPackets = 0L + a3MalformedPackets = 0L + graphMatchedSeries = 0L + lastA3DiagnosticMillis = 0L + unmatchedCanIds.clear() + chartRenderer.resume() + chartRenderLoopJob?.cancel() + chartRenderLoopJob = chartRenderer.start(scope) + for (renderer in _windowChartRenderers.values) { + renderer.resume() + renderer.start(scope) + } + _uiState.update { + it.copy(isMeasuring = true, measurementStartedAtElapsedMillis = startedAtElapsedMillis, + measurementFrequencyHz = frequencyHz, statusMessage = "실시간 계측 중") + } } if (_currentProject.value?.autostartMeasuringRecording == true) { startMeasurementRecording() @@ -3533,6 +3657,8 @@ class MainViewModel( val currentProj = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() activeCsvLoggers.clear() activeMf4Loggers.clear() + activeCanCsvLogger = null + activeCanMf4Logger = null val outDir = if (currentProj.measuredFolderUri != null) { val appContext = storageContext if (appContext == null) { @@ -3631,14 +3757,20 @@ class MainViewModel( if (currentProj.writeCsv) { com.dct.app.data.logger.CsvMeasurementLogger(outDir, canSignals, fnCsv, canColumns, withMilliseconds = csvWithMilliseconds).also { - if (it.start() != null) activeCsvLoggers.add(it) + if (it.start() != null) { + activeCsvLoggers.add(it) + activeCanCsvLogger = it + } else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } if (currentProj.writeMf4) { com.dct.app.data.logger.Mf4MeasurementLogger(outDir, canSignals, fnMf4, canDefinitions.map { com.dct.app.data.logger.PcMf4Channel.dbc(it) }).also { - if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) activeMf4Loggers.add(it) + if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) { + activeMf4Loggers.add(it) + activeCanMf4Logger = it + } else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)") } } @@ -3801,6 +3933,8 @@ class MainViewModel( activeCsvLoggers.clear() for (logger in activeMf4Loggers) logger.stop() activeMf4Loggers.clear() + activeCanCsvLogger = null + activeCanMf4Logger = null val stage = activeRecordingStage ?: return@synchronized activeRecordingStage = null val appContext = storageContext ?: return@synchronized @@ -3942,6 +4076,7 @@ class MainViewModel( val payload = packet.payload val telemetryMap = mutableMapOf() + var isLastCanPage = false // Preserve raw values during measurement, even if recording starts midway through this packet. val rawRecordingValues = if (isMeasuring) mutableMapOf() else null @@ -4027,6 +4162,9 @@ class MainViewModel( } } is com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Can -> { + // PC ProcessCANDBCData records only when curPage == TotPage. + // Keep decoding and plotting every page; only CAN file writes wait for this boundary. + isLastCanPage = stream.currentPage == stream.totalPages a3CanPackets++ val definitions = _canSignals.value if (preparedCanSource !== definitions) { @@ -4132,7 +4270,8 @@ class MainViewModel( // Only newly received signals create samples. Other packet families must not // repeat cached values in every graph and every measurement file. maybeLogA3GraphDiagnostic(telemetryMap.size) - if (telemetryMap.isEmpty()) return + // An empty/NC/unmatched final page still commits the values received on earlier pages. + if (telemetryMap.isEmpty() && !isLastCanPage) return val updatedSignals = telemetryMap.keys.toMutableSet() updateFormulaTelemetry(telemetryMap, updatedSignals) @@ -4168,15 +4307,22 @@ class MainViewModel( (activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty())) { var wroteSample = false for (logger in activeCsvLoggers) { - if (logger.signalHeaders.none { it.first in updatedSignals }) continue + if (logger === activeCanCsvLogger) { + if (!isLastCanPage) continue + } else if (logger.signalHeaders.none { it.first in updatedSignals }) continue val sampleValues = logger.signalHeaders.map { (sigName, _) -> telemetryMap[sigName] } logger.logSample(elapsedSec, sampleValues) wroteSample = true } for (logger in activeMf4Loggers) { - if (logger.signalHeaders.none { it.first in updatedSignals }) continue + val isCanLogger = logger === activeCanMf4Logger + if (isCanLogger) { + if (!isLastCanPage) continue + } else if (logger.signalHeaders.none { it.first in updatedSignals }) continue val sampleValues = logger.rawValueKeys.mapIndexed { index, key -> - if (logger.physicalInputColumns[index]) telemetryMap[key] else rawRecordingValues?.get(key) + if (logger.physicalInputColumns[index]) telemetryMap[key] + else if (isCanLogger) latestMf4RawValues[key] + else rawRecordingValues?.get(key) } logger.logRawSampleAtMillis(packet.receivedAtMillis.takeIf { it > 0 } ?: (elapsedSec * 1000).toLong(), sampleValues) @@ -4244,6 +4390,8 @@ class MainViewModel( } private fun finishMeasurementSession() { + // Clear the playback label before file compression/finalization can delay cleanup. + _uiState.update { it.copy(measurementStartedAtElapsedMillis = null) } if (_uiState.value.isMeasuring) operationalLogger?.action("Stopped measuring") // STOP ACK/연결 단절 모두 마지막 샘플과 다음 계측 세션 사이를 // 명시적으로 끊는다. NaN 마커는 렌더러에서 path를 종료하므로 diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt index 128d315..57e39c8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt @@ -69,7 +69,7 @@ import com.dct.app.presentation.main.MainViewModel import com.dct.app.presentation.main.ProjectLoadState import com.dct.app.presentation.mdi.MdiWindow import com.dct.app.presentation.mdi.MdiWindowType -import com.dct.app.presentation.dialog.WifiApConnectionDialog +import com.dct.app.presentation.dialog.WifiConnectionDialog import com.dct.app.presentation.dialog.OpenProjectDialog import com.dct.app.presentation.dialog.SelectSpecificFileDialog import com.dct.app.presentation.monitor.components.WorkspaceRibbonBar @@ -136,6 +136,8 @@ fun DiagnosticDataMonitorScreen( var showFileMenu by remember { mutableStateOf(false) } var showWifiDialog by remember { mutableStateOf(false) } var showMaintenanceDialog by remember { mutableStateOf(false) } + var showDeveloperOptionsDialog by remember { mutableStateOf(false) } + val receiveAutoRecoveryEnabled by viewModel.receiveAutoRecoveryEnabled.collectAsState() var showExitConfirmDialog by remember { mutableStateOf(false) } val programExitInProgress = (context as? com.dct.app.presentation.main.MainActivity)?.programExitRequested == true var newProjectState by remember { mutableStateOf(com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context)) } @@ -430,10 +432,10 @@ fun DiagnosticDataMonitorScreen( } } - // ESP32 AP mode. Keep P2P code for a future chip, but do not initialize its receiver/route. + // Both managers observe OS state; only explicit Connect starts the selected TCP route. LaunchedEffect(Unit) { - // viewModel.initializeWifiDirect(context) viewModel.initializeWifiAp(context) + viewModel.initializeWifiDirect(context) } @@ -518,10 +520,19 @@ fun DiagnosticDataMonitorScreen( showOverwriteConfirmDialog -> showOverwriteConfirmDialog = false showPermissionRationaleDialog -> showPermissionRationaleDialog = false showMaintenanceDialog -> showMaintenanceDialog = false + showDeveloperOptionsDialog -> showDeveloperOptionsDialog = false else -> showExitConfirmDialog = true } } + if (showDeveloperOptionsDialog) { + com.dct.app.presentation.monitor.components.DeveloperOptionsDialog( + receiveAutoRecoveryEnabled = receiveAutoRecoveryEnabled, + onReceiveAutoRecoveryChange = viewModel::setReceiveAutoRecoveryEnabled, + onDismiss = { showDeveloperOptionsDialog = false } + ) + } + Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = modifier.fillMaxSize() @@ -547,6 +558,9 @@ fun DiagnosticDataMonitorScreen( ) { Text( text = "DCT", + modifier = Modifier.clickable(onClickLabel = "개발자 옵션") { + if (viewModel.onDctTitleClick()) showDeveloperOptionsDialog = true + }, fontSize = if (isPortrait) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = ColorOnSurface @@ -1131,6 +1145,7 @@ fun DiagnosticDataMonitorScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { + com.dct.app.presentation.monitor.components.MeasurementElapsedTime(mainUiState, ColorSecondary) Text("Packets: ${mainUiState.packetCount}", fontSize = 11.sp, color = ColorSecondary) } } @@ -1349,9 +1364,8 @@ fun DiagnosticDataMonitorScreen( ) } - // Wi-Fi Direct UI retained but inactive: WifiDirectDeviceListDialog(viewModel, onDismiss). if (showWifiDialog) { - WifiApConnectionDialog( + WifiConnectionDialog( viewModel = viewModel, onDismiss = { showWifiDialog = false } ) diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialog.kt new file mode 100644 index 0000000..bc956f0 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialog.kt @@ -0,0 +1,51 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +@Composable +fun DeveloperOptionsDialog( + receiveAutoRecoveryEnabled: Boolean, + onReceiveAutoRecoveryChange: (Boolean) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("개발자 옵션") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("수신 자동복구") + Text(if (receiveAutoRecoveryEnabled) "켜짐" else "꺼짐", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = receiveAutoRecoveryEnabled, + onCheckedChange = onReceiveAutoRecoveryChange, + modifier = Modifier.testTag("ReceiveAutoRecoverySwitch") + .semantics { contentDescription = "수신 자동복구" } + ) + } + Text("수신이 중단되면 연결과 계측 설정을 복구하고 신호 재생을 다시 시작합니다.", + style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("닫기") } } + ) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTime.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTime.kt new file mode 100644 index 0000000..b61cb2a --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTime.kt @@ -0,0 +1,43 @@ +package com.dct.app.presentation.monitor.components + +import android.os.SystemClock +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.dct.app.presentation.main.MainUiState +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +/** Ticks only this label; monotonic time includes screen-off time without changing packet/graph state. */ +@Composable +internal fun MeasurementElapsedTime(ui: MainUiState, color: Color = Color(0xFF535F73), + nowMillis: () -> Long = SystemClock::elapsedRealtime) { + val startedAt = ui.measurementStartedAtElapsedMillis + val lifecycleOwner = LocalLifecycleOwner.current + val readNow by rememberUpdatedState(nowMillis) + var currentMillis by remember(startedAt, ui.isMeasuring) { mutableLongStateOf(nowMillis()) } + LaunchedEffect(lifecycleOwner, startedAt, ui.isMeasuring) { + if (startedAt == null || !ui.isMeasuring) return@LaunchedEffect + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + currentMillis = readNow() + delay(1_000L) + } + } + } + // Share the existing non-wrapping HH:mm:ss duration formatter; inputs here use a monotonic clock. + val elapsed = if (startedAt == null || !ui.isMeasuring) "00:00:00" + else formatRecordingElapsedTime(startedAt, currentMillis) + Text("재생 $elapsed", fontSize=11.sp, fontFamily=FontFamily.Monospace, color=color, + maxLines=1, softWrap=false, modifier=Modifier.testTag("MeasurementElapsedTime") + .semantics { contentDescription="신호 재생 경과 시간 $elapsed" }) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt index 321f511..7f52a3d 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt @@ -89,21 +89,25 @@ internal fun WifiConnectionRibbonButtons( onWifiClick: () -> Unit, onConnectClick: () -> Unit, onDisconnectClick: () -> Unit, - onWifiDisconnectClick: () -> Unit + onWifiDisconnectClick: () -> Unit, + direct: com.dct.app.data.communication.WifiDirectUiState? = null ) { val connected = ui.isConnected && ui.communicationType != CommunicationType.NONE val wifiConnected = connected && ui.communicationType == CommunicationType.WIFI RibbonButton( icon = Icons.Default.WifiOff, tooltip = "Wi-Fi 연결 해제", - enabled = ap.networkReady || ap.busy, + enabled = if (direct != null) direct.link != null || direct.busy else ap.networkReady || ap.busy, onClick = onWifiDisconnectClick ) RibbonButton( icon = Icons.Default.Wifi, - tooltip = if (ap.networkReady) "Wi-Fi AP 연결됨 (${ap.selectedSsid})" else "Wi-Fi AP 연결", + tooltip = if (direct != null) { + if (direct.link != null) "Wi-Fi Direct 연결됨 (${direct.link.displayName})" else "Wi-Fi Direct 연결" + } else if (ap.networkReady) "Wi-Fi AP 연결됨 (${ap.selectedSsid})" else "Wi-Fi AP 연결", enabled = !connected || wifiConnected, - tint = if (ap.networkReady || ap.connectedNetworks.isNotEmpty()) Color(0xFF2563EB) else ColorSecondary, + tint = if (if (direct != null) direct.link != null else ap.networkReady || ap.connectedNetworks.isNotEmpty()) + Color(0xFF2563EB) else ColorSecondary, onClick = onWifiClick ) RibbonButton( @@ -345,6 +349,8 @@ fun WorkspaceRibbonBar( // Group 2: 통신 연결 val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE val apState by viewModel.wifiApState.collectAsState() + val directState by viewModel.wifiDirectState.collectAsState() + val wifiMode by viewModel.wifiConnectionMode.collectAsState() val canConnectCommunication by viewModel.canConnectCommunication.collectAsState() val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI @@ -352,7 +358,8 @@ fun WorkspaceRibbonBar( ui = mainUiState, ap = apState, canConnect = canConnectCommunication, onWifiClick = onWifiClick, onConnectClick = viewModel::connectCommunication, onDisconnectClick = onDisconnectClick, - onWifiDisconnectClick = onWifiDisconnectClick + onWifiDisconnectClick = onWifiDisconnectClick, + direct = directState.takeIf { wifiMode == com.dct.app.data.communication.WifiConnectionMode.DIRECT } ) VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) @@ -361,7 +368,8 @@ fun WorkspaceRibbonBar( val canStartMeasure = isCommConnected && !isMeasuring val canStopMeasure = isCommConnected && isMeasuring - Text("신호 재생", fontSize = 12.sp, color = ColorTextSecondary) + Text(if (mainUiState.isStreamRecovering) "수신 복구 중" else "신호 재생", fontSize = 12.sp, + color = if (mainUiState.isStreamRecovering) Color(0xFFD97706) else ColorTextSecondary) RibbonButton( icon = Icons.Default.PlayArrow, tooltip = if (isCommConnected) "신호 재생 시작 (계측 시작)" else "신호 재생: 통신 연결 필요", diff --git a/Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt b/Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt new file mode 100644 index 0000000..e6847b7 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt @@ -0,0 +1,212 @@ +package com.dct.app.tests + +import com.dct.app.core.theme.PcColorRef +import com.dct.app.data.logger.CsvMeasurementLogger +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.formula.FormulaDefinition +import com.dct.app.domain.model.mdb.CanSignalKey +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.domain.model.project.DpcSensorModuleConfig +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MainViewModel +import com.dct.app.presentation.main.MeasurementRecordingState +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import java.util.zip.Inflater + +@Suppress("UNCHECKED_CAST") +class CanPageRecordingTest { + @TempDir lateinit var directory: File + private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + private val a = SignalDefinition(0, 1, "CanA", MdbAttrType.ATTR_0_UINT8, 2.0, -10.0, "V", 0L, 0.0, 255.0, + messageId = 0x101, startBit = 0, bitLength = 8) + private val b = a.copy(idx = 2, name = "CanB", attr = MdbAttrType.ATTR_3_INT16, messageId = 0x102, + bitLength = 16, cvsA = .1, cvsB = -40.0) + + private inner class Fixture(sensorAndFormula: Boolean = false) : AutoCloseable { + val owner = MainViewModelTest().also { it.setUp() } + val vm = field(owner, "viewModel").get(owner) as MainViewModel + val process = MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java, + Double::class.javaPrimitiveType).also { it.isAccessible = true } + val csv get() = (field(vm, "activeCsvLoggers").get(vm) as List).single { + it.outputFile!!.name.endsWith("_dbc.csv") } + val mf4 get() = (field(vm, "activeMf4Loggers").get(vm) as List).single { + it.outputFile!!.name.endsWith("_dbc.mf4") } + var start = 0L + var sequence = 0L + init { + vm.setCurrentProject(DpcProject(projectFileName = "PAGES.dpc", canDbcFileName = "fixture.dbc", + measuredFilesFolder = directory.resolve(UUID.randomUUID().toString()).absolutePath, + writeCsv = true, writeMf4 = true, csvWithMilliseconds = true, + sensorModules = listOf(DpcSensorModuleConfig(0, isUsed = sensorAndFormula)))) + (field(vm, "_canSignals").get(vm) as MutableStateFlow>).value = listOf(a, b) + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(isConnected = true, isMeasuring = true) + if (sensorAndFormula) vm.addFormula(FormulaDefinition(name = "F", expression = "CanA*2")) + vm.chartRenderer.bindSignal(CanSignalKey.stable(a), a.name, AxisPosition.LEFT_0, PcColorRef.DEFAULT_DASHBOARD) + vm.chartRenderer.bindSignal(CanSignalKey.stable(b), b.name, AxisPosition.LEFT_0, PcColorRef.DEFAULT_DASHBOARD) + startRecording() + } + fun startRecording() { + vm.startMeasurementRecording() + assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState) + start = vm.uiState.value.recordingStartedAtMillis!! + } + fun can(page: Int, total: Int, offset: Long, vararg frames: Pair, notReceived: Boolean = false) { + val payload = byteArrayOf(2, page.toByte(), total.toByte(), frames.size.toByte()) + frames.fold(byteArrayOf()) { out, (id, data) -> + out + byteArrayOf((id ushr 8).toByte(), id.toByte(), (data.size or if (notReceived) 128 else 0).toByte()) + data + } + receive(DctPacket(0xA3.toByte(), payload, receivedAtMillis = start + offset, ingressSequence = ++sequence)) + } + fun receive(packet: DctPacket) = process.invoke(vm, packet, packet.receivedAtMillis / 1000.0) + fun stop() = vm.stopMeasurementRecording() + override fun close() { + stop() + vm.scope.cancel() + vm.chartRenderer.stop() + owner.tearDown() + } + } + + private fun csvRows(file: File) = file.readLines().drop(7).filter { it.isNotBlank() } + .map { line -> line.split(',').map(String::trim) } + private fun binary(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) + private fun records(file: File): ByteBuffer { + val data = binary(file) + val recordSize = data.getInt(232 + 96) + val hl = data.getLong(168 + 40).toInt() + val dl = data.getLong(hl + 24).toInt() + if (dl == 0) return ByteBuffer.allocate(0) + val dz = data.getLong(dl + 32).toInt() + val rawSize = data.getLong(dz + 32).toInt() + val compressedSize = data.getLong(dz + 40).toInt() + val transposed = ByteArray(rawSize) + val inflater = Inflater() + try { + inflater.setInput(data.array(), dz + 48, compressedSize) + assertEquals(rawSize, inflater.inflate(transposed)) + assertTrue(inflater.finished()) + } finally { inflater.end() } + val count = rawSize / recordSize + val raw = ByteArray(rawSize) + for (column in 0 until recordSize) for (row in 0 until count) { + raw[row * recordSize + column] = transposed[column * count + row] + } + return ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN) + } + + @Test fun fourPagesUpdateGraphsImmediatelyButWriteOneFullSnapshotAtTheLastPage() { + Fixture().use { f -> + val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! + f.can(1, 4, 100, 0x101 to byteArrayOf(12)) + f.can(2, 4, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1)) // Raw 500. + f.can(3, 4, 300, 0x101 to byteArrayOf(20)) + assertEquals(0L, f.vm.uiState.value.recordingSampleCount) + assertEquals(2, f.vm.chartRenderer.seriesListFlow.value.first { it.signalName == "CanA" }.buffer.size) + assertEquals(30.0, f.vm.liveTelemetryValues.value["CanA"]) + f.can(4, 4, 400, 0x102 to byteArrayOf(0x58, 2)) // Raw 600. + assertEquals(1L, f.vm.uiState.value.recordingSampleCount) + f.stop() + val rows = csvRows(csv) + assertEquals(1, rows.size) + assertEquals("0", rows.single()[1]) + assertEquals(30.0, rows.single()[2].toDouble()) + assertEquals(20.0, rows.single()[3].toDouble()) + assertEquals(1L, binary(mf4).getLong(232 + 80)) + val raw = records(mf4) + assertEquals(.4, raw.double, 0.000001) + assertEquals(20, raw.get().toInt() and 255) + assertEquals(600, raw.short.toInt()) + assertFalse(raw.hasRemaining()) + } + } + + @ParameterizedTest @ValueSource(ints = [0, 1, 2]) + fun finalPageWithNoNewDecodedSignalsStillWritesEarlierPageValues(lastKind: Int) { + Fixture().use { f -> + val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! + f.can(1, 2, 100, 0x101 to byteArrayOf(12)) + assertEquals(0L, f.vm.uiState.value.recordingSampleCount) + when (lastKind) { + 0 -> f.can(2, 2, 200) + 1 -> f.can(2, 2, 200, 0x777 to byteArrayOf(1)) + else -> f.can(2, 2, 200, 0x102 to byteArrayOf(0, 0), notReceived = true) + } + f.stop() + assertEquals(14.0, csvRows(csv).single()[2].toDouble()) + assertEquals("No value", csvRows(csv).single()[3]) + val raw = records(mf4) + assertEquals(.2, raw.double, 0.000001) + assertEquals(12, raw.get().toInt() and 255) + assertEquals(0, raw.short.toInt()) + assertEquals(1L, f.vm.uiState.value.recordingSampleCount) + } + } + + @ParameterizedTest @ValueSource(ints = [0, 1]) + fun equalSinglePageNumbersRecordEveryPacket(page: Int) { + Fixture().use { f -> + val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! + f.can(page, page, 100, 0x101 to byteArrayOf(12)) + f.can(page, page, 200, 0x101 to byteArrayOf(20)) + f.stop() + assertEquals(2, csvRows(csv).size) + assertEquals(2L, binary(mf4).getLong(232 + 80)) + } + } + + @Test fun interleavedSensorAndFormulaRecordingDoesNotWriteAnExtraCanRow() { + Fixture(sensorAndFormula = true).use { f -> + val loggers = field(f.vm, "activeCsvLoggers").get(f.vm) as List + val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!! + val formula = loggers.single { it.outputFile!!.name.endsWith("_formula.csv") }.outputFile!! + val sensor = loggers.single { it.outputFile!!.name.endsWith("_sm.csv") }.outputFile!! + f.can(1, 2, 100, 0x101 to byteArrayOf(12)) + f.receive(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 25, 0, 0), receivedAtMillis = f.start + 150)) + f.can(2, 2, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1)) + f.stop() + assertEquals(1, csvRows(csv).size) + assertEquals(1L, binary(mf4).getLong(232 + 80)) + assertEquals(1, csvRows(formula).size) + assertEquals(28.0, csvRows(formula).single()[2].toDouble()) + assertEquals(1, csvRows(sensor).size) + assertEquals(25.0, csvRows(sensor).single()[2].toDouble()) + assertEquals(3L, f.vm.uiState.value.recordingSampleCount) + } + } + + @Test fun stoppingBeforeTheLastPageAndRestartingKeepsFilesAndCountersSeparate() { + Fixture().use { f -> + val firstCsv = f.csv.outputFile!!; val firstMf4 = f.mf4.outputFile!! + f.can(1, 2, 100, 0x101 to byteArrayOf(12)) + f.stop() + assertTrue(csvRows(firstCsv).isEmpty()) + assertEquals(0L, binary(firstMf4).getLong(232 + 80)) + f.startRecording() + val secondCsv = f.csv.outputFile!!; val secondMf4 = f.mf4.outputFile!! + f.can(2, 2, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1)) + f.stop() + assertEquals(1, csvRows(secondCsv).size) + assertEquals(14.0, csvRows(secondCsv).single()[2].toDouble()) + assertEquals(1L, binary(secondMf4).getLong(232 + 80)) + val raw = records(secondMf4) + raw.double + assertEquals(12, raw.get().toInt() and 255) + assertEquals(500, raw.short.toInt()) + assertEquals(1L, f.vm.uiState.value.recordingSampleCount) + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt index d518341..06fb3dd 100644 --- a/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt @@ -70,6 +70,7 @@ class ChartNavigationTest { @Test fun forwardPanAfterZoomAlsoUsesCurrentLatestData() { val r = renderer() r.zoomTimeWindow(2f, .5) + r.panTimeWindow(-10.0) // Browse history before testing return to newer live data. r.sample(2000.0) r.panTimeWindow(10000.0) assertTrue(r.snapshot().timeMaxSec >= 2000.0) @@ -95,7 +96,7 @@ class ChartNavigationTest { @Test fun zoomOutReachingLatestEdgeResumesWithoutAnotherDrag() { val r = renderer() - r.panTimeWindow(-50.0) + r.panTimeWindow(-350.0) // Move the right edge past the initial 300-second future margin. r.zoomTimeWindow(.5f, .5) assertEquals(1000.0, r.renderSnapshotFlow.value.timeMaxSec) assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) @@ -156,16 +157,55 @@ class ChartNavigationTest { assertEquals(.05, r.snapshot().span(), 1e-6) } - @Test fun forwardDragWhileFollowingPreservesFixedPresetAndInitialFutureMargin() { + @Test fun forwardDragWhileFollowingMovesBeyondInitialFutureMarginAndPinsTheRange() { val r = renderer() r.clearAllData() r.sample(2000.0) val before = r.snapshot() assertTrue(before.timeMaxSec > 2000.0) r.panTimeWindow(10.0) - assertEquals(before.timeMaxSec, r.snapshot().timeMaxSec) + assertEquals(before.timeMaxSec + 10.0, r.snapshot().timeMaxSec) + assertEquals(before.span(), r.snapshot().span()) + assertEquals(TimeWindowPreset.MANUAL, r.snapshot().xAxisPreset) + assertFalse(r.snapshot().isXAxisAuto) + r.panTimeWindow(10.0) + assertEquals(before.timeMaxSec + 20.0, r.snapshot().timeMaxSec) + r.sample(2100.0) + assertEquals(before.timeMaxSec + 20.0, r.snapshot().timeMaxSec) + r.panTimeWindow(-5.0) + assertEquals(before.timeMaxSec + 15.0, r.snapshot().timeMaxSec) + r.resetPan() assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset) assertTrue(r.snapshot().isXAxisAuto) + assertEquals(before.span(), r.snapshot().span()) + } + + @Test fun forwardDragCanContinueBeyondLatestAfterReturningFromHistory() { + val r = renderer() + r.panTimeWindow(-320.0) // Initial range is 1000..1300; actual history ends at 980. + r.panTimeWindow(20.0) + assertTrue(r.snapshot().isXAxisAuto) + r.panTimeWindow(10.0) + assertEquals(1010.0, r.snapshot().timeMaxSec) + assertFalse(r.snapshot().isXAxisAuto) + r.panTimeWindow(10.0) + assertEquals(1020.0, r.snapshot().timeMaxSec) + r.panTimeWindow(-30.0) + assertEquals(990.0, r.snapshot().timeMaxSec) + r.panTimeWindow(10.0) + assertEquals(1000.0, r.snapshot().timeMaxSec) + assertTrue(r.snapshot().isXAxisAuto) + } + + @Test fun zoomInFutureRangeKeepsTheAnchorAndDoesNotSnapBackToLatest() { + val r = renderer() + val initialRight = r.snapshot().timeMaxSec + r.panTimeWindow(600.0) + r.zoomTimeWindow(2f, 1.0) + assertEquals(initialRight + 600.0, r.snapshot().timeMaxSec) + assertEquals(150.0, r.snapshot().span()) + assertFalse(r.snapshot().isXAxisAuto) + assertTrue(r.snapshot().seriesList.single().points.isEmpty()) } @Test fun emptyOrExplicitlyFrozenViewportDoesNotResumeWithoutReachingDataByGesture() { diff --git a/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt b/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt index 23fa5ec..7bb4d5a 100644 --- a/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt @@ -67,6 +67,30 @@ class ChartStoppedNavigationTest { assertFalse(r.renderSnapshotFlow.value.isXAxisAuto) } + @Test fun stoppedChartPublishesFutureBlankRangeAndCanReturnWithoutNewSamples() = runTest { + val r = renderer() + val job = r.start(backgroundScope) + r.resume() + runCurrent() + r.stop() + r.panTimeWindow(60.0) + runCurrent() + val future = r.renderSnapshotFlow.value + assertEquals(1060.0, future.timeMaxSec, 1e-8) + assertEquals(1030.0, future.timeMinSec, 1e-8) + assertFalse(future.isXAxisAuto) + assertTrue(future.isPaused) + assertTrue(future.seriesList.single().points.isEmpty()) + r.panTimeWindow(-60.0) + assertRight(r, 1000.0) + r.resetPan() + assertRight(r, 1000.0) + assertTrue(r.renderSnapshotFlow.value.isXAxisAuto) + assertTrue(job.isCancelled) + assertEquals(900.0, r.seriesListFlow.value.single().buffer.getOldestTimestamp()) + assertEquals(1000.0, r.seriesListFlow.value.single().buffer.getLatestTimestamp()) + } + @Test fun cancelledJobAndNeverStartedRendererAlsoPublishNavigation() = runTest { val r = renderer() r.panTimeWindow(-10.0) diff --git a/Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt b/Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt new file mode 100644 index 0000000..48ce316 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt @@ -0,0 +1,54 @@ +package com.dct.app.tests + +import android.content.SharedPreferences +import com.dct.app.data.preferences.DeveloperOptionsPreferences +import com.dct.app.presentation.main.DeveloperOptionsAccess +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class DeveloperOptionsTest { + @Test fun fiveConsecutiveTapsUnlockThenOneTapReopensUntilANewSession() { + val access = DeveloperOptionsAccess() + repeat(4) { assertFalse(access.onTitleTap(it * 150L)) } + assertTrue(access.onTitleTap(600)) + assertTrue(access.onTitleTap(100_000)) + assertFalse(DeveloperOptionsAccess().onTitleTap(100_150)) + } + + @Test fun pausesAndClockDiscontinuityResetTheTapSequence() { + val access = DeveloperOptionsAccess() + repeat(4) { assertFalse(access.onTitleTap(it * 100L)) } + assertFalse(access.onTitleTap(1_301)) + assertFalse(access.onTitleTap(1_000)) + repeat(3) { assertFalse(access.onTitleTap(1_100L + it * 100)) } + assertTrue(access.onTitleTap(1_400)) + } + + @Test fun closingTheUiRelocksEvenIfTheBackgroundSessionIsRetained() { + val access = DeveloperOptionsAccess() + repeat(5) { access.onTitleTap(it * 100L) } + assertTrue(access.onTitleTap(500)) + access.lock() + repeat(4) { assertFalse(access.onTitleTap(600L + it * 100)) } + assertTrue(access.onTitleTap(1_000)) + } + + @Test fun preferenceSurvivesNewStoreAndLockedAccessWithoutBeingReset() { + val values = mutableMapOf() + val prefs = mockk() + val editor = mockk() + every { prefs.getBoolean(any(), any()) } answers { values[firstArg()] ?: secondArg() } + every { prefs.edit() } returns editor + every { editor.putBoolean(any(), any()) } answers { values[firstArg()] = secondArg(); editor } + every { editor.apply() } returns Unit + assertFalse(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled()) + DeveloperOptionsPreferences(prefs).setReceiveAutoRecoveryEnabled(false) + assertFalse(DeveloperOptionsAccess().onTitleTap(0)) + assertFalse(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled()) + DeveloperOptionsPreferences(prefs).setReceiveAutoRecoveryEnabled(true) + assertTrue(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled()) + assertEquals(setOf("receive_auto_recovery"), values.keys) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt index 46070b1..df83fbb 100644 --- a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt @@ -35,6 +35,7 @@ class FormulaTelemetryRegressionTest { private lateinit var vm: MainViewModel private lateinit var packets: MutableSharedFlow private lateinit var repository: PacketRepository + private var telemetrySequence = 0L private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } @BeforeEach fun setUp() { @@ -48,12 +49,13 @@ class FormulaTelemetryRegressionTest { @AfterEach fun tearDown() = fixture.tearDown() - private fun telemetry(value: Float) { + private fun telemetry(value: Float, receivedAtMillis: Long = 0L) { + // Real transport assigns an ingress sequence, preserving distinct same-millisecond packets. assertTrue(packets.tryEmit(DctPacket(0x02, ByteArray(18).also { val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) buffer.position(2) buffer.putFloat(value).putFloat(2f).putFloat(3f).putFloat(4f) - }))) + }, ingressSequence = ++telemetrySequence, receivedAtMillis = receivedAtMillis))) } private fun openMdbWithoutRawConsumers() { @@ -84,6 +86,95 @@ class FormulaTelemetryRegressionTest { assertEquals(3.0, vm.liveTelemetryValues.value["B"]) } + @Test fun measurementRestartClearsAllPagesAndLiveCachesThenUsesOnlyNewSamples() { + vm.startMeasure() + vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2", unit = "V")) + val binding = com.dct.app.domain.model.project.DpcSignalBindingState( + "formula_F", "F", AxisPosition.LEFT_0, 0x123456L, "V") + vm.restoreProjectWorkspace(com.dct.app.domain.model.project.DpcWorkspaceState("page_1", listOf( + com.dct.app.domain.model.project.DpcPageState("page_1", "One", listOf( + com.dct.app.domain.model.project.DpcWindowState("graph_one", "GRAPH", "Graph", signals = listOf(binding)))), + com.dct.app.domain.model.project.DpcPageState("page_2", "Two", listOf( + com.dct.app.domain.model.project.DpcWindowState("multi_two", "MULTI_AXIS_GRAPH", "Multi", signals = listOf(binding))))))) + vm.chartRenderer.bindSignal("formula_F", "F", AxisPosition.LEFT_0, 0x123456L) + val renderers = listOf(vm.chartRenderer, vm.getChartRenderer("graph_one"), vm.getChartRenderer("multi_two")) + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(dashboardTiles = listOf( + com.dct.app.domain.model.dashboard.TileWidgetItem("Incar_Snr", "Incar_Snr"))) + telemetry(2f) + vm.stopMeasure() + val raw = field(vm, "latestMf4RawValues").get(vm) as MutableMap + raw.putAll(mapOf("sig_1_1" to 42, "can_1" to 55, "a2l_1" to 66)) + val beforeCount = vm.uiState.value.packetCount + val project = vm.currentProject.value + val formulas = vm.formulas.value + val pages = vm.pages.value.map { it.id to it.title } + val bindings = renderers.map { it.exportWorkspaceState() } + renderers.forEach { assertTrue(it.seriesListFlow.value.single().buffer.size > 0) } + assertEquals(4.0, vm.liveTelemetryValues.value["F"]) + + vm.startMeasure() + + assertTrue(vm.liveTelemetryValues.value.isEmpty()) + assertTrue(raw.isEmpty()) + assertEquals(beforeCount, vm.uiState.value.packetCount) + assertTrue(vm.uiState.value.dashboardTiles.all { it.value == 0.0 && it.historyBuffer.size == 0 }) + assertEquals(project, vm.currentProject.value) + assertEquals(formulas, vm.formulas.value) + assertEquals(pages, vm.pages.value.map { it.id to it.title }) + renderers.forEachIndexed { index, renderer -> + assertEquals(bindings[index], renderer.exportWorkspaceState()) + assertEquals(0, renderer.seriesListFlow.value.single().buffer.size) + } + telemetry(7f) + assertEquals(14.0, vm.liveTelemetryValues.value["F"]) + renderers.forEach { + assertEquals(1, it.seriesListFlow.value.single().buffer.size) + assertEquals(14.0, it.seriesListFlow.value.single().buffer.getLatestValue()) + } + assertEquals(7.0, vm.uiState.value.dashboardTiles.single().value) + } + + @Test fun pendingAndFailedStartRetainOldValuesUntilStartSucceeds() { + vm.startMeasure() + vm.chartRenderer.bindSignal("s", "Incar_Snr", AxisPosition.LEFT_0, 0x123456L) + telemetry(2f) + vm.stopMeasure() + val before = vm.chartRenderer.seriesListFlow.value.single().buffer.size + val response = kotlinx.coroutines.CompletableDeferred>() + coEvery { repository.startStreaming(any()) } coAnswers { response.await() } + vm.startMeasure() + assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"]) + assertEquals(before, vm.chartRenderer.seriesListFlow.value.single().buffer.size) + response.complete(DctResult.Failure(DctError.ProtocolError.ConnectionFailed("START failed"))) + assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"]) + assertEquals(before, vm.chartRenderer.seriesListFlow.value.single().buffer.size) + coEvery { repository.startStreaming(any()) } returns DctResult.Success(Unit) + vm.startMeasure() + assertTrue(vm.liveTelemetryValues.value.isEmpty()) + assertEquals(0, vm.chartRenderer.seriesListFlow.value.single().buffer.size) + } + + @Test fun automaticRecoveryRetainsValuesGraphsAndRawSnapshot() { + vm.startMeasure() + vm.chartRenderer.bindSignal("s", "Incar_Snr", AxisPosition.LEFT_0, 0x123456L) + // Separate the sample from the recovery gap, which may replace a same-millisecond point. + telemetry(2f, receivedAtMillis = System.currentTimeMillis() - 100L) + val before = vm.chartRenderer.seriesListFlow.value.single().buffer.size + val raw = field(vm, "latestMf4RawValues").get(vm) as MutableMap + raw["can_1"] = 55 + val recovery = repository.streamRecovery as MutableStateFlow + recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECOVERING + recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECEIVING + assertTrue(vm.uiState.value.isMeasuring) + assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"]) + val buffer = vm.chartRenderer.seriesListFlow.value.single().buffer + assertEquals(before + 1, buffer.size) + assertEquals(2.0, buffer.getValueAt(0)) + assertTrue(buffer.getLatestValue()!!.isNaN()) + assertEquals(55, raw["can_1"]) + } + @Test fun debugResetClearsAllPagesAndValuesWithoutStoppingRecordingOrRemovingConfiguration() { vm.startMeasure() vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2", unit = "V")) diff --git a/Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt b/Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt new file mode 100644 index 0000000..dd8b5cd --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt @@ -0,0 +1,89 @@ +package com.dct.app.tests + +import com.dct.app.data.logger.Mf4MeasurementLogger +import com.dct.app.data.logger.PcMf4Channel +import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.zip.Inflater + +/** Accelerated component checks; these do not simulate radio, Doze, heat or 24 hours of wall time. */ +class LongRunMeasurementReviewTest { + @TempDir lateinit var directory: File + private val samples = 24 * 60 * 60 * 30 + + @Test fun twentyFourHoursAtThirtyHertzKeepsRingCapacityAndNewestSamples() { + val ring = TimeSeriesRingBuffer() + repeat(samples) { index -> ring.addSample(index / 30.0, index.toDouble()) } + assertEquals(100_000, ring.capacity) + assertEquals(ring.capacity, ring.size) + assertEquals((samples - ring.capacity) / 30.0, ring.getOldestTimestamp()!!, 1e-6) + assertEquals((samples - 1) / 30.0, ring.getLatestTimestamp()!!, 1e-6) + assertEquals((samples - 1).toDouble(), ring.getLatestValue()) + assertEquals((samples - ring.capacity).toDouble(), ring.getOldestValue()) + } + + @Test fun compressedMf4RetainsCountTimesAndValuesAcrossTwentyFourLogicalHours() { + val channel = PcMf4Channel("Counter", dataType = 0, bitCount = 16) + val logger = Mf4MeasurementLogger(directory, listOf("Counter" to ""), "24h.mf4", listOf(channel)) + // 23:59:59 KST: the simulated stream crosses midnight almost immediately. + val origin = java.time.Instant.parse("2026-10-28T14:59:59Z").toEpochMilli() + val file = logger.start(origin)!! + try { + repeat(samples) { index -> + logger.logRawSampleAtMillis(origin + index * 1000L / 30L, listOf(index and 0xFFFF)) + } + } finally { logger.stop() } + val bytes = file.readBytes() + val data = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + assertEquals(samples.toLong(), data.getLong(232 + 80)) + assertEquals(10, data.getInt(232 + 96)) // Time float64 + original uint16. + assertEquals(0, data.getShort(60).toInt()) // Finalized only after links/counts are patched. + val hl = data.getLong(168 + 40).toInt() + assertEquals("##HL", String(bytes, hl, 4)) + var dl = data.getLong(hl + 24).toInt() + var total = 0 + var chunks = 0 + var previousTime = -1.0 + while (dl != 0) { + assertTrue(++chunks < 1000, "Invalid/cyclic DL chain") + val dz = data.getLong(dl + 32).toInt() + assertEquals("##DZ", String(bytes, dz, 4)) + val rawSize = data.getLong(dz + 32).toInt() + val zipSize = data.getLong(dz + 40).toInt() + val transposed = ByteArray(rawSize) + val inflater = Inflater() + try { + inflater.setInput(bytes, dz + 48, zipSize) + var count = 0 + while (!inflater.finished() && count < rawSize) { + val n = inflater.inflate(transposed, count, rawSize - count) + assertTrue(n > 0, "Truncated compressed chunk") + count += n + } + assertTrue(inflater.finished()) + assertEquals(rawSize, count) + } finally { inflater.end() } + val rows = rawSize / 10 + for (row in listOf(0, rows - 1).distinct()) { + val timeBytes = ByteArray(8) { column -> transposed[column * rows + row] } + val time = ByteBuffer.wrap(timeBytes).order(ByteOrder.LITTLE_ENDIAN).double + assertEquals(((total + row) * 1000L / 30L) / 1000.0, time, 1e-6) + assertTrue(time >= previousTime) + previousTime = time + val raw = (transposed[8 * rows + row].toInt() and 255) or + ((transposed[9 * rows + row].toInt() and 255) shl 8) + assertEquals((total + row) and 0xFFFF, raw) + } + total += rows + dl = data.getLong(dl + 24).toInt() + } + assertEquals(samples, total) + assertEquals(260, chunks) + assertEquals(86_399.966, previousTime, 1e-6) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt index aa61c04..89af31f 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt @@ -461,6 +461,7 @@ class MainViewModelTest { private lateinit var mdbRepository: MdbRepository private lateinit var packetRepository: PacketRepository private lateinit var connectionState: MutableStateFlow + private lateinit var recoveryState: MutableStateFlow private lateinit var repositoryConnected: MutableStateFlow private lateinit var repositoryStreaming: MutableStateFlow private lateinit var chartRenderer: ChartBatchRenderer @@ -473,6 +474,7 @@ class MainViewModelTest { private lateinit var viewModel: MainViewModel private lateinit var sampleDb: MdbDatabase private var backgroundExecutionAllowed = true + private var measurementClockMillis = 0L private fun installWifiAp( initial: com.dct.app.data.communication.WifiApUiState @@ -885,10 +887,12 @@ class MainViewModelTest { val packetFlow = MutableSharedFlow(extraBufferCapacity = 8) connectionState = MutableStateFlow(DeviceConnectionState.DISCONNECTED) + recoveryState = MutableStateFlow(com.dct.app.domain.repository.StreamRecoveryState.IDLE) repositoryConnected = MutableStateFlow(false) repositoryStreaming = MutableStateFlow(false) every { packetRepository.packetStream } returns packetFlow every { packetRepository.connectionState } returns connectionState + every { packetRepository.streamRecovery } returns recoveryState every { packetRepository.isConnected } returns repositoryConnected every { packetRepository.isStreaming } returns repositoryStreaming coEvery { packetRepository.configureMeasurement(any()) } returns DctResult.Success(Unit) @@ -923,7 +927,8 @@ class MainViewModelTest { every { isConnected } returns MutableStateFlow(false) }, // Lifecycle tests must not launch real asynchronous TCP attempts. packetRepository = packetRepository, - ensureBackgroundExecution = { backgroundExecutionAllowed } + ensureBackgroundExecution = { backgroundExecutionAllowed }, + measurementClock = { measurementClockMillis } ) // Rendering cadence is not under test; keep virtual time from advancing forever. chartRenderer.stop() diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt new file mode 100644 index 0000000..9535fd3 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt @@ -0,0 +1,114 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.core.error.DctError +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import com.dct.app.domain.repository.StreamRecoveryState +import com.dct.app.presentation.main.MainViewModel +import io.mockk.coEvery +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* + +@Suppress("UNCHECKED_CAST") +class MeasurementElapsedTimeTest { + private lateinit var fixture: MainViewModelTest + private lateinit var vm: MainViewModel + private lateinit var repository: PacketRepository + private lateinit var connection: MutableStateFlow + private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + private fun clock(time: Long) = field(fixture, "measurementClockMillis").setLong(fixture, time) + + @BeforeEach fun setUp() { + fixture = MainViewModelTest().also { it.setUp() } + vm = field(fixture, "viewModel").get(fixture) as MainViewModel + repository = field(fixture, "packetRepository").get(fixture) as PacketRepository + connection = field(fixture, "connectionState").get(fixture) as MutableStateFlow + connection.value = DeviceConnectionState.READY + } + + @AfterEach fun cleanUp() { + vm.scope.cancel() + fixture.tearDown() + } + + @Test fun timerStartsOnlyAfterSuccessfulStartNotWhileWaitingForResponse() { + val response = CompletableDeferred>() + coEvery { repository.startStreaming(any()) } coAnswers { response.await() } + clock(1_000) + vm.startMeasure() + assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) + clock(50_000) + response.complete(DctResult.Success(Unit)) + assertTrue(vm.uiState.value.isMeasuring) + assertEquals(50_000L, vm.uiState.value.measurementStartedAtElapsedMillis) + } + + @Test fun failedStartDoesNotCreateTimer() { + coEvery { repository.startStreaming(any()) } returns DctResult.Failure(DctError.ProtocolError.Disconnected) + clock(5_000) + vm.startMeasure() + assertFalse(vm.uiState.value.isMeasuring) + assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) + } + + @Test fun stopClearsTimeAndSuccessfulRestartStartsFromNewTime() { + clock(1_000) + vm.startMeasure() + clock(61_000) + vm.stopMeasure() + assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) + clock(99_000) + vm.stopMeasure() + assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) + clock(120_000) + vm.startMeasure() + assertEquals(120_000L, vm.uiState.value.measurementStartedAtElapsedMillis) + } + + @Test fun pendingOrFailedStopKeepsTimerUntilMeasurementActuallyEnds() { + clock(1_000) + vm.startMeasure() + val response = CompletableDeferred>() + coEvery { repository.stopStreaming() } coAnswers { response.await() } + vm.stopMeasure() + assertTrue(vm.uiState.value.isMeasuring) + assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) + response.complete(DctResult.Failure(DctError.ProtocolError.ConnectionFailed("STOP failed"))) + assertTrue(vm.uiState.value.isMeasuring) + assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) + coEvery { repository.stopStreaming() } returns DctResult.Success(Unit) + vm.stopMeasure() + assertFalse(vm.uiState.value.isMeasuring) + assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) + } + + @Test fun automaticRecoveryAndDebugValueResetKeepOriginalMeasurementStart() { + clock(1_000) + vm.startMeasure() + clock(300_000) + val recovery = repository.streamRecovery as MutableStateFlow + recovery.value = StreamRecoveryState.RECOVERING + vm.resetDebugValues() + assertTrue(vm.uiState.value.isMeasuring) + assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) + recovery.value = StreamRecoveryState.RECEIVING + assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis) + } + + @Test fun disconnectAndNewProjectClearTimer() { + clock(1_000) + vm.startMeasure() + clock(11_000) + connection.value = DeviceConnectionState.DISCONNECTED + assertFalse(vm.uiState.value.isMeasuring) + assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) + vm.resetWorkspaceForNewProject() + assertNull(vm.uiState.value.measurementStartedAtElapsedMillis) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt index 57b5cbf..403575a 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt @@ -9,6 +9,15 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test class MeasurementExecutionStateTest { + @Test fun recoveryKeepsRecordingAndItsStartTimeWhileShowingReceptionIsUnavailable() { + val notification = state(MainUiState(isMeasuring = true, isStreamRecovering = true, + recordingState = MeasurementRecordingState.RECORDING, recordingStartedAtMillis = 1234)) + assertTrue(notification.active) + assertTrue(notification.measuring) + assertTrue(notification.recording) + assertEquals(1234L, notification.startedAtMillis) + assertTrue(notification.title.contains("수신 복구 중")) + } @Test fun apNegotiationAndTcpWaitKeepServiceButScanningDoesNot() { fun ap(phase: com.dct.app.data.communication.WifiApPhase, present: Boolean = false) = MeasurementExecutionState.from(MainUiState(), DeviceConnectionState.DISCONNECTED, false, diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt index 0e67dd1..65bb7f9 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt @@ -54,6 +54,34 @@ class MeasurementRestartRegressionTest { fixture.tearDown() } + @Test fun receptionRecoveryPreservesMeasurementRecordingAndExistingLoggerUntilDataReturns() { + vm.startMeasure() + val logger = CsvMeasurementLogger(directory.toFile(), listOf("Incar_Snr" to "")) + loggers.add(logger) + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(recordingState = MeasurementRecordingState.RECORDING, + recordingStartedAtMillis = 1234, recordingSampleCount = 10) + val recovery = repository.streamRecovery as MutableStateFlow + val before = ui.value.packetCount + recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECOVERING + assertTrue(ui.value.isMeasuring) + assertTrue(ui.value.isStreamRecovering) + assertEquals(MeasurementRecordingState.RECORDING, ui.value.recordingState) + assertEquals(1234L, ui.value.recordingStartedAtMillis) + assertEquals(10L, ui.value.recordingSampleCount) + assertSame(logger, loggers.single()) + recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECEIVING + packets.tryEmit(DctPacket(0x02, ByteArray(18).also { + ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putFloat(10f).putFloat(2f).putFloat(3f).putFloat(4f) + })) + assertFalse(ui.value.isStreamRecovering) + assertEquals(before + 1, ui.value.packetCount) + assertEquals(11L, ui.value.recordingSampleCount) + assertEquals(1234L, ui.value.recordingStartedAtMillis) + assertSame(logger, loggers.single()) + vm.stopMeasure() + } + @Test fun clearingLoggerListDuringPacketProcessingDoesNotKillSubscription() { vm.startMeasure() val subscribers = packets.subscriptionCount.value diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt new file mode 100644 index 0000000..97239c3 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt @@ -0,0 +1,396 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.data.communication.WifiApEndpoint +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.repository.PacketRepositoryImpl +import com.dct.app.domain.model.protocol.DctMeasurementConfiguration +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.StreamRecoveryState +import io.mockk.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +@OptIn(ExperimentalCoroutinesApi::class) +class MeasurementStreamRecoveryTest { + private class Fixture(val scope: TestScope, timeout: Long = 50, autoRecovery: Boolean = true) { + val wifi = mockk(relaxed = true) + val packets = MutableSharedFlow(extraBufferCapacity = 32) + val connected = MutableStateFlow(true) + var session = 7L + var owner = 42L + var nextSession = 8L + val sent = mutableListOf() + val times = mutableListOf() + val acks = mutableListOf() + val reconnects = mutableListOf>() + val events = mutableListOf() + var silent: Int? = null + var reject: Int? = null + var reconnectWorks = true + var emptyStop = false + var endpoint: WifiApEndpoint? = WifiApEndpoint("192.168.4.1", 5000) + var holdCommand: Int? = null + val gate = CompletableDeferred() + val dispatcher = UnconfinedTestDispatcher(scope.testScheduler) + init { + every { wifi.isConnected } returns connected + every { wifi.currentSessionId } answers { session } + every { wifi.recoveryGeneration } answers { owner } + every { wifi.recoveryEndpoint } answers { endpoint } + every { wifi.packetStream } returns packets + every { wifi.offerCommand(capture(acks)) } returns true + every { wifi.diagnosticEvent(any(), any()) } answers { events += secondArg() } + coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false; session = 0; owner++ } + coEvery { wifi.reconnectForRecovery(any(), any(), any()) } coAnswers { + reconnects += firstArg() to secondArg() + if (firstArg() != owner || !reconnectWorks) false else { + connected.value = false + yield() + session = nextSession++ + connected.value = true + true + } + } + coEvery { wifi.sendCommand(any()) } coAnswers { + val request = firstArg() + sent += request + times += scope.testScheduler.currentTime + val command = request.command.toInt() and 255 + if (holdCommand == command) gate.await() + if (silent != command && command !in listOf(0x0F, 0x17)) { + val data = when (command) { + 1 -> byteArrayOf(1, 65) + 3 -> ByteArray(6) + 9 -> byteArrayOf(request.payload[0], 1) + 0x08 -> byteArrayOf(0, 1) + 0x20 -> byteArrayOf(1, 1) + 5 -> byteArrayOf() + 0x21 -> if (emptyStop) byteArrayOf() else byteArrayOf(1) + else -> byteArrayOf(1) + } + if (reject == command) data[0] = 2 + packets.emit(DctPacket((command + 0x80).toByte(), data, transportSessionId = request.transportSessionId)) + } + true + } + } + val repo = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher, + responseTimeoutMs = timeout, heartbeatIntervalMs = 40, heartbeatTimeoutMs = 20, + heartbeatDispatcher = dispatcher, recoveryDispatcher = dispatcher, + monotonicTimeMillis = { scope.testScheduler.currentTime }, streamSilenceTimeoutMs = 300, + recoveryDataTimeoutMs = 200, scope = scope.backgroundScope, + initialReceiveAutoRecoveryEnabled = autoRecovery) + suspend fun open(measure: Boolean = true) { + assertTrue(repo.connect("") is DctResult.Success) + assertTrue(repo.configureMeasurement(DctMeasurementConfiguration(projectName = "SavedProject", vehicleCode = "Car")) is DctResult.Success) + if (measure) assertTrue(repo.startStreaming() is DctResult.Success) + sent.clear(); times.clear() + } + suspend fun stream(valid: Boolean = true, from: Long = session) = packets.emit( + DctPacket(0xA3.toByte(), if (valid) byteArrayOf(1, 0xA0.toByte(), 9) else byteArrayOf(), transportSessionId = from)) + suspend fun enable() = packets.emit(DctPacket(0x80.toByte(), byteArrayOf(1), transportSessionId = session)) + fun controls() = sent.map { it.command.toInt() and 255 }.filter { it != 5 } + suspend fun close() { repo.disconnect() } + } + + @Test fun repositoryDefaultLeavesAutoRecoveryOff() = runTest { + assertFalse(PacketRepositoryImpl(scope = backgroundScope).receiveAutoRecoveryEnabled.value) + } + + @Test fun disabledRecoveryIgnoresSilenceHeartbeatTimeoutAndUnexpectedEnableButStillAcksData() = runTest { + val f = Fixture(this, autoRecovery = false); f.open() + assertFalse(f.repo.receiveAutoRecoveryEnabled.value) + f.silent = 5 + f.enable() + advanceTimeBy(20_000); runCurrent() + assertTrue(f.controls().isEmpty()) + assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) + assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) + f.stream(); runCurrent() + assertEquals(1, f.acks.size) + assertTrue(f.repo.isStreaming.value) + assertTrue(f.reconnects.isEmpty()) + f.close() + } + + @Test fun disabledRecoveryDoesNotReconnectOnTcpLoss() = runTest { + val f = Fixture(this); f.open() + f.repo.setReceiveAutoRecoveryEnabled(false) + f.session = 0; f.connected.value = false + advanceTimeBy(20_000); runCurrent() + assertTrue(f.reconnects.isEmpty()) + assertTrue(f.controls().isEmpty()) + assertEquals(DeviceConnectionState.ERROR, f.repo.connectionState.value) + assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) + f.close() + } + + @Test fun disablingDuringRecoveryCancelsPendingSetupAndAllLaterStarts() = runTest { + val f = Fixture(this); f.open(); f.holdCommand = 3 + advanceTimeBy(301); runCurrent() + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + f.repo.setReceiveAutoRecoveryEnabled(false) + val count = f.controls().size + f.gate.complete(Unit) + advanceTimeBy(20_000); runCurrent() + assertEquals(count, f.controls().size) + assertFalse(f.controls().contains(0x20)) + assertTrue(f.reconnects.isEmpty()) + assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) + f.close() + } + + @Test fun enablingDuringStalledMeasurementRestoresRecoveryWithoutManualRestart() = runTest { + val f = Fixture(this, autoRecovery = false); f.open() + advanceTimeBy(1_000); runCurrent() + f.repo.setReceiveAutoRecoveryEnabled(true) + advanceTimeBy(421); runCurrent() + assertEquals(1, f.controls().count { it == 0x20 }) + f.stream(); advanceTimeBy(25); runCurrent() + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + f.close() + } + + @Test fun disablingAfterStartKeepsNaturallyResumedTelemetryVisibleWithoutRetrying() = runTest { + val f = Fixture(this); f.open() + advanceTimeBy(621); runCurrent() + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + assertFalse(f.repo.isStreaming.value) + f.repo.setReceiveAutoRecoveryEnabled(false) + val count = f.controls().size + f.stream(); runCurrent() + assertTrue(f.repo.isStreaming.value) + assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) + advanceTimeBy(2_000); runCurrent() + assertEquals(count, f.controls().size) + assertEquals(1, f.acks.size) + f.close() + } + + @Test fun togglingRecoveryDuringHealthyMeasurementDoesNotRestartPlayback() = runTest { + val f = Fixture(this); f.open() + f.repo.setReceiveAutoRecoveryEnabled(false) + f.stream() + f.repo.setReceiveAutoRecoveryEnabled(true) + repeat(8) { advanceTimeBy(150); f.stream(); runCurrent() } + assertTrue(f.controls().isEmpty()) + assertEquals(9, f.acks.size) + f.close() + } + + @Test fun continuousValidDataNeverTriggersRecovery() = runTest { + val f = Fixture(this); f.open() + repeat(20) { advanceTimeBy(150); f.stream(); runCurrent() } + assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) + assertTrue(f.controls().isEmpty()) + assertEquals(20, f.acks.size) + f.close() + } + + @Test fun heartbeatAndStartAckCannotCompleteRecoveryButRealDataCan() = runTest { + val f = Fixture(this); f.open() + advanceTimeBy(299); runCurrent() + assertTrue(f.controls().isEmpty()) + advanceTimeBy(322); runCurrent() // 300 silence + 200 version gap + 120 DID setup. + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) + assertFalse(f.repo.isStreaming.value) + val controls = f.controls().size + assertTrue(f.repo.sendCommand(com.dct.app.data.protocol.DctProtocolRequests.hardwareId()) is DctResult.Failure) + assertEquals(controls, f.controls().size, "User commands must not interleave recovery setup") + assertTrue(f.sent.count { it.command == 5.toByte() } > 1) + assertTrue(f.controls().containsAll(listOf(0x21, 0, 1, 3, 4, 6, 8, 0x40, 0x11, 0x17, 9, 0x12, 0x20))) + val db = f.sent.single { it.command == 8.toByte() } + assertEquals("SavedProject", String(db.payload.copyOfRange(2, db.payload.size))) + advanceTimeBy(50); runCurrent() + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + f.stream(); advanceTimeBy(25); runCurrent() + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + assertTrue(f.repo.isStreaming.value) + verify { f.wifi.diagnosticEvent(7, match { it.startsWith("STREAM_RECOVERY_COMPLETE") }) } + f.close() + } + + @Test fun duplicateEnableDuringHealthyStreamingDoesNotResetTheDevice() = runTest { + val f = Fixture(this); f.open() + f.enable(); f.enable(); f.enable() + repeat(8) { advanceTimeBy(125); f.stream() } + runCurrent() + assertTrue(f.controls().isEmpty()) + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + assertEquals(8, f.acks.size) + f.close() + } + + @Test fun unsolicitedEnableWithNoDataRestoresSettingsAndStartsOnlyOnce() = runTest { + val f = Fixture(this); f.open() + f.enable(); f.enable() + advanceTimeBy(1321); runCurrent() + assertEquals(1, f.controls().count { it == 0x20 }) + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + f.stream(); advanceTimeBy(25); runCurrent() + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + f.close() + } + + @ParameterizedTest @ValueSource(ints = [1, 9, 0x20]) + fun missingSetupResponseRetriesOnANewSessionWithoutEndingMeasurement(command: Int) = runTest { + val f = Fixture(this); f.open(); f.silent = command + var budget = 0L + while (f.events.none { it.startsWith("STREAM_RECOVERY_FAILED") } && budget < 20_000) { + advanceTimeBy(25); runCurrent(); budget += 25 + } + assertTrue(budget < 20_000) + assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + f.silent = null + budget = 0 + while (f.sent.none { it.command == 0x20.toByte() && it.transportSessionId == 8L } && budget < 5_000) { + advanceTimeBy(25); runCurrent(); budget += 25 + } + assertTrue(budget < 5_000) + assertEquals(listOf(42L to 7L), f.reconnects) + f.stream(from = 7) // Late bytes from the failed socket cannot complete recovery. + runCurrent() + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + f.stream(); advanceTimeBy(25); runCurrent() + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + assertTrue(f.sent.filter { it.transportSessionId != 7L }.all { it.transportSessionId == 8L }) + f.close() + } + + @Test fun startAckWithNoDataRetriesInsteadOfClaimingSuccess() = runTest { + val f = Fixture(this); f.open() + advanceTimeBy(2825); runCurrent() + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + coVerify(exactly = 1) { f.wifi.reconnectForRecovery(42, 7, any()) } + advanceTimeBy(320); runCurrent() + f.stream(); advanceTimeBy(25); runCurrent() + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + f.close() + } + + @Test fun eofImmediatelyRecoversWhileKeepingMeasurementIntent() = runTest { + val f = Fixture(this); f.open() + f.session = 0; f.connected.value = false; runCurrent() + assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value) + advanceTimeBy(321); runCurrent() + coVerify(exactly = 1) { f.wifi.reconnectForRecovery(42, 0, any()) } + f.stream(); advanceTimeBy(25); runCurrent() + assertTrue(f.repo.isStreaming.value) + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + f.close() + } + + @Test fun malformedDataDoesNotRenewTheSilenceBudget() = runTest { + val f = Fixture(this); f.open() + repeat(3) { advanceTimeBy(100); f.stream(valid = false) } + runCurrent() + assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value) + assertTrue(f.acks.all { it.payload.single() == 0.toByte() }) + f.close() + } + + @Test fun explicitStopDuringRecoveryCancelsAllLaterStartsAndRetries() = runTest { + val f = Fixture(this); f.open() + advanceTimeBy(301); runCurrent() + assertTrue(f.repo.stopStreaming() is DctResult.Success) + val commands = f.controls().size + advanceTimeBy(20_000); runCurrent() + assertEquals(commands, f.controls().size) + assertFalse(f.controls().contains(0x20)) + assertEquals(DeviceConnectionState.READY, f.repo.connectionState.value) + assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) + f.close() + } + + @Test fun explicitStopAlsoWorksWhileTcpIsUnavailable() = runTest { + val f = Fixture(this); f.open(); f.reconnectWorks = false + f.session = 0; f.connected.value = false; runCurrent() + assertTrue(f.repo.stopStreaming() is DctResult.Success) + advanceTimeBy(20_000); runCurrent() + assertEquals(DeviceConnectionState.DISCONNECTED, f.repo.connectionState.value) + assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value) + assertFalse(f.repo.isStreaming.value) + coVerify(exactly = 1) { f.wifi.reconnectForRecovery(any(), any(), any()) } + f.close() + } + + @Test fun disconnectDuringRecoveryNeverReopensTheTransport() = runTest { + val f = Fixture(this); f.open() + advanceTimeBy(301); runCurrent() + f.close() + val commands = f.sent.size + advanceTimeBy(20_000); runCurrent() + assertEquals(commands, f.sent.size) + assertEquals(DeviceConnectionState.DISCONNECTED, f.repo.connectionState.value) + coVerify(exactly = 0) { f.wifi.reconnectForRecovery(any(), any(), any()) } + } + + @Test fun retryBackoffDoesNotSpinWhenDeviceIsUnavailable() = runTest { + val f = Fixture(this); f.open(); f.reconnectWorks = false + f.session = 0; f.connected.value = false; runCurrent() + advanceTimeBy(1999); runCurrent() + coVerify(exactly = 1) { f.wifi.reconnectForRecovery(any(), any(), any()) } + advanceTimeBy(1); runCurrent() + coVerify(exactly = 2) { f.wifi.reconnectForRecovery(any(), any(), any()) } + advanceTimeBy(3999); runCurrent() + coVerify(exactly = 2) { f.wifi.reconnectForRecovery(any(), any(), any()) } + advanceTimeBy(1); runCurrent() + coVerify(exactly = 3) { f.wifi.reconnectForRecovery(any(), any(), any()) } + f.close() + } + + @Test fun readinessAloneDoesNotStartMeasurementAutomatically() = runTest { + val f = Fixture(this); f.open(measure = false) + advanceTimeBy(20_000); runCurrent() + assertTrue(f.controls().isEmpty()) + f.enable(); advanceTimeBy(250); runCurrent() + assertEquals(listOf(1, 9, 9), f.controls()) + assertEquals(DeviceConnectionState.READY, f.repo.connectionState.value) + f.close() + } + + @Test fun lostStartAckDoesNotResetAlreadyResumedFreshTelemetry() = runTest { + val f = Fixture(this); f.open(); f.silent = 0x20 + advanceTimeBy(621); runCurrent() + repeat(160) { advanceTimeBy(100); f.stream(); runCurrent() } + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + assertTrue(f.repo.isStreaming.value) + assertTrue(f.reconnects.isEmpty()) + assertEquals(1, f.controls().count { it == 0x20 }) + f.close() + } + + @Test fun legacyEmptyStopAckIsAcceptedDuringRecovery() = runTest { + val f = Fixture(this); f.open(); f.emptyStop = true + advanceTimeBy(621); runCurrent() + assertEquals(1, f.controls().count { it == 0x20 }) + f.stream(); advanceTimeBy(25); runCurrent() + assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value) + assertTrue(f.reconnects.isEmpty()) + f.close() + } + + @Test fun manualStopCancelsARecoveryCommandWaitingForAnotherRequest() = runTest { + val f = Fixture(this, timeout = 2000); f.open(); f.holdCommand = 3 + val blocking = async { f.repo.sendCommand(com.dct.app.data.protocol.DctProtocolRequests.hardwareId()) } + runCurrent(); advanceTimeBy(301); runCurrent() + val stop = async { f.repo.stopStreaming() } + runCurrent(); f.gate.complete(Unit) + assertTrue(blocking.await() is DctResult.Success) + assertTrue(stop.await() is DctResult.Success) + assertEquals(listOf(3, 0x21), f.controls()) + advanceTimeBy(2000); runCurrent() + assertFalse(f.controls().contains(0x20)) + f.close() + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt index f2b2ae7..c88e790 100644 --- a/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt @@ -14,6 +14,45 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger class PacketPipelineReliabilityTest { + @Test fun consecutiveAcksAndControlWaitFiveMillisecondsAfterThePreviousWrite() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val entered = CountDownLatch(1) + val releaseFirst = CountDownLatch(1) + val finished = CountDownLatch(4) + val commands = mutableListOf() + val startedAt = mutableListOf() + val completedAt = mutableListOf() + val writer = OrderedPacketWriter(scope, write = { bytes -> + commands += bytes[0].toInt() + startedAt += System.nanoTime() + if (bytes[0] == 0x24.toByte()) { + entered.countDown() + check(releaseFirst.await(2, TimeUnit.SECONDS)) + } + // A slow write must not consume the following frame's quiet interval. + Thread.sleep(10) + completedAt += System.nanoTime() + finished.countDown() + }, closeTransport = { releaseFirst.countDown() }) + try { + assertTrue(writer.offer(byteArrayOf(0x24))) + assertTrue(entered.await(2, TimeUnit.SECONDS)) + assertTrue(writer.offer(byteArrayOf(0x23))) + assertTrue(writer.offer(byteArrayOf(0x50))) + val control = async(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(0x05)) } + releaseFirst.countDown() + assertTrue(finished.await(3, TimeUnit.SECONDS)) + assertTrue(control.await()) + assertEquals(listOf(0x24, 0x05, 0x23, 0x50), commands, + "Control priority and ACK ordering must survive pacing") + for (index in 1 until startedAt.size) { + val quietNanos = startedAt[index] - completedAt[index - 1] + assertTrue(quietNanos >= 5_000_000L, + "Frame $index began after only ${quietNanos / 1_000_000.0}ms of quiet") + } + } finally { writer.close(); scope.cancel() } + } + @Test fun cancelledCallerDoesNotCloseSharedWriter() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val entered = CountDownLatch(1) diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt new file mode 100644 index 0000000..d7dcf6d --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt @@ -0,0 +1,239 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.data.repository.PacketRepositoryImpl +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import io.mockk.* +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class PacketRepositoryEnableRefreshTest { + private class Fixture(val testScope: TestScope, timeoutMs: Long = 50) { + val wifi = mockk(relaxed = true) + val packets = MutableSharedFlow(extraBufferCapacity = 16) + val connected = MutableStateFlow(true) + var sessionId = 7L + val sent = mutableListOf() + val writtenAt = mutableListOf>() + val acks = mutableListOf() + var versionDelay = 0L + var silent: Byte? = null + var sendFailure: Byte? = null + var versionPayload = byteArrayOf(1, 65) + var environmentRejected = false + + init { + every { wifi.isConnected } returns connected + every { wifi.currentSessionId } answers { sessionId } + every { wifi.packetStream } returns packets + every { wifi.offerCommand(capture(acks)) } returns true + every { wifi.invalidateConnectionIfSession(any()) } answers { + if (firstArg() == sessionId && connected.value) { connected.value = false; true } else false + } + coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false } + coEvery { wifi.sendCommand(any()) } coAnswers { + val request = firstArg() + sent += request + if (request.command == sendFailure) return@coAnswers false + if (request.command == 1.toByte()) kotlinx.coroutines.delay(versionDelay) + writtenAt += request to testScope.testScheduler.currentTime + if (request.command != silent && request.command != 0x0F.toByte()) { + val payload = when (request.command.toInt()) { + 1 -> versionPayload + 3 -> ByteArray(6) + 9 -> byteArrayOf(request.payload[0], if (environmentRejected) 2 else 1) + 0x20 -> byteArrayOf(1, 1) + else -> byteArrayOf(1) + } + packets.emit(DctPacket((request.command + 0x80).toByte(), payload, + transportSessionId = request.transportSessionId)) + } + true + } + } + + val repository = PacketRepositoryImpl(wifiSocketManager = wifi, + ioDispatcher = UnconfinedTestDispatcher(testScope.testScheduler), responseTimeoutMs = timeoutMs, + enableHeartbeat = false, monotonicTimeMillis = { testScope.testScheduler.currentTime }, scope = testScope.backgroundScope) + + suspend fun open(measuring: Boolean = false) { + assertTrue(repository.connect("") is DctResult.Success) + if (measuring) assertTrue(repository.startStreaming(30) is DctResult.Success) + sent.clear(); writtenAt.clear() + } + + suspend fun enable(payload: ByteArray = byteArrayOf(1), session: Long = sessionId) { + packets.emit(DctPacket(0x80.toByte(), payload, transportSessionId = session)) + } + } + + @Test + fun readyEnableRefreshesVersionAndReadOnlyEnvironmentWithoutInterruptingAcks() = runTest { + val f = Fixture(this) + f.open() + f.versionDelay = 25 + f.enable() + runCurrent() + // Keep receiving/ACKing while VERSION's actual write is still pending. + f.packets.emit(DctPacket(0xA3.toByte(), byteArrayOf(1, 0xA0.toByte(), 9), transportSessionId = 7)) + f.enable(); f.enable() + runCurrent() + assertEquals(1, f.acks.size) + assertEquals(0x23.toByte(), f.acks.single().command) + assertEquals(listOf(1), f.sent.map { it.command.toInt() }) + advanceTimeBy(224); runCurrent() + assertEquals(1, f.sent.size) + advanceTimeBy(1); runCurrent() + assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() }) + assertEquals(listOf(0, 3), f.sent.filter { it.command == 9.toByte() }.map { it.payload.single().toInt() }) + assertEquals(200L, f.writtenAt[1].second - f.writtenAt[0].second) + assertTrue(f.sent.all { it.transportSessionId == 7L }) + assertFalse(f.repository.isStreaming.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + assertTrue(f.connected.value) + verify(exactly = 1) { f.wifi.diagnosticEvent(7L, match { it.startsWith("ENABLE_REFRESH_COMPLETE") }) } + coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + f.enable() + advanceTimeBy(225); runCurrent() + assertEquals(listOf(1, 9, 9, 1, 9, 9), f.sent.map { it.command.toInt() }, + "A later ENABLE must be able to refresh again after completion") + } + + @Test fun matchingInitialEnableReplyDoesNotStartAnotherRefresh() = runTest { + val f = Fixture(this) + assertTrue(f.repository.connect("") is DctResult.Success) + advanceTimeBy(500); runCurrent() + assertEquals(1, f.sent.count { it.command == 1.toByte() }) + assertEquals(2, f.sent.count { it.command == 9.toByte() }) + verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_") }) } + } + + @Test fun versionReplyAfter200msDoesNotAddAnother200msBeforeEnvironment() = runTest { + val f = Fixture(this, timeoutMs = 1000) + f.open() + coEvery { f.wifi.sendCommand(match { it.command == 1.toByte() }) } coAnswers { + val request = firstArg() + f.sent += request + f.writtenAt += request to testScheduler.currentTime + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + kotlinx.coroutines.delay(250) + f.packets.emit(DctPacket(0x81.toByte(), byteArrayOf(1, 65), transportSessionId = 7)) + } + true + } + f.enable() + advanceTimeBy(249); runCurrent() + assertEquals(listOf(1), f.sent.map { it.command.toInt() }) + advanceTimeBy(1); runCurrent() + assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() }) + assertEquals(250L, f.writtenAt[1].second - f.writtenAt[0].second) + } + + @ParameterizedTest + @ValueSource(strings = ["empty", "nack", "extra", "stale"]) + fun invalidOrOldSessionEnableDoesNotQuery(failure: String) = runTest { + val f = Fixture(this) + f.open() + when (failure) { + "empty" -> f.enable(byteArrayOf()) + "nack" -> f.enable(byteArrayOf(2)) + "extra" -> f.enable(byteArrayOf(1, 1)) + else -> f.enable(session = 6) + } + advanceTimeBy(500); runCurrent() + assertTrue(f.sent.isEmpty()) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + } + + @ParameterizedTest + @ValueSource(strings = ["versionTimeout", "versionMalformed", "versionSendFailed", "environmentTimeout", "environmentRejected"]) + fun failedReadyRefreshReportsWithoutStartingMeasurementOrClosingTcp(failure: String) = runTest { + val f = Fixture(this) + f.open() + when (failure) { + "versionTimeout" -> f.silent = 1 + "versionMalformed" -> f.versionPayload = byteArrayOf(3, 65) + "versionSendFailed" -> f.sendFailure = 1 + "environmentTimeout" -> f.silent = 9 + else -> f.environmentRejected = true + } + f.enable() + advanceTimeBy(500); runCurrent() + assertEquals(if (failure.startsWith("version")) listOf(1) else listOf(1, 9), + f.sent.map { it.command.toInt() }) + assertTrue(f.connected.value) + assertFalse(f.repository.isStreaming.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + verify(exactly = 1) { f.wifi.diagnosticEvent(7L, match { it.startsWith("ENABLE_REFRESH_FAILED") }) } + coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + } + + @Test fun disconnectCancelsRefreshDuringTheVersionEnvironmentGap() = runTest { + val f = Fixture(this) + f.open() + f.enable(); runCurrent() + assertEquals(listOf(1), f.sent.map { it.command.toInt() }) + assertTrue(f.repository.disconnect() is DctResult.Success) + advanceTimeBy(500); runCurrent() + assertEquals(listOf(1, 15), f.sent.map { it.command.toInt() }) + assertEquals(DeviceConnectionState.DISCONNECTED, f.repository.connectionState.value) + verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_COMPLETE") }) } + } + + @Test fun transportLossCancelsOldRefreshAndOldRepliesCannotStartWorkOnNewSession() = runTest { + val f = Fixture(this) + f.open() + f.enable(); runCurrent() + f.connected.value = false; runCurrent() + f.sessionId = 8 + f.connected.value = true + f.open() + f.enable(session = 7) + advanceTimeBy(500); runCurrent() + assertTrue(f.sent.isEmpty()) + f.enable(session = 8) + advanceTimeBy(200); runCurrent() + assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() }) + assertTrue(f.sent.all { it.transportSessionId == 8L }) + } + + @Test fun enableDuringExplicitDisconnectCannotStartAnotherRefresh() = runTest { + val f = Fixture(this) + f.open(measuring = true) + f.silent = 0x21 + val disconnect = async { f.repository.disconnect() } + runCurrent() + f.enable() + advanceTimeBy(50); runCurrent() + assertTrue(disconnect.await() is DctResult.Success) + assertEquals(listOf(0x21, 0x0F), f.sent.map { it.command.toInt() }) + verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_START") }) } + } + + @Test fun commandWaitingForMutexCannotBeRetaggedToAnotherSession() = runTest { + val f = Fixture(this) + f.open() + f.silent = 3 + val blocking = async { f.repository.sendCommand(DctProtocolRequests.hardwareId()) } + runCurrent() + val waiting = async { f.repository.sendCommand(DctProtocolRequests.version().copy(transportSessionId = 7)) } + runCurrent() + f.sessionId = 8 + advanceTimeBy(50); runCurrent() + assertTrue(blocking.await() is DctResult.Failure) + assertTrue(waiting.await() is DctResult.Failure) + assertEquals(listOf(3), f.sent.map { it.command.toInt() }) + assertTrue(f.repository.sendCommand(DctProtocolRequests.version().copy(transportSessionId = 7)) is DctResult.Failure) + assertEquals(listOf(3), f.sent.map { it.command.toInt() }) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt index a44da19..74683c1 100644 --- a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt @@ -23,6 +23,23 @@ import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.ValueSource class PacketRepositoryResponseTest { + @Test fun collectorExceptionAndActualResubscriptionAreObservableWithoutChangingRetry() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + every { f.wifi.offerCommand(any()) } throws IllegalStateException("injected ACK failure") + f.packets.emit(DctPacket(DctCommand.SEND_CAN_FRAME.code, byteArrayOf(1, 0xA0.toByte(), 1), ingressSequence = 42)) + runCurrent() + verify { f.wifi.diagnosticEvent(any(), match { it.contains("COLLECTOR_ERROR") && it.contains("ingress=42") && it.contains("injected ACK failure") }) } + assertEquals(0, f.packets.subscriptionCount.value) + advanceTimeBy(99); runCurrent() + assertEquals(0, f.packets.subscriptionCount.value) + every { f.wifi.offerCommand(any()) } returns true + advanceTimeBy(1); runCurrent() + assertEquals(1, f.packets.subscriptionCount.value) + verify { f.wifi.diagnosticEvent(any(), match { it.contains("COLLECTOR_RESUBSCRIBED") }) } + f.packets.emit(DctPacket(DctCommand.SEND_CAN_FRAME.code, byteArrayOf(1, 0xA0.toByte(), 2))) + runCurrent() + verify(exactly = 2) { f.wifi.offerCommand(any()) } + } @ParameterizedTest @CsvSource("true,false", "false,true") fun xcpCalibrationFollowsConfiguredEndianAcrossProjectChanges(firstMsb: Boolean, secondMsb: Boolean) = runTest { diff --git a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt index ce7b6bf..7bd98b3 100644 --- a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt @@ -137,7 +137,7 @@ class PcOperationalLoggerTest { logger.connected(1, "PC"); logger.frame(1, "Recv", codec.encode(packet)) logger.frame(1, "Send", codec.encode(packet)) logger.close(); logger.awaitClosed() - assertEquals(2, errors.size) // Action creation and first raw creation; no repeated raw attempts. + assertEquals(2, errors.size) // CSV and first TXT; no diagnostic file writer. assertEquals("preserve", notDirectory.readText()) } @@ -160,6 +160,10 @@ class PcOperationalLoggerTest { val frame = codec.encode(packet) val corrupt = frame.copyOf().apply { this[lastIndex - 1] = 0 } peer.getOutputStream().write(corrupt + frame.copyOfRange(0, 3)) + withTimeout(5000) { + while (socketManager.metrics.tcpBytesRead.get() < corrupt.size + 3L) delay(10) + } + assertFalse(received.isCompleted, "A partial/corrupt frame must not be published") peer.getOutputStream().write(frame.copyOfRange(3, frame.size)) assertEquals(packet, withTimeout(5000) { received.await() }) assertTrue(socketManager.sendCommand(packet)) @@ -179,5 +183,7 @@ class PcOperationalLoggerTest { val csv = files("csv").single().readText() assertTrue(csv.contains("Connected 127.0.0.1")) assertTrue(csv.contains("Disconnected")) + assertTrue(files("rxdiag").isEmpty(), "TCP sessions must no longer create .rxdiag files") + assertTrue(folder.listFiles()!!.all { it.extension in setOf("txt", "csv") }) } } diff --git a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt index edab69c..c79ffdd 100644 --- a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt @@ -22,6 +22,38 @@ class SlidingRingBufferParserTest { codec = DctPacketCodec() } + @Test fun diagnosticsAccountForFragmentsNoiseAndChecksumWithoutChangingParsing() { + val frame = codec.encode(DctPacket.create(DctCommand.SEND_CAN_FRAME, byteArrayOf(1, 2, 3))) + val corrupt = frame.copyOf().apply { this[lastIndex - 1] = (this[lastIndex - 1].toInt() xor 1).toByte() } + val prefix = byteArrayOf(9, 8) + corrupt + frame.copyOfRange(0, 4) + assertThat(parser.parseStream(prefix)).isEmpty() + val pending = parser.diagnostics() + assertThat(pending.pendingBytes).isEqualTo(4) + assertThat(pending.expectedBytes).isEqualTo(frame.size) + assertThat(pending.discarded[SlidingRingBufferParser.DiscardReason.CHECKSUM]).isEqualTo(1L) + assertThat(pending.inputBytes).isEqualTo(pending.frameBytes + pending.discardedBytes + pending.pendingBytes) + assertThat(parser.parseStream(frame.copyOfRange(4, frame.size))).hasSize(1) + val complete = parser.diagnostics() + assertThat(complete.frameBytes).isEqualTo(frame.size.toLong()) + assertThat(complete.frames).isEqualTo(1L) + assertThat(complete.pendingBytes).isEqualTo(0) + assertThat(complete.inputBytes).isEqualTo(complete.frameBytes + complete.discardedBytes) + parser.reset() + assertThat(parser.diagnostics().inputBytes).isEqualTo(0L) + assertThat(parser.diagnostics().discardedBytes).isEqualTo(0L) + } + + @Test fun diagnosticSnapshotDoesNotConsumePartialHeader() { + val frame = codec.encode(DctPacket.create(DctCommand.CMD_ENABLE)) + parser.parseStream(frame.copyOfRange(0, 2)) + repeat(10) { + assertThat(parser.diagnostics().pendingBytes).isEqualTo(2) + assertThat(parser.diagnostics().expectedBytes).isNull() + } + assertThat(parser.parseStream(frame.copyOfRange(2, frame.size))).hasSize(1) + assertThat(parser.diagnostics().discardedBytes).isEqualTo(0L) + } + @Test @DisplayName("단일 완전한 패킷 입력 시 즉시 파싱 성공") fun testParseSingleCompletePacket() { diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt index c8a6709..27e3198 100644 --- a/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt @@ -51,6 +51,60 @@ class WifiApManagerTest { manager = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34) } @AfterEach fun teardown() { manager.close(); unmockkAll() } + + @Test fun recoveryRestoresOnlyTheSelectedApWithoutEndingMeasurementOrDuplicatingRequests() { + var switching = 0 + val owner = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34, onConnecting = { switching++ }) + try { + owner.connect(device, "12345678") + val first = callbacks.last(); val network = mockk() + first.onLinkPropertiesChanged(network, properties("192.168.4.20")) + first.onLost(network) + assertEquals(1, lost) + assertFalse(owner.recoverLastConnection()) // IP callback has not arrived yet. + assertEquals(2, callbacks.size) + assertFalse(owner.recoverLastConnection()) // CONNECTING is already in progress. + assertEquals(2, callbacks.size) + assertEquals(1, switching, "An automatic retry must not invoke manual session teardown") + first.onLinkPropertiesChanged(network, properties("192.168.4.20")) + assertFalse(owner.uiState.value.networkReady) + callbacks.last().onLinkPropertiesChanged(mockk(), properties("192.168.4.21")) + assertEquals(2, ready) + assertTrue(owner.recoverLastConnection()) + owner.disconnect() + assertFalse(owner.recoverLastConnection()) + assertEquals(2, callbacks.size, "Explicit Disconnect clears AP retry intent") + } finally { owner.close() } + } + + @Test fun settingsNetworkRecoveryNeverAdoptsAnUnrelatedWifi() { + fun network(name: String): Network { + val network = mockk() + val capabilities = mockk() + val info = mockk() + every { info.ssid } returns "\"$name\"" + every { capabilities.transportInfo } returns info + every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true + every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false + every { connectivity.getNetworkCapabilities(network) } returns capabilities + every { connectivity.getLinkProperties(network) } returns properties("192.168.4.20") + return network + } + val original = network("DCT") + every { connectivity.allNetworks } returns arrayOf(original) + manager.connectCurrent() + callbacks.last().onLost(original) + val unrelated = network("Office") + every { connectivity.allNetworks } returns arrayOf(unrelated) + assertFalse(manager.recoverLastConnection()) + assertEquals(1, ready) + every { connectivity.getNetworkCapabilities(original) } returns connectivity.getNetworkCapabilities(unrelated) + every { connectivity.allNetworks } returns arrayOf(original) + assertFalse(manager.recoverLastConnection(), "A known SSID must match even when Network identity is reused") + every { connectivity.allNetworks } returns arrayOf(network("DCT")) + assertTrue(manager.recoverLastConnection()) + assertEquals(2, ready) + } private fun properties(address: String): LinkProperties { val link = mockk(); every { link.address } returns InetAddress.getByName(address) val properties = mockk(); every { properties.linkAddresses } returns listOf(link) diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt index 1582cd0..c67eaba 100644 --- a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt @@ -22,12 +22,19 @@ class WifiConnectionPreferencesTest { private fun preferences(): SharedPreferences { val values = mutableMapOf() val pending = mutableMapOf() + val integers = mutableMapOf() + val pendingIntegers = mutableMapOf() val prefs = mockk() val editor = mockk() every { prefs.getString(any(), any()) } answers { values[firstArg()] ?: secondArg() } + every { prefs.getInt(any(), any()) } answers { integers[firstArg()] ?: secondArg() } every { prefs.edit() } returns editor every { editor.putString(any(), any()) } answers { pending[firstArg()] = secondArg(); editor } - every { editor.apply() } answers { values.putAll(pending); pending.clear() } + every { editor.putInt(any(), any()) } answers { pendingIntegers[firstArg()] = secondArg(); editor } + every { editor.apply() } answers { + values.putAll(pending); pending.clear() + integers.putAll(pendingIntegers); pendingIntegers.clear() + } return prefs } @@ -39,6 +46,33 @@ class WifiConnectionPreferencesTest { assertNull(input.portNumber) } + @Test fun directPortDefaultsTo5000AndPersistsIndependentlyOfApSettings() { + val prefs=preferences() + val store=WifiConnectionPreferences(prefs) + assertEquals(5000,store.loadDirectPort()) + val ap=WifiConnectionInput("192.168.200.9","5000") + store.save(ap) + store.saveDirectPort(6000) + assertEquals(6000,WifiConnectionPreferences(prefs).loadDirectPort()) + assertEquals(ap,WifiConnectionPreferences(prefs).load()) + store.save(WifiConnectionInput()) + assertEquals(6000,store.loadDirectPort()) + for (port in listOf(1,65535)) { + store.saveDirectPort(port) + assertEquals(port,WifiConnectionPreferences(prefs).loadDirectPort()) + } + for (port in listOf(0,-1,65536)) assertThrows(IllegalArgumentException::class.java) { store.saveDirectPort(port) } + assertEquals(65535,store.loadDirectPort()) + } + + @Test fun invalidStoredDirectPortUses5000() { + val prefs=preferences() + for (port in listOf(0,-1,65536)) { + every { prefs.getInt("direct_port",any()) } returns port + assertEquals(5000,WifiConnectionPreferences(prefs).loadDirectPort()) + } + } + @Test fun lastDraftSurvivesNewStoreAndDoesNotRequireSuccessfulConnection() { val prefs = preferences() val input = WifiConnectionInput("192.168.0.", "") diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt new file mode 100644 index 0000000..097e542 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt @@ -0,0 +1,206 @@ +package com.dct.app.tests + +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import com.dct.app.core.common.DctResult +import com.dct.app.data.communication.* +import com.dct.app.domain.repository.DeviceConnectionState +import com.dct.app.domain.repository.PacketRepository +import com.dct.app.presentation.main.* +import io.mockk.* +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.net.InetAddress + +@Suppress("UNCHECKED_CAST") +class WifiDualModeTest { + private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true } + private inner class Fixture : AutoCloseable { + val owner = MainViewModelTest().also { it.setUp() } + val vm = field(owner, "viewModel").get(owner) as MainViewModel + val repo = field(owner, "packetRepository").get(owner) as PacketRepository + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + val apState = field(vm, "_wifiApState").get(vm) as MutableStateFlow + val directState = MutableStateFlow(WifiDirectUiState(phase=WifiDirectPhase.IDLE, + enabled=true, permissionGranted=true, synchronizedWithOs=true)) + val direct = mockk(relaxed=true) + val ap = mockk(relaxed=true) + val socket = vm.wifiSocketManager + init { + every { direct.uiState } returns directState + every { ap.uiState } returns apState + field(vm, "registeredWifiApManager").set(vm, ap) + vm.registerWifiDirectManager(direct) + coEvery { repo.connect(any()) } answers { + (field(owner,"repositoryConnected").get(owner) as MutableStateFlow).value=true + (field(owner,"connectionState").get(owner) as MutableStateFlow).value=DeviceConnectionState.READY + DctResult.Success(Unit) + } + } + fun connectedDirect(ownerRole: Boolean = false): WifiDirectUiState { + val peer = WifiP2pDevice().apply { deviceName="DCT peer"; deviceAddress="aa:bb:cc:dd:ee:ff" } + val group = mockk { + every { getOwner() } returns peer + every { getClientList() } returns listOf(peer) + every { getInterface() } returns "p2p-test" + } + return directState.value.copy(phase=WifiDirectPhase.CONNECTED, + link=WifiDirectLink(WifiP2pInfo().apply { + groupFormed=true; isGroupOwner=ownerRole; groupOwnerAddress=InetAddress.getByName("192.168.77.1") + }, group)) + } + fun apReady() = WifiApUiState(phase=WifiApPhase.CONNECTED, enabled=true, permissionGranted=true, + localAddress="192.168.4.2", selectedSsid="DCT") + override fun close() { vm.scope.cancel(); owner.tearDown() } + } + + @ParameterizedTest @ValueSource(booleans=[false,true]) + fun directGroupFormationNeverStartsProtocolUntilExplicitConnect(ownerRole: Boolean) { + Fixture().use { f -> + assertEquals(WifiConnectionMode.TCP, f.vm.wifiConnectionMode.value) + assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) + f.directState.value = f.connectedDirect(ownerRole) + assertTrue(f.vm.canConnectCommunication.value) + verify(exactly=0) { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) } + coVerify(exactly=0) { f.repo.connect(any()) } + var ready: ((Boolean,String)->Unit)? = null + every { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) } answers { ready=arg(3) } + f.vm.connectCommunication() + f.vm.connectCommunication() + verify(exactly=1) { f.socket.startDualMode(ownerRole, "192.168.77.1", 5000, any(), any(), any()) } + coVerify(exactly=0) { f.repo.connect(any()) } + ready!!(ownerRole, "192.168.77.1") + coVerify(exactly=1) { f.repo.connect("192.168.77.1") } + coVerify(exactly=1) { f.repo.configureMeasurement(any()) } + } + } + + @Test fun modeChangesSelectOnlyTheirOwnRouteAndKeepApEndpoint() { + Fixture().use { f -> + assertTrue(f.vm.setWifiApEndpoint(WifiApEndpoint("192.168.200.9",5000))) + clearMocks(f.socket, answers=false) + assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) + verify { f.socket.setWifiApRouteProvider(null) } + verify { f.socket.setRecoveryNetworkProvider(null) } + verify { f.socket.setWifiDirectRouteProvider(isNull(inverse=true)) } + assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) + verify { f.socket.setWifiDirectRouteProvider(null) } + verify { f.socket.setWifiApRouteProvider(isNull(inverse=true)) } + assertEquals(WifiApEndpoint("192.168.200.9",5000), f.vm.wifiApEndpoint.value) + } + } + + @ParameterizedTest @ValueSource(booleans=[false,true]) + fun configuredDirectPortIsUsedByBothRolesAndLockedDuringCommunication(ownerRole: Boolean) { + Fixture().use { f -> + assertEquals(5000,f.vm.wifiDirectPort.value) + assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) + f.directState.value=f.connectedDirect(ownerRole) + for (port in listOf(0,65536,-1)) assertFalse(f.vm.setWifiDirectPort(port)) + assertTrue(f.vm.setWifiDirectPort(6000)) + assertEquals(WifiApEndpoint(),f.vm.wifiApEndpoint.value) + f.vm.connectCommunication() + verify(exactly=1) { f.socket.startDualMode(ownerRole,"192.168.77.1",6000,any(),any(),any()) } + assertFalse(f.vm.canEditWifiDirectPort.value) + assertFalse(f.vm.setWifiDirectPort(5000)) + f.vm.disconnectCommunication() + assertTrue(f.vm.setWifiDirectPort(5001)) + assertEquals(5001,f.vm.wifiDirectPort.value) + } + } + + @Test fun connectedMeasuringAndRecordingCannotChangeDirectPort() { + Fixture().use { f -> + for (ui in listOf(MainUiState(isConnected=true),MainUiState(isMeasuring=true), + MainUiState(recordingState=MeasurementRecordingState.RECORDING))) { + f.ui.value=ui + assertFalse(f.vm.setWifiDirectPort(6000)) + assertEquals(5000,f.vm.wifiDirectPort.value) + } + } + } + + @Test fun disconnectKeepsTheDirectGroupAndWifiReleaseRemovesOnlyThatGroup() { + Fixture().use { f -> + f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT) + f.directState.value=f.connectedDirect() + f.ui.value=f.ui.value.copy(isConnected=true, communicationType=CommunicationType.WIFI) + f.vm.disconnectCommunication() + verify(exactly=0) { f.direct.disconnect() } + verify(exactly=0) { f.ap.disconnect() } + coVerify(exactly=1) { f.repo.disconnect() } + assertTrue(f.vm.canConnectCommunication.value) + assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) + f.vm.disconnectCommunication(releaseWifiAp=true) + verify(exactly=1) { f.direct.disconnect() } + verify(exactly=0) { f.ap.disconnect() } + f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) + assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) + } + } + + @Test fun inactiveDirectGroupLossNeverClosesApCommunication() { + Fixture().use { f -> + f.apState.value=f.apReady() + f.directState.value=f.connectedDirect() + f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI) + f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) + verify(exactly=0) { f.socket.invalidateConnection() } + coVerify(exactly=0) { f.repo.disconnect() } + assertTrue(f.vm.uiState.value.isConnected) + } + } + + @Test fun activeDirectGroupLossClosesItsTransportWithoutStartingAnother() { + Fixture().use { f -> + f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT) + f.directState.value=f.connectedDirect() + f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI) + f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE) + verify(exactly=1) { f.socket.invalidateConnection() } + coVerify(exactly=1) { f.repo.disconnect() } + verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) } + assertFalse(f.vm.uiState.value.isConnected) + } + } + + @Test fun apModeAndDirectModeUseIndependentReadiness() { + Fixture().use { f -> + f.apState.value=f.apReady() + assertTrue(f.vm.canConnectCommunication.value) + assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) + f.apState.value=WifiApUiState() + assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) + assertFalse(f.vm.canConnectCommunication.value) + f.apState.value=f.apReady() + assertFalse(f.vm.canConnectCommunication.value) + f.directState.value=f.connectedDirect() + assertTrue(f.vm.canConnectCommunication.value) + f.directState.value=f.directState.value.copy(permissionGranted=false) + assertFalse(f.vm.canConnectCommunication.value) + f.vm.connectCommunication() + verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) } + } + } + + @Test fun measurementRecordingAndNegotiationBlockModeChanges() { + Fixture().use { f -> + for (state in listOf(MainUiState(isMeasuring=true), MainUiState(isCommunicationConnecting=true), + MainUiState(recordingState=MeasurementRecordingState.RECORDING),MainUiState(isConnected=true))) { + f.ui.value=state + assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) + } + f.ui.value=MainUiState() + assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)) + f.directState.value=f.directState.value.copy(phase=WifiDirectPhase.CONNECTING) + assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP)) + f.vm.disconnectCommunication(releaseWifiAp=true) + verify(exactly=1) { f.direct.cancelConnect() } + } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt new file mode 100644 index 0000000..e9197aa --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt @@ -0,0 +1,53 @@ +package com.dct.app.tests + +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.protocol.DctPacketCodec +import com.dct.app.data.protocol.DctProtocolRequests +import kotlinx.coroutines.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.net.ServerSocket +import java.net.Socket + +class WifiSocketRecoveryTest { + @Test fun recoveryReopensTheSameEndpointAndOldOwnerCannotReplaceANewManualSession() = runBlocking { + val manager = WifiSocketManager() + ServerSocket(0).use { server -> + server.soTimeout = 3000 + val peers = mutableListOf() + suspend fun connect() { + val connected = CompletableDeferred() + manager.connect("127.0.0.1", server.localPort, + onSuccess = { connected.complete(Unit) }, + onFailure = { connected.completeExceptionally(IllegalStateException(it)) }) + peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 } + withTimeout(5000) { connected.await() } + } + try { + connect() + val owner = manager.recoveryGeneration + val session = manager.currentSessionId + val endpoint = manager.recoveryEndpoint!! + manager.pauseForNetworkRecovery() + assertFalse(manager.isConnected.value) + assertEquals(owner, manager.recoveryGeneration) + assertEquals(endpoint, manager.recoveryEndpoint) + val recovery = async { manager.reconnectForRecovery(owner, 0, endpoint) } + peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 } + assertTrue(withTimeout(5000) { recovery.await() }) + assertTrue(manager.currentSessionId > session) + assertEquals(owner, manager.recoveryGeneration) + val frame = DctPacketCodec().encode(DctProtocolRequests.possibleToConnect()) + assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect())) + assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) }) + connect() // A manual connection takes ownership, even at the same address. + val newest = manager.currentSessionId + assertFalse(manager.reconnectForRecovery(owner, newest, endpoint)) + assertEquals(newest, manager.currentSessionId) + assertTrue(manager.isConnected.value) + assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect())) + assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) }) + } finally { manager.close(); peers.forEach { it.close() } } + } + } +} diff --git a/DCT_test/DCT_test/App.xaml.cs b/DCT_test/DCT_test/App.xaml.cs index e3fdfc2..bd64f15 100644 --- a/DCT_test/DCT_test/App.xaml.cs +++ b/DCT_test/DCT_test/App.xaml.cs @@ -13,5 +13,33 @@ namespace DCT_test /// public partial class App : Application { + protected override void OnStartup(StartupEventArgs e) + { + if (e.Args.Length > 0 && e.Args[0] == "--allow-network") + { + StartupUri = null; + ShutdownMode = ShutdownMode.OnExplicitShutdown; + try + { + int port; + if (e.Args.Length != 4 || !int.TryParse(e.Args[1], out port)) + throw new ArgumentException("잘못된 통신 허용 요청입니다."); + var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + using (identity) + if (!new System.Security.Principal.WindowsPrincipal(identity) + .IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) + throw new UnauthorizedAccessException("관리자 승인이 필요합니다."); + NetworkSupport.AllowFirewall(new NetworkFirewallSettings(NetworkSupport.ExecutablePath, port, e.Args[2], e.Args[3])); + Shutdown(0); + } + catch (Exception ex) + { + MessageBox.Show(ex.Message, "DCT 통신 허용 실패", MessageBoxButton.OK, MessageBoxImage.Error); + Shutdown(1); + } + return; + } + base.OnStartup(e); + } } } diff --git a/DCT_test/DCT_test/ClientSession.cs b/DCT_test/DCT_test/ClientSession.cs index b7e2705..f1ba29f 100644 --- a/DCT_test/DCT_test/ClientSession.cs +++ b/DCT_test/DCT_test/ClientSession.cs @@ -166,6 +166,21 @@ namespace DCT_test if (!value) ClearPendingTelemetryWrites(); } + /// Protocol Connect/Disconnect is independent of the TCP/Wi-Fi lease. + public void ResetProtocolState() + { + lock (SendLock) + { + ++ControlRevision; // A late START write must not reactivate the previous run. + SetStreaming(false); + ResetTelemetryAcks(); + HandshakeMask = 0; + ConfigurationMask = 0; + XcpUseMode = 0; + XcpConfigurationMask = 0; + } + } + /// /// Enqueues a telemetry frame without waiting for socket I/O. The /// queue is bounded and drops the oldest telemetry frame on overflow; diff --git a/DCT_test/DCT_test/DCT_test.csproj b/DCT_test/DCT_test/DCT_test.csproj index ce3a424..7dda7d1 100644 --- a/DCT_test/DCT_test/DCT_test.csproj +++ b/DCT_test/DCT_test/DCT_test.csproj @@ -15,6 +15,21 @@ true true PackageReference + false + 게시\ + true + Disk + false + Foreground + 7 + Days + false + false + true + 0 + 1.0.0.%2a + false + true AnyCPU @@ -73,6 +88,7 @@ + MainWindow.xaml Code @@ -107,5 +123,17 @@ PreserveNewest + + + False + Microsoft .NET Framework 4.7.2%28x86 및 x64%29 + true + + + False + .NET Framework 3.5 SP1 + false + + diff --git a/DCT_test/DCT_test/DctTcpServer.cs b/DCT_test/DCT_test/DctTcpServer.cs index d0ada45..06d1287 100644 --- a/DCT_test/DCT_test/DctTcpServer.cs +++ b/DCT_test/DCT_test/DctTcpServer.cs @@ -449,6 +449,7 @@ namespace DCT_test switch (packet.CommandId) { case 0x00: // CMD_ENABLE + ResetProtocolSession(session); session.HandshakeMask |= 0x01; SendResponse(session, DctPacket.Create(0x80, new byte[] { 0x01 }, packet.Sequence), "RES_ENABLE (0x80, ACK: 0x01) [HANDSHAKE]"); break; @@ -474,6 +475,11 @@ namespace DCT_test SendResponse(session, DctPacket.Create(0x84, new byte[] { 0x01 }, packet.Sequence), "RES_SYNC_TIME (0x84, ACK: 0x01) [HANDSHAKE]"); break; + case 0x05: // CMD_POSSIBLE_TO_CONNECT: idle probe, not Connect/START. + SendResponse(session, DctPacket.Create(0x85, new byte[0], packet.Sequence), + "RES_POSSIBLE_TO_CONNECT (0x85) [ALIVE]"); + break; + case 0x06: // CMD_CARTYPE string carType = "Unknown"; if (packet.Payload.Length > 1) @@ -524,7 +530,10 @@ namespace DCT_test break; case 0x0F: // CMD_DISABLE - SendResponse(session, DctPacket.Create(0x8F, new byte[] { 0x01 }, packet.Sequence), "RES_DISABLE (0x8F, 0x01) [HANDSHAKE]"); + // PC and current Android define no 8F response. End protocol + // measurement/readiness, leaving the TCP connection usable. + ResetProtocolSession(session); + Log($"[INFO] 통신 해제 완료 (TCP 유지): {session.IpPort}"); break; case 0x10: // CMD_MDB_INFO @@ -710,6 +719,12 @@ namespace DCT_test } } + private void ResetProtocolSession(ClientSession session) + { + session.ResetProtocolState(); + if (!_clients.Values.Any(client => client.Streaming)) OnStopPlaybackRequested?.Invoke(); + } + private void QueueMeasurementResponse(ClientSession session, DctPacket packet, Action onSent) { if (!session.QueueControl(packet.Encode(), sent => diff --git a/DCT_test/DCT_test/MainWindow.xaml b/DCT_test/DCT_test/MainWindow.xaml index e7e9ebf..e9b7a7c 100644 --- a/DCT_test/DCT_test/MainWindow.xaml +++ b/DCT_test/DCT_test/MainWindow.xaml @@ -43,6 +43,11 @@ + +