Browse Source

Improve session lifecycle, protocol handling and recording controls

master
seuuung 2 weeks ago
parent
commit
1c76cdc708
  1. 94
      Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt
  2. 125
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt
  3. 26
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt
  4. 87
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt
  5. 102
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt
  6. 11
      Android/app/src/main/java/com/dct/app/DctApplication.kt
  7. 19
      Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt
  8. 5
      Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt
  9. 16
      Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt
  10. 7
      Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt
  11. 95
      Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt
  12. 176
      Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt
  13. 3
      Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt
  14. 49
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt
  15. 35
      Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt
  16. 202
      Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt
  17. 2
      Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt
  18. 15
      Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt
  19. 71
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonLabel.kt
  20. 92
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt
  21. 102
      Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt
  22. 18
      Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt
  23. 85
      Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt
  24. 137
      Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt
  25. 383
      Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt
  26. 419
      Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt
  27. 22
      Android/app/src/test/java/com/dct/app/tests/RecordingElapsedTimeTest.kt
  28. 193
      Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt
  29. 27
      Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt

94
Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt

@ -0,0 +1,94 @@
package com.dct.app.background
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import com.dct.app.DctApplication
import com.dct.app.data.repository.DpcProjectManager
import com.dct.app.domain.model.chart.AxisPosition
import com.dct.app.domain.model.project.DpcProject
import com.dct.app.presentation.main.MainActivity
import com.dct.app.presentation.mdi.MdiWindowType
import org.junit.Assert.*
import org.junit.Test
import java.io.File
/** No equipment connection: keep the Application/process alive across an explicit exit. */
class ProgramExitSessionTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val app = ApplicationProvider.getApplicationContext<DctApplication>()
@Test fun explicitExitSavesProjectAndNextLaunchHasEmptyWorkspace() {
val name = "exit-session-${System.nanoTime()}.dpc"
val candidates = savedFiles(name)
val first = ActivityScenario.launch(MainActivity::class.java)
val previous = app.session
try {
first.onActivity { activity ->
val vm = previous.viewModel
vm.setCurrentProject(DpcProject(projectFileName = name, vehicleCode = "Exit test"))
vm.mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)
vm.chartRenderer.bindSignal("can_1", "Selected signal", AxisPosition.LEFT_0, 0L)
activity.exitProgram()
}
await { first.state == Lifecycle.State.DESTROYED && previous.isClosed }
val file = candidates.first { it.isFile }
val saved = file.readBytes()
ActivityScenario.launch(MainActivity::class.java).use { second ->
second.onActivity {
val fresh = app.session
assertNotSame(previous, fresh)
assertNotSame(previous.viewModel, fresh.viewModel)
assertNull(fresh.viewModel.currentProject.value)
assertNull(fresh.viewModel.uiState.value.projectFileName)
assertTrue(fresh.viewModel.mdiViewModel.uiState.value.windows.isEmpty())
assertTrue(fresh.viewModel.chartRenderer.seriesListFlow.value.isEmpty())
assertArrayEquals(saved, file.readBytes())
}
}
} finally {
first.close()
candidates.forEach { it.delete() } // Only this test's uniquely named project copies.
}
}
@Test fun recreationAndBackgroundReturnKeepCurrentWorkspace() {
val name = "retained-session-${System.nanoTime()}.dpc"
val candidates = savedFiles(name)
try {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
val retained = app.session
scenario.onActivity {
retained.viewModel.setCurrentProject(DpcProject(projectFileName = name))
retained.viewModel.mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)
}
scenario.recreate()
scenario.moveToState(Lifecycle.State.CREATED)
scenario.moveToState(Lifecycle.State.RESUMED)
scenario.onActivity {
assertSame(retained, app.session)
assertEquals(name, retained.viewModel.currentProject.value?.projectFileName)
assertFalse(retained.viewModel.mdiViewModel.uiState.value.windows.isEmpty())
}
}
} finally {
candidates.forEach { it.delete() }
}
}
private fun savedFiles(name: String) = listOfNotNull(
File(DpcProjectManager.getDefaultConfigurationFolder(app), name),
app.getExternalFilesDir(null)?.let { File(it, "Documents/DCT Work/Configuration/$name") },
File(app.filesDir, "Documents/DCT Work/Configuration/$name")
)
private fun await(condition: () -> Boolean) {
val end = android.os.SystemClock.elapsedRealtime() + 10_000
while (!condition() && android.os.SystemClock.elapsedRealtime() < end) {
instrumentation.waitForIdleSync()
Thread.sleep(25)
}
assertTrue("Session lifecycle did not finish", condition())
}
}

125
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt

@ -22,7 +22,6 @@ class WifiApConnectionUiTest {
private var cancelled = 0 private var cancelled = 0
private var dismissed = false private var dismissed = false
private var disconnected = 0 private var disconnected = 0
private var retried = 0
private var current = 0 private var current = 0
private var scanned = 0 private var scanned = 0
private var settingsAction: String? = null private var settingsAction: String? = null
@ -30,6 +29,7 @@ class WifiApConnectionUiTest {
private val endpointEditable = mutableStateOf(true) private val endpointEditable = mutableStateOf(true)
private val communicationReady = mutableStateOf(false) private val communicationReady = mutableStateOf(false)
private val socketConnected = mutableStateOf(false) private val socketConnected = mutableStateOf(false)
private val communicationFailed = mutableStateOf(false)
private val connectedEndpoint = mutableStateOf<WifiApEndpoint?>(null) private val connectedEndpoint = mutableStateOf<WifiApEndpoint?>(null)
private var savedEndpoint: WifiApEndpoint? = null private var savedEndpoint: WifiApEndpoint? = null
@Test fun installedAppHasPermissionToRequestTheEquipmentNetwork() { @Test fun installedAppHasPermissionToRequestTheEquipmentNetwork() {
@ -41,13 +41,28 @@ class WifiApConnectionUiTest {
compose.setContent { MaterialTheme { compose.setContent { MaterialTheme {
WifiApConnectionContent(state.value, communicationReady.value, socketConnected.value, WifiApConnectionContent(state.value, communicationReady.value, socketConnected.value,
onScan = { scanned++ }, onConnect = { ap, password -> connected = ap to password }, onScan = { scanned++ }, onConnect = { ap, password -> connected = ap to password },
onCancel = { cancelled++ }, onDisconnect = { disconnected++ }, onRetry = { retried++ }, onCancel = { cancelled++ }, onDisconnect = { disconnected++ },
onCurrent = { current++ }, onPermissions = {}, onSettings = { settingsAction = it }, onCurrent = { current++ }, onPermissions = {}, onSettings = { settingsAction = it },
onDismiss = { dismissed = true }, endpoint = endpoint.value, connectedEndpoint = connectedEndpoint.value, onDismiss = { dismissed = true }, endpoint = endpoint.value, connectedEndpoint = connectedEndpoint.value,
canEditEndpoint = endpointEditable.value, canEditEndpoint = endpointEditable.value, communicationFailed = communicationFailed.value,
onEndpointChange = { savedEndpoint = it; endpoint.value = it; true }) onEndpointChange = { savedEndpoint = it; endpoint.value = it; true })
} } } }
} }
@Test fun handshakeFailureDoesNotRemainInitializingOrExposePopupProtocolActions() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = "DCT_DEVICE",
localAddress = "192.168.4.2", activeNetworkKey = "test", connectedNetworks = listOf(
ConnectedWifiNetwork("test", "DCT_DEVICE", "192.168.4.2")
))
socketConnected.value = true
show()
compose.onNodeWithText("장비 초기화 중…").assertExists()
compose.runOnIdle { communicationFailed.value = true; socketConnected.value = false }
compose.onNodeWithText("장비 초기화 중…").assertDoesNotExist()
compose.onNodeWithText("통신 연결 실패").assertExists()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, current) }
}
@Test fun fixedEndpointAndPasswordFlowReplaceP2pAndManualTcpInputs() { @Test fun fixedEndpointAndPasswordFlowReplaceP2pAndManualTcpInputs() {
show() show()
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist()
@ -110,7 +125,7 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist()
} }
@Test fun connectedRowOpensDetailsForRetryAndDisconnect() { @Test fun connectedRowOpensDetailsForWifiDisconnectOnly() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, state.value = state.value.copy(phase = WifiApPhase.CONNECTED,
selectedSsid = device.ssid, localAddress = "192.168.4.20") selectedSsid = device.ssid, localAddress = "192.168.4.20")
show() show()
@ -118,9 +133,8 @@ class WifiApConnectionUiTest {
compose.onNodeWithText("연결 해제").assertDoesNotExist() compose.onNodeWithText("연결 해제").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-current").performClick() compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed() compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed()
compose.onNodeWithText("통신 재연결").performClick() compose.onNodeWithText("통신 재연결").assertDoesNotExist()
compose.runOnIdle { assertEquals(1, retried); assertEquals(0, disconnected) } compose.runOnIdle { assertEquals(0, disconnected) }
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("연결 해제").performClick() compose.onNodeWithText("연결 해제").performClick()
compose.runOnIdle { assertEquals(1, disconnected) } compose.runOnIdle { assertEquals(1, disconnected) }
} }
@ -138,7 +152,7 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.onNodeWithText("연결 해제").assertDoesNotExist() compose.onNodeWithText("연결 해제").assertDoesNotExist()
compose.onNodeWithText("확인").performClick() compose.onNodeWithText("확인").performClick()
compose.runOnIdle { assertEquals(0, current); assertEquals(0, retried); assertNull(connected) } compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
} }
@Test fun managedNetworkIsShownOnlyOnceAndOtherConnectedWifiRemainsVisible() { @Test fun managedNetworkIsShownOnlyOnceAndOtherConnectedWifiRemainsVisible() {
@ -150,17 +164,17 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed() compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed()
compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist() compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist()
compose.onNodeWithTag("wifi:connected:102").assertIsDisplayed() compose.onNodeWithTag("wifi:connected:102").assertIsDisplayed()
compose.onNodeWithText("통신 연결 대기").assertIsDisplayed() compose.onAllNodesWithText("Wi-Fi 연결됨").assertCountEquals(2)
compose.onNodeWithTag("wifi:ap-current").performClick() compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("통신 재연결").assertIsDisplayed() compose.onNodeWithText("통신 재연결").assertDoesNotExist()
} }
@Test fun listFooterStartsCommunicationAndDetailsNoLongerContainsTheConnectButton() { @Test fun listAndDetailsDoNotExposeProtocolConnectButtons() {
endpoint.value = WifiApEndpoint("192.168.1.100", 6000) endpoint.value = WifiApEndpoint("192.168.1.100", 6000)
state.value = state.value.copy(connectedNetworks = listOf( state.value = state.value.copy(connectedNetworks = listOf(
ConnectedWifiNetwork("101", device.ssid, "192.168.1.20"))) ConnectedWifiNetwork("101", device.ssid, "192.168.1.20")))
show() show()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:connected:101").performClick() compose.onNodeWithTag("wifi:connected:101").performClick()
compose.onNode(hasText("접속 설정: 192.168.1.100:6000") and compose.onNode(hasText("접속 설정: 192.168.1.100:6000") and
hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertIsDisplayed() hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertIsDisplayed()
@ -169,21 +183,20 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, current); assertNull(connected) } compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
compose.onNodeWithText("확인").performClick() compose.onNodeWithText("확인").performClick()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled().performClick() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed()
compose.runOnIdle { compose.runOnIdle {
assertEquals(1, current); assertEquals(0, retried); assertEquals(0, disconnected) assertEquals(0, current); assertEquals(0, disconnected)
assertFalse(dismissed) assertFalse(dismissed)
assertNull(connected); assertEquals(WifiApEndpoint("192.168.1.100", 6000), endpoint.value) assertNull(connected); assertEquals(WifiApEndpoint("192.168.1.100", 6000), endpoint.value)
} }
} }
@Test fun communicationShortcutIsDisabledForUnavailableAmbiguousOrBusyConnections() { @Test fun popupDoesNotOfferCommunicationEvenWhenNetworkStateChanges() {
val network = ConnectedWifiNetwork("101", device.ssid, "192.168.1.20") val network = ConnectedWifiNetwork("101", device.ssid, "192.168.1.20")
val base = state.value.copy(connectedNetworks = listOf(network)) val base = state.value.copy(connectedNetworks = listOf(network))
state.value = base state.value = base
show() show()
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
listOf(base.copy(permissionGranted = false), base.copy(enabled = false), listOf(base.copy(permissionGranted = false), base.copy(enabled = false),
base.copy(phase = WifiApPhase.CONNECTING), base.copy(phase = WifiApPhase.CONNECTING),
base.copy(connectedNetworks = emptyList()), base.copy(connectedNetworks = emptyList()),
@ -191,18 +204,18 @@ class WifiApConnectionUiTest {
base.copy(connectedNetworks = listOf(network, ConnectedWifiNetwork("102", "Other", "192.168.2.20")))) base.copy(connectedNetworks = listOf(network, ConnectedWifiNetwork("102", "Other", "192.168.2.20"))))
.forEach { unavailable -> .forEach { unavailable ->
compose.runOnIdle { state.value = unavailable } compose.runOnIdle { state.value = unavailable }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
} }
compose.runOnIdle { state.value = base; endpointEditable.value = false } compose.runOnIdle { state.value = base; endpointEditable.value = false }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { endpointEditable.value = true; communicationReady.value = true } compose.runOnIdle { endpointEditable.value = true; communicationReady.value = true }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { communicationReady.value = false; socketConnected.value = true } compose.runOnIdle { communicationReady.value = false; socketConnected.value = true }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { socketConnected.value = false; endpoint.value = WifiApEndpoint("0.0.0.0", 6000) } compose.runOnIdle { socketConnected.value = false; endpoint.value = WifiApEndpoint("0.0.0.0", 6000) }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { endpoint.value = WifiApEndpoint() } compose.runOnIdle { endpoint.value = WifiApEndpoint() }
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, current); assertNull(connected) } compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
} }
@ -274,16 +287,53 @@ class WifiApConnectionUiTest {
show() show()
compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31) compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31)
compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed() compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled() compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
val connectBounds = compose.onNodeWithTag("wifi:connected-connect").fetchSemanticsNode().boundsInRoot
val closeBounds = compose.onNodeWithText("닫기").fetchSemanticsNode().boundsInRoot
assertTrue(closeBounds.left >= connectBounds.right)
assertEquals(connectBounds.center.y, closeBounds.center.y, 1f)
compose.onNodeWithContentDescription("새로고침").assertIsDisplayed().performClick() compose.onNodeWithContentDescription("새로고침").assertIsDisplayed().performClick()
compose.onNodeWithText("닫기").assertIsDisplayed().performClick() compose.onNodeWithText("닫기").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals(1, scanned); assertTrue(dismissed) } compose.runOnIdle { assertEquals(1, scanned); assertTrue(dismissed) }
} }
@Test fun equipmentConnectionScrollsItsMovedRowToTheTop() {
state.value = state.value.copy(devices = (1..40).map { device.copy(ssid = "DCT_$it") })
show()
compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(40)
compose.onNodeWithTag("wifi:ap:DCT_40").assertIsDisplayed()
compose.runOnIdle {
state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = "DCT_40")
}
compose.runOnIdle {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, localAddress = "192.168.4.2",
activeNetworkKey = "equipment", connectedNetworks = listOf(
ConnectedWifiNetwork("equipment", "DCT_40", "192.168.4.2")))
}
val row = compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed().fetchSemanticsNode().boundsInRoot
val list = compose.onNodeWithTag("wifi:ap-list").fetchSemanticsNode().boundsInRoot
assertEquals(list.top, row.top, 1f)
compose.onNodeWithText("닫기").assertIsDisplayed()
compose.runOnIdle { assertEquals(0, current); assertEquals(0, disconnected) }
}
@Test fun systemWifiConnectionScrollsToTopButScanUpdatesKeepManualScroll() {
state.value = state.value.copy(devices = (1..40).map { device.copy(ssid = "DCT_$it") })
show()
compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(40)
compose.onNodeWithTag("wifi:ap:DCT_40").assertIsDisplayed()
compose.runOnIdle {
state.value = state.value.copy(connectedNetworks = listOf(
ConnectedWifiNetwork("system", "DCT_40", "192.168.1.20")))
}
val row = compose.onNodeWithTag("wifi:connected:system").assertIsDisplayed().fetchSemanticsNode().boundsInRoot
val list = compose.onNodeWithTag("wifi:ap-list").fetchSemanticsNode().boundsInRoot
assertEquals(list.top, row.top, 1f)
compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31)
compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed()
compose.runOnIdle {
state.value = state.value.copy(devices = state.value.devices.map { it.copy(level = it.level - 1) })
}
compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed()
compose.runOnIdle { assertEquals(0, current); assertEquals(0, disconnected) }
}
@Test fun alreadyConnectedWifiActionRemainsAvailableInOverflow() { @Test fun alreadyConnectedWifiActionRemainsAvailableInOverflow() {
show() show()
compose.onNodeWithText("현재 Wi-Fi로 연결").assertDoesNotExist() compose.onNodeWithText("현재 Wi-Fi로 연결").assertDoesNotExist()
@ -296,15 +346,18 @@ class WifiApConnectionUiTest {
show() show()
compose.onNodeWithContentDescription("더보기").performClick() compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick() compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-guide").assertIsDisplayed().assertTextEquals(
"장치와 같은 Wi-Fi에 연결한 뒤 장치의 IPv4 주소와 포트를 입력하세요. 기본값은 192.168.4.1:5000입니다.")
compose.onNodeWithText("PC", substring = true).assertDoesNotExist()
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100")
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("65536") compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("65536")
compose.onNodeWithTag("wifi:tcp-save").assertIsNotEnabled() compose.onNodeWithTag("wifi:tcp-save").assertIsNotEnabled()
compose.onNodeWithTag("wifi:tcp-connect").assertIsNotEnabled() compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000") compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000")
compose.onNodeWithTag("wifi:tcp-save").performClick() compose.onNodeWithTag("wifi:tcp-save").performClick()
compose.runOnIdle { compose.runOnIdle {
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint) assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint)
assertEquals(0, current); assertEquals(0, retried) assertEquals(0, current)
} }
compose.onNodeWithContentDescription("더보기").performClick() compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("연결 정보").performClick() compose.onNodeWithText("연결 정보").performClick()
@ -315,26 +368,28 @@ class WifiApConnectionUiTest {
compose.runOnIdle { assertEquals(WifiApEndpoint(), savedEndpoint) } compose.runOnIdle { assertEquals(WifiApEndpoint(), savedEndpoint) }
} }
@Test fun savingAndConnectingUsesNewTargetForCurrentWifiAndConnectedAp() { @Test fun savingTargetNeverConnectsForCurrentWifiOrConnectedAp() {
show() show()
compose.onNodeWithContentDescription("더보기").performClick() compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick() compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100")
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000") compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000")
compose.onNodeWithTag("wifi:tcp-connect").performClick() compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:tcp-save").performClick()
compose.runOnIdle { compose.runOnIdle {
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint) assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint)
assertEquals(1, current) assertEquals(0, current)
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, state.value = state.value.copy(phase = WifiApPhase.CONNECTED,
selectedSsid = "PC LAN", localAddress = "192.168.1.20") selectedSsid = "PC LAN", localAddress = "192.168.1.20")
} }
compose.onNodeWithTag("wifi:ap-current").performClick() compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick() compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("7000") compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("7000")
compose.onNodeWithTag("wifi:tcp-connect").performClick() compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:tcp-save").performClick()
compose.runOnIdle { compose.runOnIdle {
assertEquals(7000, savedEndpoint!!.port) assertEquals(7000, savedEndpoint!!.port)
assertEquals(1, retried); assertEquals(1, current) assertEquals(0, current)
} }
} }

26
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt

@ -0,0 +1,26 @@
package com.dct.app.presentation.monitor.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
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 DebugValueResetUiTest {
@get:Rule val compose = createComposeRule()
@Test fun resetActionIsVisibleOnlyInDebugAndInvokesItsOwnCallback() {
val debug = mutableStateOf(false)
var clicks = 0
compose.setContent { MaterialTheme {
DebugValueResetButton(isDebugBuild = debug.value, onClick = { clicks++ })
} }
compose.onNodeWithText("값 초기화").assertDoesNotExist()
compose.runOnIdle { debug.value = true }
compose.onNodeWithText("값 초기화").assertIsDisplayed().assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, clicks); debug.value = false }
compose.onNodeWithText("값 초기화").assertDoesNotExist()
}
}

87
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt

@ -2,6 +2,7 @@ package com.dct.app.presentation.monitor.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -9,17 +10,103 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.test.* import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import com.dct.app.presentation.main.MainUiState
import com.dct.app.presentation.main.MeasurementRecordingState
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.concurrent.atomic.AtomicLong
class RecordingRibbonButtonUiTest { class RecordingRibbonButtonUiTest {
@get:Rule val compose = createComposeRule() @get:Rule val compose = createComposeRule()
@Test fun elapsedTimeOnlyAppearsForAnActuallyStartedRecording() {
val state = mutableStateOf(MainUiState())
compose.setContent {
RecordingRibbonLabel(state.value.recordingState, state.value.recordingStartedAtMillis) { 6_000L }
}
compose.onNodeWithText("신호 기록").assertIsDisplayed()
for (phase in listOf(MeasurementRecordingState.IDLE, MeasurementRecordingState.STARTING,
MeasurementRecordingState.STOPPING, MeasurementRecordingState.ERROR)) {
compose.runOnIdle { state.value = MainUiState(recordingState = phase, recordingStartedAtMillis = 1_000L) }
compose.onNodeWithTag("RecordingElapsedTime").assertDoesNotExist()
}
compose.runOnIdle { state.value = MainUiState(recordingState = MeasurementRecordingState.RECORDING) }
compose.onNodeWithTag("RecordingElapsedTime").assertDoesNotExist()
compose.runOnIdle { state.value = state.value.copy(recordingStartedAtMillis = 1_000L) }
compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05")
}
@Test fun timerTicksWithoutPacketsAndRestartUsesNewRecordingStart() {
val state = mutableStateOf(MainUiState(recordingState = MeasurementRecordingState.RECORDING,
recordingStartedAtMillis = 1_000L))
val clock = AtomicLong(6_000L)
compose.setContent {
RecordingRibbonLabel(state.value.recordingState, state.value.recordingStartedAtMillis, clock::get)
}
compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05")
clock.set(8_000L)
compose.mainClock.advanceTimeBy(1_100)
compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:07")
compose.runOnIdle { state.value = MainUiState(recordingState = MeasurementRecordingState.IDLE) }
compose.onNodeWithTag("RecordingElapsedTime").assertDoesNotExist()
compose.runOnIdle { state.value = MainUiState(recordingState = MeasurementRecordingState.RECORDING,
recordingStartedAtMillis = 8_000L) }
compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:00")
}
@Test fun largeFontKeepsTimeBesideLabelAndSeparateFromRecordingButtons() {
compose.setContent {
CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 1.5f)) {
Row(Modifier.height(44.dp).background(Color.White)) {
RecordingRibbonLabel(MeasurementRecordingState.RECORDING, 1_000L) { 3_662_000L }
RecordingRibbonButton("신호 기록 시작", enabled = false, onClick = {})
RecordingRibbonButton("신호 기록 중지", enabled = true, isStop = true, onClick = {})
}
}
}
val label = compose.onNodeWithText("신호 기록").assertIsDisplayed().fetchSemanticsNode().boundsInRoot
val time = compose.onNodeWithTag("RecordingElapsedTime").assertIsDisplayed()
.assertTextEquals("01:01:01").fetchSemanticsNode().boundsInRoot
val start = compose.onNodeWithContentDescription("신호 기록 시작").assertIsNotEnabled()
.fetchSemanticsNode().boundsInRoot
compose.onNodeWithContentDescription("신호 기록 중지").assertIsEnabled()
assertTrue(label.right <= time.left)
assertTrue(time.right <= start.left)
assertEquals(label.center.y, time.center.y, 1f)
}
@Test fun backgroundStopsUiTicksAndResumeRecomputesElapsedTime() {
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) {
RecordingRibbonLabel(MeasurementRecordingState.RECORDING, 1_000L, clock::get)
}
}
compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05")
compose.runOnIdle { owner.registry.currentState = Lifecycle.State.CREATED }
clock.set(30_000L)
compose.mainClock.advanceTimeBy(1_100)
compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05")
compose.runOnIdle { owner.registry.currentState = Lifecycle.State.RESUMED }
compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:29")
}
private fun redPixels(image: ImageBitmap): Int { private fun redPixels(image: ImageBitmap): Int {
val pixels = image.toPixelMap() val pixels = image.toPixelMap()
return (0 until image.width).sumOf { x -> return (0 until image.width).sumOf { x ->

102
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt

@ -0,0 +1,102 @@
package com.dct.app.presentation.monitor.components
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.dct.app.data.communication.WifiApPhase
import com.dct.app.data.communication.WifiApUiState
import com.dct.app.presentation.main.CommunicationType
import com.dct.app.presentation.main.MainUiState
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
class WifiConnectionRibbonUiTest {
@get:Rule val compose = createComposeRule()
@Test fun wifiConnectDisconnectAreAdjacentAndActionsRemainSeparateDuringConnection() {
val ui = mutableStateOf(MainUiState())
val canConnect = mutableStateOf(true)
var wifiClicks = 0
var connectClicks = 0
var disconnectClicks = 0
var wifiDisconnectClicks = 0
compose.setContent { MaterialTheme { Row {
WifiConnectionRibbonButtons(ui.value, WifiApUiState(
phase = WifiApPhase.CONNECTED, selectedSsid = "DCT", localAddress = "192.168.4.2"),
canConnect.value, { wifiClicks++ }, { connectClicks++ }, { disconnectClicks++ },
{ wifiDisconnectClicks++ })
} } }
val wifi = compose.onNodeWithContentDescription("Wi-Fi AP 연결됨 (DCT)")
val connect = compose.onNodeWithText("Connect")
val disconnect = compose.onNodeWithText("Disconnect")
val wifiDisconnect = compose.onNodeWithContentDescription("Wi-Fi 연결 해제")
compose.onNodeWithText("Wi-Fi 해제").assertDoesNotExist()
val wifiBounds = wifi.fetchSemanticsNode().boundsInRoot
val connectBounds = connect.fetchSemanticsNode().boundsInRoot
val disconnectBounds = disconnect.fetchSemanticsNode().boundsInRoot
assertTrue(connectBounds.left >= wifiBounds.right)
assertTrue(disconnectBounds.left >= connectBounds.right)
assertEquals(wifiBounds.center.y, connectBounds.center.y, 1f)
assertEquals(connectBounds.center.y, disconnectBounds.center.y, 1f)
compose.onAllNodesWithContentDescription("통신 끊기 (Disconnect)").assertCountEquals(1)
wifiDisconnect.assertIsEnabled()
disconnect.assertIsNotEnabled()
wifi.performClick()
compose.runOnIdle { assertEquals(1, wifiClicks); assertEquals(0, connectClicks) }
connect.assertIsEnabled().performClick()
compose.runOnIdle {
assertEquals(1, connectClicks)
ui.value = MainUiState(isCommunicationConnecting = true)
canConnect.value = false
}
connect.assertIsNotEnabled()
disconnect.assertIsEnabled().performClick()
compose.runOnIdle {
assertEquals(1, disconnectClicks)
assertEquals(0, wifiDisconnectClicks)
ui.value = MainUiState(isConnected = true, communicationType = CommunicationType.WIFI)
}
connect.assertIsNotEnabled()
wifi.assertIsEnabled()
disconnect.assertIsEnabled()
disconnect.performClick()
compose.runOnIdle {
assertEquals(2, disconnectClicks)
assertEquals(1, connectClicks)
assertEquals(1, wifiClicks)
ui.value = MainUiState()
canConnect.value = true
}
connect.assertIsEnabled()
disconnect.assertIsNotEnabled()
wifi.assertIsEnabled()
wifiDisconnect.assertIsEnabled().performClick()
compose.runOnIdle {
assertEquals(1, wifiDisconnectClicks)
assertEquals(2, disconnectClicks)
}
}
@Test fun wifiDisconnectIsAvailableDuringApConnectionButProtocolDisconnectIsNot() {
val ap = mutableStateOf(WifiApUiState(phase = WifiApPhase.CONNECTING))
var wifiDisconnects = 0
var protocolDisconnects = 0
compose.setContent { MaterialTheme { Row {
WifiConnectionRibbonButtons(MainUiState(), ap.value, false, {}, {},
{ protocolDisconnects++ }, { wifiDisconnects++ })
} } }
compose.onNodeWithText("Disconnect").assertIsNotEnabled()
compose.onNodeWithText("Wi-Fi 해제").assertDoesNotExist()
compose.onNodeWithContentDescription("Wi-Fi 연결 해제").assertIsEnabled().performClick()
compose.runOnIdle {
assertEquals(1, wifiDisconnects)
assertEquals(0, protocolDisconnects)
ap.value = WifiApUiState()
}
compose.onNodeWithContentDescription("Wi-Fi 연결 해제").assertIsNotEnabled()
compose.onNodeWithText("Disconnect").assertIsNotEnabled()
}
}

11
Android/app/src/main/java/com/dct/app/DctApplication.kt

@ -5,7 +5,16 @@ import com.dct.app.background.DctSessionRuntime
/** One process-wide session survives Activity recreation and a return from the launcher. */ /** One process-wide session survives Activity recreation and a return from the launcher. */
class DctApplication : Application() { class DctApplication : Application() {
val session: DctSessionRuntime by lazy { DctSessionRuntime(this) } private var currentSession: DctSessionRuntime? = null
val session: DctSessionRuntime
get() = currentSession ?: DctSessionRuntime(this).also { currentSession = it }
/** Only an explicit program exit retires the retained workspace. */
fun releaseSession(expected: DctSessionRuntime) {
if (currentSession !== expected) return
expected.close()
currentSession = null
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

19
Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt

@ -22,6 +22,7 @@ import com.dct.app.presentation.signaltree.SignalTreeViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -49,6 +50,10 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
onFailure = { Log.e("PcOperationalLogger", "운영 로그 저장 실패 (통신·계측은 유지)", it) } onFailure = { Log.e("PcOperationalLogger", "운영 로그 저장 실패 (통신·계측은 유지)", it) }
) )
private var observing = false private var observing = false
var isClosed = false
private set
var programExitRequested = false
private set
private var uiVisible = false private var uiVisible = false
@Volatile private var serviceRequested = false @Volatile private var serviceRequested = false
private val _serviceRunning = MutableStateFlow(false) private val _serviceRunning = MutableStateFlow(false)
@ -90,6 +95,7 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
/** Start while handling a visible user connection request, before P2P/TCP negotiation begins. */ /** Start while handling a visible user connection request, before P2P/TCP negotiation begins. */
@Synchronized @Synchronized
fun ensureForegroundService(): Boolean { fun ensureForegroundService(): Boolean {
if (isClosed) return false
// A running service may already have queued stopSelf(). Deliver a fresh startId // A running service may already have queued stopSelf(). Deliver a fresh startId
// for a new connection instead of relying on a stale "running" flag. // for a new connection instead of relying on a stale "running" flag.
if (serviceRequested && !serviceRunning.value) return true if (serviceRequested && !serviceRunning.value) return true
@ -106,6 +112,7 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
} }
fun onUiStarted() { fun onUiStarted() {
if (isClosed) return
uiVisible = true uiVisible = true
viewModel.setApplicationVisible(true) viewModel.setApplicationVisible(true)
if (!observing) { if (!observing) {
@ -123,4 +130,16 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
fun onUiStopped() { uiVisible = false; viewModel.setApplicationVisible(false) } fun onUiStopped() { uiVisible = false; viewModel.setApplicationVisible(false) }
fun onServiceStarted() { serviceRequested = true; _serviceRunning.value = true } fun onServiceStarted() { serviceRequested = true; _serviceRunning.value = true }
fun onServiceStopped() { serviceRequested = false; _serviceRunning.value = false } fun onServiceStopped() { serviceRequested = false; _serviceRunning.value = false }
fun requestProgramExit() { programExitRequested = true }
/** Call only after STOP/disconnect and file finalization have completed. */
fun close() {
if (isClosed) return
isClosed = true
scope.cancel()
appContext.stopService(Intent(appContext, MeasurementForegroundService::class.java))
viewModelStore.clear()
operationalLogger.close()
}
} }

5
Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt

@ -58,7 +58,7 @@ class MeasurementForegroundService : Service() {
} catch (error: RuntimeException) { } catch (error: RuntimeException) {
Log.e(TAG, "Foreground promotion failed", error) Log.e(TAG, "Foreground promotion failed", error)
session.viewModel.setStatusMessage("백그라운드 계측 서비스를 시작하지 못했습니다: ${error.message}") session.viewModel.setStatusMessage("백그라운드 계측 서비스를 시작하지 못했습니다: ${error.message}")
session.viewModel.disconnectCommunication() session.viewModel.disconnectCommunication(releaseWifiAp = true)
intentionalStop = true intentionalStop = true
stopSelf() stopSelf()
return return
@ -113,7 +113,8 @@ class MeasurementForegroundService : Service() {
wakeLock?.let { if (it.isHeld) it.release() } wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null wakeLock = null
if (::session.isInitialized) { if (::session.isInitialized) {
if (!intentionalStop && session.executionState.value.active) session.viewModel.disconnectCommunication() if (!intentionalStop && !session.isClosed && session.executionState.value.active)
session.viewModel.disconnectCommunication(releaseWifiAp = true)
session.onServiceStopped() session.onServiceStopped()
} }
super.onDestroy() super.onDestroy()

16
Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt

@ -60,7 +60,13 @@ class WifiSocketManager(
return connectionGeneration.incrementAndGet() return connectionGeneration.incrementAndGet()
} }
private val parser = SlidingRingBufferParser() private val parser = SlidingRingBufferParser(onFrameRecovery = { recovery ->
val header = recovery.blockedHeader.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }
Log.w("WifiSocketManager", "TCP parser resync: session=$currentSessionId, " +
"buffered=${recovery.bufferedBytes}B, expected=${recovery.expectedBytes}B, " +
"discarded=${recovery.discardedBytes}B, idle=${recovery.idleMillis}ms, " +
"header=[$header], recovered=${DctCommand.toHexString(recovery.recoveredCommand)}")
})
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -444,6 +450,14 @@ class WifiSocketManager(
disconnectInternal() disconnectInternal()
} }
/** A late liveness timeout must never close a newer connection. */
@Synchronized
fun invalidateConnectionIfSession(sessionId: Long): Boolean {
if (sessionId == 0L || currentSessionId != sessionId || !_isConnected.value) return false
disconnectInternal()
return true
}
@Synchronized @Synchronized
private fun disconnectActiveSocketOnly() { private fun disconnectActiveSocketOnly() {
if (activeSocket != null) Log.i("WifiSocketManager", "TCP local cleanup: session=$currentSessionId", if (activeSocket != null) Log.i("WifiSocketManager", "TCP local cleanup: session=$currentSessionId",

7
Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt

@ -164,8 +164,11 @@ object DctProtocolRequests {
stream.write(moduleMask) stream.write(moduleMask)
normalized.forEach { module -> normalized.forEach { module ->
var temperatureMask = 0 var temperatureMask = 0
module.channels.take(8).forEach { channel -> // PC RequestSMCount leaves unused modules' channel bytes at zero.
if (!channel.isVoltage) temperatureMask = temperatureMask or (1 shl channel.channelIndex) if (module.isUsed) {
module.channels.take(8).forEach { channel ->
if (!channel.isVoltage) temperatureMask = temperatureMask or (1 shl channel.channelIndex)
}
} }
stream.write(temperatureMask) stream.write(temperatureMask)
} }

95
Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt

@ -14,25 +14,43 @@ import com.dct.app.domain.model.protocol.DctPacket
* 5. 페이로드 내부 0x7E / 0xA1 데이터 처리: 길이 검증을 통해 페이로드 데이터 바이트와 실제 푸터를 정확히 구분. * 5. 페이로드 내부 0x7E / 0xA1 데이터 처리: 길이 검증을 통해 페이로드 데이터 바이트와 실제 푸터를 정확히 구분.
* 6. 체크섬 오류 패킷 안전 폐기 및 다음 패킷 복구. * 6. 체크섬 오류 패킷 안전 폐기 및 다음 패킷 복구.
* 7. 링 버퍼 오버플로우 방지 및 자동 복구. * 7. 링 버퍼 오버플로우 방지 및 자동 복구.
* 8. Thread-Safe: 모든 주요 연산에 synchronized 적용. * 8. 2초 수신 공백 뒤 검증된 제어 프레임으로 미완성 선두 프레임 복구.
* 9. Thread-Safe: 모든 주요 연산에 synchronized 적용.
*/ */
class SlidingRingBufferParser( class SlidingRingBufferParser(
private val bufferCapacity: Int = DEFAULT_BUFFER_CAPACITY, private val bufferCapacity: Int = DEFAULT_BUFFER_CAPACITY,
private val maxPacketSize: Int = DEFAULT_MAX_PACKET_SIZE, private val maxPacketSize: Int = DEFAULT_MAX_PACKET_SIZE,
private val codec: DctPacketCodec = DctPacketCodec() private val codec: DctPacketCodec = DctPacketCodec(),
private val clockNanos: () -> Long = System::nanoTime,
private val onFrameRecovery: (FrameRecovery) -> Unit = {}
) : PacketParser { ) : PacketParser {
companion object { companion object {
const val DEFAULT_MAX_PACKET_SIZE: Int = DctPacketCodec.MAX_PAYLOAD_LENGTH + 6 const val DEFAULT_MAX_PACKET_SIZE: Int = DctPacketCodec.MAX_PAYLOAD_LENGTH + 6
const val DEFAULT_BUFFER_CAPACITY: Int = DEFAULT_MAX_PACKET_SIZE * 2 const val DEFAULT_BUFFER_CAPACITY: Int = DEFAULT_MAX_PACKET_SIZE * 2
const val RECOVERY_IDLE_NANOS: Long = 2_000_000_000L
} }
data class FrameRecovery(
val bufferedBytes: Int,
val expectedBytes: Int,
val discardedBytes: Int,
val idleMillis: Long,
val blockedHeader: ByteArray,
val recoveredCommand: Byte
)
private val ringBuffer = ByteArray(bufferCapacity) private val ringBuffer = ByteArray(bufferCapacity)
private val scratchFrameBuffer = ByteArray(maxPacketSize) private val scratchFrameBuffer = ByteArray(maxPacketSize)
private var head = 0 private var head = 0
private var tail = 0 private var tail = 0
private var availableBytes = 0 private var availableBytes = 0
private var sequenceCounter = 0 private var sequenceCounter = 0
private var lastInputNanos = 0L
private var hasReceivedInput = false
private var recoveryBoundary = -1
private var recoveryScanOffset = 0
private var recoveryIdleNanos = 0L
@Synchronized @Synchronized
override fun parseStream(buffer: ByteArray, length: Int): List<DctPacket> { override fun parseStream(buffer: ByteArray, length: Int): List<DctPacket> {
@ -40,6 +58,17 @@ class SlidingRingBufferParser(
if (length <= 0) return result if (length <= 0) return result
require(length <= buffer.size) { "Input length exceeds buffer size" } require(length <= buffer.size) { "Input length exceeds buffer size" }
val now = clockNanos()
if (availableBytes > 0 && hasReceivedInput && recoveryBoundary < 0 &&
now - lastInputNanos >= RECOVERY_IDLE_NANOS) {
// TCP reads are not packet boundaries. Keep the original assembly and
// only consider checksum-valid control frames arriving AFTER an idle gap.
recoveryBoundary = availableBytes
recoveryScanOffset = availableBytes
recoveryIdleNanos = now - lastInputNanos
}
lastInputNanos = now
hasReceivedInput = true
// Drain between chunks: a coalesced read larger than the ring must not erase older frames. // Drain between chunks: a coalesced read larger than the ring must not erase older frames.
var offset = 0 var offset = 0
while (offset < length) { while (offset < length) {
@ -111,7 +140,8 @@ class SlidingRingBufferParser(
// 2.4 전체 프레임 수신 여부 확인 // 2.4 전체 프레임 수신 여부 확인
if (availableBytes < expectedTotalLength) { if (availableBytes < expectedTotalLength) {
// 프레임 전체가 아직 도착하지 않았으므로 추가 데이터 수신 대기 if (recoverStalledFrame(expectedTotalLength)) continue
// No timeout-only discard: ordinary fragments remain buffered.
break break
} }
@ -146,6 +176,42 @@ class SlidingRingBufferParser(
} }
private fun recoverStalledFrame(expectedLength: Int): Boolean {
if (recoveryBoundary < 0) return false
// The wire format has no escaping/sequence marker. A control-shaped payload
// after a long gap is inherently ambiguous; prefer bounded recovery of the
// control path over retaining a stalled sample forever. Complete outer frames
// and pre-gap payload candidates are never preempted by this policy.
// Control frames are at most eight bytes. Revisit only the short suffix
// needed for fragmented ACKs, not the entire accumulated telemetry buffer.
for (offset in maxOf(1, recoveryScanOffset)..(availableBytes - DctPacketCodec.MIN_PACKET_LENGTH)) {
if (peek(offset) != DctPacketCodec.START_BYTE) continue
val packLen = ((peek(offset + 1).toInt() and 0xFF) shl 8) or
(peek(offset + 2).toInt() and 0xFF)
val command = peek(offset + 3)
val isControl = when (command) {
DctCommand.RES_POSSIBLE_TO_CONNECT.code,
DctCommand.RES_STOP_TRANSMIT_DATA.code -> packLen in 1..2
DctCommand.RES_START_TRANSMIT_DATA.code -> packLen == 3
else -> false
}
val length = packLen + DctPacketCodec.OVERHEAD_LENGTH
if (!isControl || length > minOf(maxPacketSize, bufferCapacity) || offset + length > availableBytes) continue
peekBytes(scratchFrameBuffer, 0, length, offset)
if (!codec.isValidFrame(scratchFrameBuffer, length = length)) continue
val header = ByteArray(minOf(8, recoveryBoundary))
peekBytes(header, 0, header.size)
val recovery = FrameRecovery(availableBytes, expectedLength, offset,
recoveryIdleNanos / 1_000_000L, header, command)
skipBytes(offset)
onFrameRecovery(recovery)
return true
}
recoveryScanOffset = maxOf(recoveryBoundary, availableBytes - 7)
return false
}
@Synchronized @Synchronized
override fun pollPacket(): DctPacket? = null override fun pollPacket(): DctPacket? = null
@ -155,6 +221,11 @@ class SlidingRingBufferParser(
tail = 0 tail = 0
availableBytes = 0 availableBytes = 0
sequenceCounter = 0 sequenceCounter = 0
lastInputNanos = 0L
hasReceivedInput = false
recoveryBoundary = -1
recoveryScanOffset = 0
recoveryIdleNanos = 0L
} }
val availableByteCount: Int val availableByteCount: Int
@ -196,10 +267,11 @@ class SlidingRingBufferParser(
return ringBuffer[index] return ringBuffer[index]
} }
private fun peekBytes(target: ByteArray, targetOffset: Int, count: Int) { private fun peekBytes(target: ByteArray, targetOffset: Int, count: Int, sourceOffset: Int = 0) {
check(count <= availableBytes) { "Cannot peek $count bytes, only $availableBytes available" } check(sourceOffset + count <= availableBytes) { "Cannot peek $count bytes at $sourceOffset, only $availableBytes available" }
val firstChunk = (bufferCapacity - tail).coerceAtMost(count) val start = (tail + sourceOffset) % bufferCapacity
System.arraycopy(ringBuffer, tail, target, targetOffset, firstChunk) val firstChunk = (bufferCapacity - start).coerceAtMost(count)
System.arraycopy(ringBuffer, start, target, targetOffset, firstChunk)
if (count > firstChunk) { if (count > firstChunk) {
val secondChunk = count - firstChunk val secondChunk = count - firstChunk
System.arraycopy(ringBuffer, 0, target, targetOffset + firstChunk, secondChunk) System.arraycopy(ringBuffer, 0, target, targetOffset + firstChunk, secondChunk)
@ -210,5 +282,14 @@ class SlidingRingBufferParser(
val toSkip = count.coerceAtMost(availableBytes) val toSkip = count.coerceAtMost(availableBytes)
tail = (tail + toSkip) % bufferCapacity tail = (tail + toSkip) % bufferCapacity
availableBytes -= toSkip availableBytes -= toSkip
if (recoveryBoundary >= 0) {
if (toSkip >= recoveryBoundary) {
recoveryBoundary = -1
recoveryScanOffset = 0
} else {
recoveryBoundary -= toSkip
recoveryScanOffset = (recoveryScanOffset - toSkip).coerceAtLeast(recoveryBoundary)
}
}
} }
} }

176
Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt

@ -48,7 +48,8 @@ class PacketRepositoryImpl(
private val heartbeatTimeoutMs: Long = DEFAULT_HEARTBEAT_TIMEOUT_MS, private val heartbeatTimeoutMs: Long = DEFAULT_HEARTBEAT_TIMEOUT_MS,
private val heartbeatFailureThreshold: Int = DEFAULT_HEARTBEAT_FAILURE_THRESHOLD, private val heartbeatFailureThreshold: Int = DEFAULT_HEARTBEAT_FAILURE_THRESHOLD,
private val enableHeartbeat: Boolean = true, private val enableHeartbeat: Boolean = true,
private val heartbeatDispatcher: CoroutineDispatcher = Dispatchers.IO private val heartbeatDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val monotonicTimeMillis: () -> Long = { System.nanoTime() / 1_000_000L }
) : PacketRepository { ) : PacketRepository {
private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
private val requestMutex = Mutex() private val requestMutex = Mutex()
@ -57,15 +58,21 @@ class PacketRepositoryImpl(
private val responseQuarantineSession = ConcurrentHashMap<Byte, Long>() private val responseQuarantineSession = ConcurrentHashMap<Byte, Long>()
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
private val inboundPacketGeneration = java.util.concurrent.atomic.AtomicLong(0L) private val inboundPacketGeneration = java.util.concurrent.atomic.AtomicLong(0L)
private val lastInboundMillis = java.util.concurrent.atomic.AtomicLong(0L)
/** /**
* Keeps TCP response matching ahead of slow graph/log subscribers without * Keeps TCP response matching ahead of slow graph/log subscribers without
* allowing an unbounded heap backlog. A full queue applies backpressure to * allowing an unbounded heap backlog. A full queue applies backpressure to
* the reader; already received packets are never silently discarded. * the reader; already received packets are never silently discarded.
*/ */
private val packetDispatchQueue = Channel<DctPacket>(dispatchQueueCapacity.coerceAtLeast(1)) private val packetDispatchQueue = Channel<DctPacket>(dispatchQueueCapacity.coerceAtLeast(1))
@Volatile
private var xcpIsMsbFirst = false private var xcpIsMsbFirst = false
@Volatile @Volatile
private var pendingInitialConfiguration: DctMeasurementConfiguration? = null private var pendingInitialConfiguration: DctMeasurementConfiguration? = null
/** A cancelled/timed-out START may already have reached the device. Clear only at a STOP/session boundary. */
@Volatile private var startMayHaveReachedDevice = false
/** After START is requested, response silence must not close this live TCP session, even after STOP. */
@Volatile private var playbackTransportSessionId: Long? = null
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -118,12 +125,31 @@ class PacketRepositoryImpl(
} }
override suspend fun connect(target: String): DctResult<Unit> = withContext(ioDispatcher) { override suspend fun connect(target: String): DctResult<Unit> = withContext(ioDispatcher) {
val sessionId = wifiSocketManager?.currentSessionId ?: 0L
var completed = false
try {
performHandshake().also { completed = it is DctResult.Success }
} finally {
// A failed/cancelled handshake cannot leave an apparently connected
// socket behind. Never invalidate a newer connection's session.
if (!completed && wifiSocketManager?.invalidateConnectionIfSession(sessionId) == true) {
_isStreaming.value = false
failPendingRequests("장비 초기화에 실패하여 TCP 연결을 종료했습니다.")
transition(DeviceConnectionState.ERROR)
android.util.Log.w("DctPacketRepository", "Handshake failed; TCP session closed: session=$sessionId")
}
}
}
private suspend fun performHandshake(): DctResult<Unit> = withContext(ioDispatcher) {
// Consume the snapshot once. MainViewModel supplies it immediately before // Consume the snapshot once. MainViewModel supplies it immediately before
// each Wi-Fi callback, so a failed/stale session cannot leak its settings // each Wi-Fi callback, so a failed/stale session cannot leak its settings
// into a later reconnect. // into a later reconnect.
val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null } val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null }
transition(DeviceConnectionState.CONNECTING) transition(DeviceConnectionState.CONNECTING)
_isStreaming.value = false _isStreaming.value = false
startMayHaveReachedDevice = false
playbackTransportSessionId = null
if (!hasLiveTransport()) { if (!hasLiveTransport()) {
transition(DeviceConnectionState.ERROR) transition(DeviceConnectionState.ERROR)
return@withContext failure("Wi-Fi TCP 전송 계층이 연결되지 않았습니다.") return@withContext failure("Wi-Fi TCP 전송 계층이 연결되지 않았습니다.")
@ -144,10 +170,11 @@ class PacketRepositoryImpl(
return@withContext failure("연결 초기화 0x00: ${e.message}") return@withContext failure("연결 초기화 0x00: ${e.message}")
} }
// 2. SW -> HW: 0x01 (VERSION) 요청 발송 - 0x81 응답은 파란색 박스 교환 이후 도착하므로 deferred 선등록 // 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) { if (responseQuarantineSession[DctCommand.RES_VERSION.code] == wifiSocketManager?.currentSessionId) {
transition(DeviceConnectionState.ERROR) transition(DeviceConnectionState.ERROR)
return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 연결은 유지됩니다.") return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 다시 연결하세요.")
} }
val versionDeferred = CompletableDeferred<DctPacket>() val versionDeferred = CompletableDeferred<DctPacket>()
pendingResponses[DctCommand.RES_VERSION.code] = versionDeferred pendingResponses[DctCommand.RES_VERSION.code] = versionDeferred
@ -160,6 +187,11 @@ class PacketRepositoryImpl(
return@withContext failure("버전 요청(0x01) 송신에 실패했습니다.") return@withContext failure("버전 요청(0x01) 송신에 실패했습니다.")
} }
// PC MainFrm.cpp ENABLE response handler (2026-04-13):
// VERSION -> Sleep(200) -> RequestDCTSavingIntervalTime.
// Delay after the actual write, not merely after queue insertion.
kotlinx.coroutines.delay(VERSION_ENV_GAP_MS)
// 3. 파란색 박스 인터리빙 시퀀스. 0x09/0x12는 payload 없이 // 3. 파란색 박스 인터리빙 시퀀스. 0x09/0x12는 payload 없이
// 보낼 수 있는 조회가 아니므로, 프로젝트 스냅샷이 있을 때만 // 보낼 수 있는 조회가 아니므로, 프로젝트 스냅샷이 있을 때만
// PC의 완전한 CAN 모드/SM 설정 payload를 추가합니다. // PC의 완전한 CAN 모드/SM 설정 payload를 추가합니다.
@ -184,9 +216,8 @@ class PacketRepositoryImpl(
try { try {
com.dct.app.data.protocol.DctResponseValidator.validateSetup(query, res.data) com.dct.app.data.protocol.DctResponseValidator.validateSetup(query, res.data)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// A malformed/NACK setup response must fail this handshake, but // connect() closes the failed TCP session without releasing
// it is not a transport error. Keep the TCP/Wi-Fi session alive so // the Wi-Fi AP, so a clean retry does not require pairing.
// a caller can inspect logs or retry without a second P2P pairing.
transition(DeviceConnectionState.ERROR) transition(DeviceConnectionState.ERROR)
return@withContext failure("연결 초기화 ${DctCommand.toHexString(query.command)}: ${e.message}") return@withContext failure("연결 초기화 ${DctCommand.toHexString(query.command)}: ${e.message}")
} }
@ -246,13 +277,15 @@ class PacketRepositoryImpl(
override suspend fun disconnect(): DctResult<Unit> = withContext(ioDispatcher) { override suspend fun disconnect(): DctResult<Unit> = withContext(ioDispatcher) {
stopHeartbeatLoop() stopHeartbeatLoop()
try { try {
if (_isStreaming.value) stopStreaming() if (_isStreaming.value || startMayHaveReachedDevice) stopStreaming()
if (hasLiveTransport()) sendCommand(DctProtocolRequests.disable()) if (hasLiveTransport()) sendCommand(DctProtocolRequests.disable())
} finally { } finally {
transition(DeviceConnectionState.DISCONNECTED) transition(DeviceConnectionState.DISCONNECTED)
failPendingRequests("연결이 해제되었습니다.") failPendingRequests("연결이 해제되었습니다.")
wifiSocketManager?.disconnectSuspend() wifiSocketManager?.disconnectSuspend()
_isStreaming.value = false _isStreaming.value = false
startMayHaveReachedDevice = false
playbackTransportSessionId = null
} }
DctResult.Success(Unit) DctResult.Success(Unit)
} }
@ -300,7 +333,7 @@ class PacketRepositoryImpl(
} catch (e: kotlinx.coroutines.TimeoutCancellationException) { } catch (e: kotlinx.coroutines.TimeoutCancellationException) {
responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId
android.util.Log.w("DctPacketRepository", android.util.Log.w("DctPacketRepository",
"Command response timeout; TCP session kept alive: session=${sessionCommand.transportSessionId}, command=${DctCommand.toHexString(command.command)}", e) "Command response timeout: session=${sessionCommand.transportSessionId}, command=${DctCommand.toHexString(command.command)}", e)
DctResult.Failure( DctResult.Failure(
DctError.ProtocolError.ResponseTimeout( DctError.ProtocolError.ResponseTimeout(
DctCommand.toHexString(command.command), DctCommand.toHexString(command.command),
@ -325,6 +358,7 @@ class PacketRepositoryImpl(
if (_connectionState.value != DeviceConnectionState.READY) { if (_connectionState.value != DeviceConnectionState.READY) {
return@withContext failure("장비 설정은 READY 상태에서만 적용할 수 있습니다.") return@withContext failure("장비 설정은 READY 상태에서만 적용할 수 있습니다.")
} }
val sessionId = wifiSocketManager?.currentSessionId ?: 0L
val requests = try { val requests = try {
fun packet(step: String, build: () -> DctPacket): DctPacket = try { fun packet(step: String, build: () -> DctPacket): DctPacket = try {
@ -368,29 +402,57 @@ class PacketRepositoryImpl(
add(packet("XCP 미사용 모드") { DctProtocolRequests.xcpUse(0x02) }) add(packet("XCP 미사용 모드") { DctProtocolRequests.xcpUse(0x02) })
} }
// 4. CAN mode is a complete 0x09/0x06 request, not a // PC registers CAN first, sends the DID preset after a 100ms
// subtype-only query. Reapply it here as well so a project // gap, and refreshes the environment after RES_CANDBC_INFO.
// change on an already paired device takes effect. // 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 모드") { add(packet("CAN 모드") {
DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel) DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel)
}) })
// 5. SM setup is also sent with its module/channel bitmasks. // SM setup is also sent with its module/channel bitmasks.
// An empty list intentionally emits [01 00] to clear stale // An empty list intentionally emits [01 00] to clear stale
// module selection from the previous project. // module selection from the previous project.
add(packet("센서 모듈") { add(packet("센서 모듈") {
DctProtocolRequests.sensorModuleInfo(configuration.sensorModules) DctProtocolRequests.sensorModuleInfo(configuration.sensorModules)
}) })
// 6. Send CAN DBC even when empty; [00 00 00] explicitly
// clears the previous project's DBC table on the device.
add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) })
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}") return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}")
} }
for (request in requests) { 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) val result = sendCommand(request)
if (result !is DctResult.Success || result.data == null) { 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( return@withContext if (result is DctResult.Failure) result else failure(
"장비 설정 응답 누락: ${DctCommand.toHexString(request.command)}" "장비 설정 응답 누락: ${DctCommand.toHexString(request.command)}"
) )
@ -400,6 +462,11 @@ class PacketRepositoryImpl(
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
return@withContext failure("장비 설정 ${DctCommand.toHexString(request.command)}: ${e.message}") 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) DctResult.Success(Unit)
} }
@ -416,6 +483,8 @@ class PacketRepositoryImpl(
// Wi-Fi Direct 브리지의 control writer가 밀린 실제 로그에서 0xA0이 // Wi-Fi Direct 브리지의 control writer가 밀린 실제 로그에서 0xA0이
// 약 12초 후 도착했다. 일반 설정 명령의 빠른 실패 기준은 유지하되 // 약 12초 후 도착했다. 일반 설정 명령의 빠른 실패 기준은 유지하되
// 계측 시작 ACK만 PC 통신 환경에 맞는 별도 유예 시간을 사용한다. // 계측 시작 ACK만 PC 통신 환경에 맞는 별도 유예 시간을 사용한다.
playbackTransportSessionId = sessionId
startMayHaveReachedDevice = true
val result = sendCommandWithTimeout( val result = sendCommandWithTimeout(
DctProtocolRequests.startAll(), DctProtocolRequests.startAll(),
START_STREAM_RESPONSE_TIMEOUT_MS START_STREAM_RESPONSE_TIMEOUT_MS
@ -425,20 +494,29 @@ class PacketRepositoryImpl(
com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), response) com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), response)
}.isSuccess) { }.isSuccess) {
_isStreaming.value = true _isStreaming.value = true
startMayHaveReachedDevice = false
transition(DeviceConnectionState.MEASURING) transition(DeviceConnectionState.MEASURING)
DctResult.Success(Unit) DctResult.Success(Unit)
} else if (result is DctResult.Failure) result } else if (response?.payload?.let { it.size == 2 && it[1] == 1.toByte() && it[0] != 1.toByte() } == true) {
else failure("장비가 계측 시작을 거절했습니다.") startMayHaveReachedDevice = false // An explicit NACK is not an ambiguous timeout.
failure("장비가 계측 시작을 거절했습니다.")
} else {
// Missing/malformed replies must not implicitly STOP or disconnect
// after a playback request. Do not claim measurement success either.
// Keep the uncertain-START flag so explicit Disconnect still sends STOP.
failure("계측 시작 응답을 확인할 수 없습니다. 통신 연결은 유지합니다. 필요하면 Disconnect로 정리한 뒤 다시 연결하세요.")
}
} }
override suspend fun stopStreaming(): DctResult<Unit> = withContext(ioDispatcher) { override suspend fun stopStreaming(): DctResult<Unit> = withContext(ioDispatcher) {
if (_connectionState.value != DeviceConnectionState.MEASURING) { if (_connectionState.value != DeviceConnectionState.MEASURING && !startMayHaveReachedDevice) {
return@withContext failure("현재 계측 중이 아닙니다.") return@withContext failure("현재 계측 중이 아닙니다.")
} }
val result = sendCommand(DctProtocolRequests.stop()) val result = sendCommand(DctProtocolRequests.stop())
val response = (result as? DctResult.Success)?.data val response = (result as? DctResult.Success)?.data
if (response != null && (response.payload.isEmpty() || response.payload[0] == STATUS_SUCCESS)) { if (response != null && (response.payload.isEmpty() || response.payload[0] == STATUS_SUCCESS)) {
_isStreaming.value = false _isStreaming.value = false
startMayHaveReachedDevice = false
transition(DeviceConnectionState.READY) transition(DeviceConnectionState.READY)
DctResult.Success(Unit) DctResult.Success(Unit)
} else if (result is DctResult.Failure && } else if (result is DctResult.Failure &&
@ -450,6 +528,7 @@ class PacketRepositoryImpl(
// even when the peer's ACK is late or lost, while preserving the // even when the peer's ACK is late or lost, while preserving the
// live TCP/P2P connection for the next command or retry. // live TCP/P2P connection for the next command or retry.
_isStreaming.value = false _isStreaming.value = false
startMayHaveReachedDevice = false
transition(DeviceConnectionState.READY) transition(DeviceConnectionState.READY)
android.util.Log.w( android.util.Log.w(
"DctPacketRepository", "DctPacketRepository",
@ -566,6 +645,7 @@ class PacketRepositoryImpl(
// PC는 패킷 종류와 무관하게 정상 수신이 계속되면 장비가 // PC는 패킷 종류와 무관하게 정상 수신이 계속되면 장비가
// 살아 있는 것으로 판단해 연결 감시 시간을 갱신한다. // 살아 있는 것으로 판단해 연결 감시 시간을 갱신한다.
inboundPacketGeneration.incrementAndGet() inboundPacketGeneration.incrementAndGet()
lastInboundMillis.set(monotonicTimeMillis())
// Validate and enqueue the stream ACK before publishing to // Validate and enqueue the stream ACK before publishing to
// UI/log subscribers. SharedFlow emission is intentionally // UI/log subscribers. SharedFlow emission is intentionally
// lossless and may suspend on a slow consumer; ACKs must not // lossless and may suspend on a slow consumer; ACKs must not
@ -678,36 +758,45 @@ class PacketRepositoryImpl(
} }
/** /**
* [ISSUE-R1-02] 연결 수립 시 백그라운드 코루틴으로 주기적(3초 기본)으로 * PC처럼 정상 수신 자체를 생존 확인으로 사용한다. 마지막 수신 뒤
* DctCommand.CMD_POSSIBLE_TO_CONNECT (0x05)를 발송하고 생존 여부를 확인합니다. * 3초(기본) 동안 조용할 때만 0x05를 보내 MDB/XCP ACK 흐름과의 경합을 줄인다.
*/ */
private fun startHeartbeatLoop() { private fun startHeartbeatLoop() {
if (!enableHeartbeat) return if (!enableHeartbeat) return
if (heartbeatJob?.isActive == true) return if (heartbeatJob?.isActive == true) return
// A new connection starts its own silence budget; never inherit an old session's time.
lastInboundMillis.set(monotonicTimeMillis())
heartbeatJob = scope.launch(heartbeatDispatcher) { heartbeatJob = scope.launch(heartbeatDispatcher) {
var consecutiveFailures = 0 var consecutiveFailures = 0
var waitMillis = heartbeatIntervalMs.coerceAtLeast(1L)
while (isActive && hasLiveTransport() && while (isActive && hasLiveTransport() &&
(_connectionState.value == DeviceConnectionState.READY || (_connectionState.value == DeviceConnectionState.READY ||
_connectionState.value == DeviceConnectionState.MEASURING) _connectionState.value == DeviceConnectionState.MEASURING)
) { ) {
kotlinx.coroutines.delay(heartbeatIntervalMs) kotlinx.coroutines.delay(waitMillis)
if (!isActive || !hasLiveTransport() || if (!isActive || !hasLiveTransport() ||
(_connectionState.value != DeviceConnectionState.READY && (_connectionState.value != DeviceConnectionState.READY &&
_connectionState.value != DeviceConnectionState.MEASURING) _connectionState.value != DeviceConnectionState.MEASURING)
) break ) break
waitMillis = remainingHeartbeatSilenceMillis()
if (waitMillis > 0L) {
consecutiveFailures = 0
continue
}
val sessionId = wifiSocketManager?.currentSessionId ?: break
val success = performHeartbeat() val success = performHeartbeat()
waitMillis = heartbeatIntervalMs.coerceAtLeast(1L)
consecutiveFailures = if (success) 0 else consecutiveFailures + 1 consecutiveFailures = if (success) 0 else consecutiveFailures + 1
if (!success && consecutiveFailures >= heartbeatFailureThreshold.coerceAtLeast(1) && if (!success && consecutiveFailures >= heartbeatFailureThreshold.coerceAtLeast(1) &&
isActive && hasLiveTransport() && isActive && hasLiveTransport() &&
(_connectionState.value == DeviceConnectionState.READY || (_connectionState.value == DeviceConnectionState.READY ||
_connectionState.value == DeviceConnectionState.MEASURING) _connectionState.value == DeviceConnectionState.MEASURING)
) { ) {
// 0x05/0x85는 보조 생존 진단이다. 장비/테스트 프로그램이 // Before playback retain the connection watchdog. After a
// 제어 응답이나 대용량 스트림을 늦게 보내는 동안 Heartbeat만 // START request, silence is diagnostic only until actual
// 누락될 수 있으므로 이것만으로 정상 TCP를 닫지 않는다. // transport loss or explicit Disconnect, including after STOP.
// 실제 단선은 TCP EOF/I/O 오류 또는 P2P 그룹 해제에서 확정한다. if (closeUnresponsiveSession(sessionId, consecutiveFailures)) break
reportHeartbeatTimeout(consecutiveFailures)
consecutiveFailures = 0 consecutiveFailures = 0
} }
} }
@ -719,6 +808,11 @@ class PacketRepositoryImpl(
heartbeatJob = null heartbeatJob = null
} }
private fun remainingHeartbeatSilenceMillis(): Long {
val elapsed = (monotonicTimeMillis() - lastInboundMillis.get()).coerceAtLeast(0L)
return (heartbeatIntervalMs.coerceAtLeast(1L) - elapsed).coerceAtLeast(0L)
}
/** /**
* 계측 중(isMeasuring)일 때의 데이터 스트림 및 다른 동기 제어 명령과 충돌하지 않도록 * 계측 중(isMeasuring)일 때의 데이터 스트림 및 다른 동기 제어 명령과 충돌하지 않도록
* 안전하게 Mutex를 조율하며 0x05 발송 및 0x85 응답을 대기합니다. * 안전하게 Mutex를 조율하며 0x05 발송 및 0x85 응답을 대기합니다.
@ -727,7 +821,6 @@ class PacketRepositoryImpl(
if (!hasLiveTransport()) return false if (!hasLiveTransport()) return false
val sessionId = wifiSocketManager?.currentSessionId ?: return false val sessionId = wifiSocketManager?.currentSessionId ?: return false
val expected = DctCommand.RES_POSSIBLE_TO_CONNECT.code val expected = DctCommand.RES_POSSIBLE_TO_CONNECT.code
val inboundGenerationBeforeProbe = inboundPacketGeneration.get()
// 다른 제어 명령(UI 파라미터 변경 등)이 진행 중이면 이미 통신이 활성화된 상태이므로 경합을 피함 // 다른 제어 명령(UI 파라미터 변경 등)이 진행 중이면 이미 통신이 활성화된 상태이므로 경합을 피함
val locked = requestMutex.tryLock() val locked = requestMutex.tryLock()
@ -735,10 +828,14 @@ class PacketRepositoryImpl(
return true return true
} }
val inboundGenerationBeforeProbe = inboundPacketGeneration.get()
val deferred = CompletableDeferred<DctPacket>() val deferred = CompletableDeferred<DctPacket>()
pendingResponses[expected] = deferred
val packet = DctProtocolRequests.possibleToConnect().copy(transportSessionId = sessionId) val packet = DctProtocolRequests.possibleToConnect().copy(transportSessionId = sessionId)
try { try {
// Receive/ACK handling can run between the timer check and acquiring this mutex.
// Recheck immediately before registering/sending a probe; do not delay stream ACKs.
if (remainingHeartbeatSilenceMillis() > 0L) return true
pendingResponses[expected] = deferred
if (!sendLive(packet)) { if (!sendLive(packet)) {
pendingResponses.remove(expected, deferred) pendingResponses.remove(expected, deferred)
return false return false
@ -769,13 +866,24 @@ class PacketRepositoryImpl(
} }
} }
/** Heartbeat 누락은 진단만 남기며 활성 TCP 세션의 상태를 변경하지 않는다. */ private fun closeUnresponsiveSession(sessionId: Long, consecutiveFailures: Int): Boolean {
private fun reportHeartbeatTimeout(consecutiveFailures: Int) { if (playbackTransportSessionId == sessionId) {
android.util.Log.w("DctPacketRepository",
"재생 요청 이후 수신 지연: session=$sessionId, 정상 패킷/Heartbeat 응답이 없어도 TCP 연결을 유지합니다.")
return false
}
// Close before transition(): ERROR cancels this heartbeat job. The
// session check and socket invalidation are atomic inside the manager.
if (wifiSocketManager?.invalidateConnectionIfSession(sessionId) != true) return false
android.util.Log.w( android.util.Log.w(
"DctPacketRepository", "DctPacketRepository",
"Alive Check 경고: 하트비트와 정상 데이터 수신이 연속 ${consecutiveFailures}회 없었지만 " + "장비 무응답: session=$sessionId, 하트비트와 정상 데이터 수신이 " +
"TCP 연결은 유지합니다. 실제 EOF/I/O 오류 또는 Wi-Fi Direct 그룹 해제를 기다립니다." "${heartbeatTimeoutMs}ms 동안 없음 (${consecutiveFailures}회). PC와 동일하게 TCP 연결을 종료합니다."
) )
_isStreaming.value = false
failPendingRequests("장비 응답이 없어 TCP 연결을 종료했습니다.")
transition(DeviceConnectionState.ERROR)
return true
} }
private fun failPendingRequests(reason: String) { private fun failPendingRequests(reason: String) {
@ -817,10 +925,12 @@ class PacketRepositoryImpl(
// payload copies in memory. // payload copies in memory.
const val DEFAULT_DISPATCH_QUEUE_CAPACITY = 512 const val DEFAULT_DISPATCH_QUEUE_CAPACITY = 512
private const val DEFAULT_RESPONSE_TIMEOUT_MS = 3_000L private const val DEFAULT_RESPONSE_TIMEOUT_MS = 3_000L
private const val VERSION_ENV_GAP_MS = 200L
private const val DID_ENV_GAP_MS = 20L
private const val START_STREAM_RESPONSE_TIMEOUT_MS = 15_000L private const val START_STREAM_RESPONSE_TIMEOUT_MS = 15_000L
const val DEFAULT_HEARTBEAT_INTERVAL_MS = 3_000L const val DEFAULT_HEARTBEAT_INTERVAL_MS = 3_000L
const val DEFAULT_HEARTBEAT_TIMEOUT_MS = 5_000L const val DEFAULT_HEARTBEAT_TIMEOUT_MS = 5_000L
const val DEFAULT_HEARTBEAT_FAILURE_THRESHOLD = 2 const val DEFAULT_HEARTBEAT_FAILURE_THRESHOLD = 1
private const val RESTART_STOP_BOUNDARY_TIMEOUT_MS = 12_000L private const val RESTART_STOP_BOUNDARY_TIMEOUT_MS = 12_000L
private const val STOP_BOUNDARY_POLL_MS = 25L private const val STOP_BOUNDARY_POLL_MS = 25L
private const val STATUS_SUCCESS: Byte = 0x01 private const val STATUS_SUCCESS: Byte = 0x01

3
Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt

@ -16,7 +16,8 @@ data class DctMeasurementConfiguration(
val canMessages: List<DctCanMessageConfiguration> = emptyList(), val canMessages: List<DctCanMessageConfiguration> = emptyList(),
val sensorModules: List<DpcSensorModuleConfig> = emptyList(), val sensorModules: List<DpcSensorModuleConfig> = emptyList(),
val isHighSpeedCan: Boolean = true, val isHighSpeedCan: Boolean = true,
val isOldModel: Boolean = false val isOldModel: Boolean = false,
val didPreSetting: Int = 0
) )
data class DctCanMessageConfiguration( data class DctCanMessageConfiguration(

49
Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt

@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -39,6 +40,7 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
val state by viewModel.wifiApState.collectAsStateWithLifecycle() val state by viewModel.wifiApState.collectAsStateWithLifecycle()
val ui by viewModel.uiState.collectAsStateWithLifecycle() val ui by viewModel.uiState.collectAsStateWithLifecycle()
val socket by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle() val socket by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle()
val protocolState by viewModel.packetRepository.connectionState.collectAsStateWithLifecycle()
val endpoint by viewModel.wifiApEndpoint.collectAsStateWithLifecycle() val endpoint by viewModel.wifiApEndpoint.collectAsStateWithLifecycle()
val connectedEndpoint by viewModel.wifiSocketManager.connectedEndpoint.collectAsStateWithLifecycle() val connectedEndpoint by viewModel.wifiSocketManager.connectedEndpoint.collectAsStateWithLifecycle()
var requested by remember { mutableStateOf(false) } var requested by remember { mutableStateOf(false) }
@ -72,11 +74,13 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket, WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket,
onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp, onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp,
onCancel = viewModel::cancelWifiApConnection, onDisconnect = { viewModel.disconnectCommunication() }, onCancel = viewModel::cancelWifiApConnection,
onRetry = viewModel::retryWifiApTcp, onCurrent = viewModel::connectCurrentWifiAp, onDisconnect = { viewModel.disconnectCommunication(releaseWifiAp = true) },
onCurrent = viewModel::connectCurrentWifiAp,
onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss, onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss,
endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint, endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint,
canEditEndpoint = !ui.isMeasuring && ui.recordingState !in setOf( 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.STARTING,
com.dct.app.presentation.main.MeasurementRecordingState.RECORDING, com.dct.app.presentation.main.MeasurementRecordingState.RECORDING,
com.dct.app.presentation.main.MeasurementRecordingState.STOPPING) && !state.busy) com.dct.app.presentation.main.MeasurementRecordingState.STOPPING) && !state.busy)
@ -87,10 +91,11 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
internal fun WifiApConnectionContent( internal fun WifiApConnectionContent(
state: WifiApUiState, communicationReady: Boolean, socketConnected: Boolean, state: WifiApUiState, communicationReady: Boolean, socketConnected: Boolean,
onScan: () -> Unit, onConnect: (WifiApDevice, String) -> Unit, onCancel: () -> Unit, onScan: () -> Unit, onConnect: (WifiApDevice, String) -> Unit, onCancel: () -> Unit,
onDisconnect: () -> Unit, onRetry: () -> Unit, onCurrent: () -> Unit, onDisconnect: () -> Unit, onCurrent: () -> Unit,
onPermissions: () -> Unit, onSettings: (String) -> Unit, onDismiss: () -> Unit, onPermissions: () -> Unit, onSettings: (String) -> Unit, onDismiss: () -> Unit,
endpoint: WifiApEndpoint = WifiApEndpoint(), canEditEndpoint: Boolean = true, endpoint: WifiApEndpoint = WifiApEndpoint(), canEditEndpoint: Boolean = true,
onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null,
communicationFailed: Boolean = false
) { ) {
var selected by remember { mutableStateOf<WifiApDevice?>(null) } var selected by remember { mutableStateOf<WifiApDevice?>(null) }
var addingNetwork by remember { mutableStateOf(false) } var addingNetwork by remember { mutableStateOf(false) }
@ -98,14 +103,19 @@ internal fun WifiApConnectionContent(
var showDetails by remember { mutableStateOf(false) } var showDetails by remember { mutableStateOf(false) }
var connectedDetailsKey by remember { mutableStateOf<String?>(null) } var connectedDetailsKey by remember { mutableStateOf<String?>(null) }
var editEndpoint by remember { mutableStateOf(false) } var editEndpoint by remember { mutableStateOf(false) }
val networkListState = rememberLazyListState()
val connectedNetworkKeys = state.connectedNetworks.map { it.key }.toSet()
LaunchedEffect(state.networkReady, state.activeNetworkKey, connectedNetworkKeys) {
// Stable item keys otherwise keep the old visible AP anchored when a
// newly connected row moves to the top. Scan/RSSI updates must not jump.
if (state.networkReady || connectedNetworkKeys.isNotEmpty()) networkListState.scrollToItem(0)
}
val endpointEditable = canEditEndpoint && !state.busy val endpointEditable = canEditEndpoint && !state.busy
// This shortcut uses the existing single-IPv4 Wi-Fi path, never an arbitrary second network.
val canConnectCurrent = endpointEditable && endpoint.isValid && state.enabled && state.permissionGranted &&
!communicationReady && !socketConnected && state.connectedNetworks.count { it.localAddress != null } == 1
val connectedStatus = when { val connectedStatus = when {
communicationReady -> "연결됨" communicationReady -> "연결됨"
communicationFailed -> "통신 연결 실패"
socketConnected -> "장비 초기화 중…" socketConnected -> "장비 초기화 중…"
else -> "통신 연결 대기" else -> "Wi-Fi 연결됨"
} }
val configuredAddress = "접속 설정: ${endpoint.host}:${endpoint.port}" val configuredAddress = "접속 설정: ${endpoint.host}:${endpoint.port}"
val currentAddress = if (!state.busy && (socketConnected || communicationReady)) connectedEndpoint?.let { val currentAddress = if (!state.busy && (socketConnected || communicationReady)) connectedEndpoint?.let {
@ -171,7 +181,7 @@ internal fun WifiApConnectionContent(
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
} }
LazyColumn(Modifier.fillMaxWidth().weight(1f, fill = false).heightIn(max = 320.dp) LazyColumn(Modifier.fillMaxWidth().weight(1f, fill = false).heightIn(max = 320.dp)
.testTag("wifi:ap-list")) { .testTag("wifi:ap-list"), state = networkListState) {
if (state.networkReady || state.busy) item(key = "current") { if (state.networkReady || state.busy) item(key = "current") {
WifiApNetworkRow(state.selectedSsid ?: "DCT Wi-Fi", WifiApNetworkRow(state.selectedSsid ?: "DCT Wi-Fi",
status = if (state.busy) "연결 중…" else connectedStatus, status = if (state.busy) "연결 중…" else connectedStatus,
@ -211,8 +221,6 @@ internal fun WifiApConnectionContent(
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)) { horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)) {
if (state.busy) TextButton(onClick = onCancel) { Text("연결 취소") } if (state.busy) TextButton(onClick = onCancel) { Text("연결 취소") }
Button(enabled = canConnectCurrent, onClick = onCurrent,
modifier = Modifier.testTag("wifi:connected-connect")) { Text("통신 연결") }
TextButton(onClick = onDismiss) { Text("닫기") } TextButton(onClick = onDismiss) { Text("닫기") }
} }
} }
@ -314,9 +322,6 @@ internal fun WifiApConnectionContent(
TextButton(enabled = endpointEditable, onClick = { TextButton(enabled = endpointEditable, onClick = {
showDetails = false; editEndpoint = true showDetails = false; editEndpoint = true
}) { Text("IP / 포트 설정") } }) { Text("IP / 포트 설정") }
if (state.networkReady && !communicationReady) TextButton(onClick = {
showDetails = false; onRetry()
}) { Text("통신 재연결") }
TextButton(onClick = { showDetails = false }) { Text("확인") } TextButton(onClick = { showDetails = false }) { Text("확인") }
} }
}) })
@ -326,23 +331,20 @@ internal fun WifiApConnectionContent(
var port by remember { mutableStateOf(endpoint.port.toString()) } var port by remember { mutableStateOf(endpoint.port.toString()) }
var saveError by remember { mutableStateOf(false) } var saveError by remember { mutableStateOf(false) }
val target = WifiApEndpoint.fromInput(WifiConnectionInput(ip, port)) val target = WifiApEndpoint.fromInput(WifiConnectionInput(ip, port))
fun applyTarget(connectNow: Boolean) { fun applyTarget() {
if (target == null || !endpointEditable || !onEndpointChange(target)) { if (target == null || !endpointEditable || !onEndpointChange(target)) {
saveError = true saveError = true
return return
} }
editEndpoint = false editEndpoint = false
if (connectNow) {
if (state.networkReady) onRetry() else onCurrent()
}
} }
AlertDialog(onDismissRequest = { editEndpoint = false }, AlertDialog(onDismissRequest = { editEndpoint = false },
title = { Text("IP / 포트 설정") }, title = { Text("IP / 포트 설정") },
text = { text = {
Column(Modifier.verticalScroll(rememberScrollState()), Column(Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("PC와 같은 Wi-Fi에서 PC의 IPv4 주소를 입력하세요. 테스트 서버 기본 포트는 6000입니다.", Text("장치와 같은 Wi-Fi에 연결한 뒤 장치의 IPv4 주소와 포트를 입력하세요. 기본값은 ${DctWifiAp.HOST}:${DctWifiAp.PORT}입니다.",
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("wifi:tcp-guide"))
OutlinedTextField(ip, onValueChange = { ip = it; saveError = false }, OutlinedTextField(ip, onValueChange = { ip = it; saveError = false },
label = { Text("IP 주소") }, singleLine = true, label = { Text("IP 주소") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
@ -364,11 +366,8 @@ internal fun WifiApConnectionContent(
dismissButton = { TextButton(onClick = { editEndpoint = false }) { Text("취소") } }, dismissButton = { TextButton(onClick = { editEndpoint = false }) { Text("취소") } },
confirmButton = { confirmButton = {
Row { Row {
TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget(false) }, TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget() },
modifier = Modifier.testTag("wifi:tcp-save")) { Text("저장") } modifier = Modifier.testTag("wifi:tcp-save")) { Text("저장") }
TextButton(enabled = target != null && endpointEditable && state.enabled &&
state.permissionGranted && !state.busy, onClick = { applyTarget(true) },
modifier = Modifier.testTag("wifi:tcp-connect")) { Text("저장 후 연결") }
} }
}) })
} }

35
Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt

@ -33,8 +33,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.lifecycleScope
import com.dct.app.DctApplication
import com.dct.app.background.DctSessionRuntime
import com.dct.app.core.theme.DctTheme import com.dct.app.core.theme.DctTheme
import com.dct.app.core.theme.DctThemeTokens import com.dct.app.core.theme.DctThemeTokens
import kotlinx.coroutines.launch
private const val PERMISSION_EXPLANATION_PREFS = "dct_permission_explanation" private const val PERMISSION_EXPLANATION_PREFS = "dct_permission_explanation"
private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown" private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown"
@ -43,8 +47,13 @@ private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown"
* DCT 안드로이드 태블릿 메인 액티비티 * DCT 안드로이드 태블릿 메인 액티비티
*/ */
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var activitySession: DctSessionRuntime
var programExitRequested by mutableStateOf(false)
private set
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
activitySession = (application as DctApplication).session
// 처음 실행할 때 시스템 권한 화면으로 바로 이동하지 않고, 먼저 // 처음 실행할 때 시스템 권한 화면으로 바로 이동하지 않고, 먼저
// 권한의 용도와 제한 사항을 설명한다. 실제 요청은 사용자가 // 권한의 용도와 제한 사항을 설명한다. 실제 요청은 사용자가
@ -90,6 +99,9 @@ class MainActivity : ComponentActivity() {
BackgroundExecutionPermissionDialogs(backgroundRunning, showStoragePermissionExplanation) BackgroundExecutionPermissionDialogs(backgroundRunning, showStoragePermissionExplanation)
} }
} }
// A configuration change must not strand the exit dialog or abandon cleanup.
// Keep this request only in the live session, never in process-death saved state.
if (activitySession.programExitRequested) exitProgram()
} }
override fun onResume() { override fun onResume() {
@ -102,14 +114,33 @@ class MainActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
(application as com.dct.app.DctApplication).session.onUiStarted() activitySession.onUiStarted()
} }
override fun onStop() { override fun onStop() {
(application as com.dct.app.DctApplication).session.onUiStopped() activitySession.onUiStopped()
super.onStop() super.onStop()
} }
fun exitProgram() {
if (programExitRequested) return
programExitRequested = true
activitySession.requestProgramExit()
lifecycleScope.launch {
activitySession.viewModel.autoSaveOnExit(applicationContext)
// Do not clear the shared ViewModel while STOP/recording finalization is pending.
activitySession.viewModel.disconnectCommunication(applicationContext, releaseWifiAp = true)?.join()
finishAndRemoveTask()
}
}
override fun onDestroy() {
super.onDestroy()
if (isFinishing && programExitRequested) {
(application as DctApplication).releaseSession(activitySession)
}
}
/** 저장소 권한이 없어 최초 안내가 필요한지 확인한다. */ /** 저장소 권한이 없어 최초 안내가 필요한지 확인한다. */
private fun storagePermissionRequired(): Boolean { private fun storagePermissionRequired(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {

202
Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt

@ -33,12 +33,15 @@ import com.dct.app.presentation.signaltree.AddToChartRequest
import com.dct.app.presentation.signaltree.SignalTreeViewModel import com.dct.app.presentation.signaltree.SignalTreeViewModel
import com.dct.app.presentation.signaltree.TreeItemSelection import com.dct.app.presentation.signaltree.TreeItemSelection
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -94,6 +97,7 @@ data class MainUiState(
val dashboardTiles: List<TileWidgetItem> = emptyList(), val dashboardTiles: List<TileWidgetItem> = emptyList(),
val currentSelection: TreeItemSelection? = null, val currentSelection: TreeItemSelection? = null,
val isConnected: Boolean = false, val isConnected: Boolean = false,
val isCommunicationConnecting: Boolean = false,
val communicationType: CommunicationType = CommunicationType.NONE, val communicationType: CommunicationType = CommunicationType.NONE,
val connectedDeviceName: String? = null, val connectedDeviceName: String? = null,
val statusMessage: String? = null, val statusMessage: String? = null,
@ -125,6 +129,20 @@ class MainViewModel(
private val calibrationMutex = kotlinx.coroutines.sync.Mutex() private val calibrationMutex = kotlinx.coroutines.sync.Mutex()
private var calibrationJob: Job? = null private var calibrationJob: Job? = null
private val sessionMutex = kotlinx.coroutines.sync.Mutex() private val sessionMutex = kotlinx.coroutines.sync.Mutex()
private val sessionControlJobs = java.util.concurrent.ConcurrentHashMap.newKeySet<Job>()
/** Register before starting so Disconnect can cancel even a queued command. */
private fun launchSessionControl(block: suspend () -> Unit): Job {
val job = scope.launch(dispatchers.io, start = CoroutineStart.LAZY) { block() }
sessionControlJobs.add(job)
job.invokeOnCompletion { sessionControlJobs.remove(job) }
job.start()
return job
}
private fun cancelSessionControls() {
sessionControlJobs.toList().forEach { it.cancel() }
}
private val recordingMutex = kotlinx.coroutines.sync.Mutex() private val recordingMutex = kotlinx.coroutines.sync.Mutex()
// A whole CSV/MF4 sample and start/close are indivisible, including STOP ACK/disconnect paths. // A whole CSV/MF4 sample and start/close are indivisible, including STOP ACK/disconnect paths.
private val recordingSampleLock = Any() private val recordingSampleLock = Any()
@ -134,8 +152,10 @@ class MainViewModel(
@Volatile private var configurationRequired = false @Volatile private var configurationRequired = false
private val chartTelemetryResolver = ChartTelemetryResolver() private val chartTelemetryResolver = ChartTelemetryResolver()
private val formulaTelemetryLock = Any() private val formulaTelemetryLock = Any()
private val telemetryResetLock = Any()
private val formulaTelemetryEngine = com.dct.app.core.formula.FormulaTelemetryEngine() private val formulaTelemetryEngine = com.dct.app.core.formula.FormulaTelemetryEngine()
private var configuredMdbSelection: List<Pair<Int, Int>>? = null private var configuredMdbSelection: List<Pair<Int, Int>>? = null
private var configuredCanMessages: List<com.dct.app.domain.model.protocol.DctCanMessageConfiguration> = emptyList()
private val _isCalibrationBusy = MutableStateFlow(false) private val _isCalibrationBusy = MutableStateFlow(false)
val isCalibrationBusy: StateFlow<Boolean> = _isCalibrationBusy.asStateFlow() val isCalibrationBusy: StateFlow<Boolean> = _isCalibrationBusy.asStateFlow()
@ -197,7 +217,7 @@ class MainViewModel(
try { try {
val nowSec = packet.receivedAtSec.takeIf { it.isFinite() } val nowSec = packet.receivedAtSec.takeIf { it.isFinite() }
?: (packet.receivedAtMillis.takeIf { it > 0 } ?: System.currentTimeMillis()) / 1000.0 ?: (packet.receivedAtMillis.takeIf { it > 0 } ?: System.currentTimeMillis()) / 1000.0
processIncomingPacket(packet, nowSec) synchronized(telemetryResetLock) { processIncomingPacket(packet, nowSec) }
} catch (e: kotlinx.coroutines.CancellationException) { } catch (e: kotlinx.coroutines.CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@ -237,7 +257,10 @@ class MainViewModel(
communicationType = if (isDisconnectedOrError) CommunicationType.NONE else it.communicationType, communicationType = if (isDisconnectedOrError) CommunicationType.NONE else it.communicationType,
connectedDeviceName = if (isDisconnectedOrError) null else it.connectedDeviceName, connectedDeviceName = if (isDisconnectedOrError) null else it.connectedDeviceName,
statusMessage = when (state) { statusMessage = when (state) {
DeviceConnectionState.ERROR -> "Wi-Fi 장비 연결이 종료되었습니다." // ERROR can be collected after the configuration coroutine
// publishes its cause. Do not replace that recovery guidance.
DeviceConnectionState.ERROR -> if (it.statusMessage == CONFIGURATION_TIMEOUT_MESSAGE)
it.statusMessage else "Wi-Fi 장비 연결이 종료되었습니다."
DeviceConnectionState.DISCONNECTED -> if (it.communicationType == CommunicationType.NONE) it.statusMessage else "장비 연결이 해제되었습니다." DeviceConnectionState.DISCONNECTED -> if (it.communicationType == CommunicationType.NONE) it.statusMessage else "장비 연결이 해제되었습니다."
else -> it.statusMessage else -> it.statusMessage
} }
@ -733,6 +756,27 @@ class MainViewModel(
renderers.forEach { it.clearSamplesOnly() } renderers.forEach { it.clearSamplesOnly() }
} }
/** Debug-only display reset; never changes device settings, recording or project bindings. */
fun resetDebugValues() {
if (!com.dct.app.BuildConfig.DEBUG) return
// A packet must finish as a whole before its values and graph samples are cleared.
synchronized(telemetryResetLock) {
clearFormulaTelemetry()
synchronized(recordingSampleLock) { latestMf4RawValues.clear() }
resetGraphData()
_uiState.update { current ->
current.copy(
packetCount = 0L,
dashboardTiles = current.dashboardTiles.map { tile ->
tile.historyBuffer.clear()
tile.copy(value = 0.0)
},
statusMessage = "값을 초기화했습니다. 계측 중에는 다음 수신부터 다시 표시됩니다."
)
}
}
}
/** /**
* 기본 Signal list MDI 서브윈도우 인스턴스 생성 * 기본 Signal list MDI 서브윈도우 인스턴스 생성
*/ */
@ -1034,6 +1078,8 @@ class MainViewModel(
companion object { companion object {
const val MAX_WORKSPACE_PAGES = 5 const val MAX_WORKSPACE_PAGES = 5
private const val LOG_TAG = "DctMainViewModel" private const val LOG_TAG = "DctMainViewModel"
private const val CONFIGURATION_TIMEOUT_MESSAGE =
"장비 설정 응답 시간 초과로 통신 연결이 해제되었습니다. Connect로 다시 연결하세요."
} }
/** /**
@ -1590,9 +1636,11 @@ class MainViewModel(
if (!ensureBackgroundExecution()) return if (!ensureBackgroundExecution()) return
pendingWifiTarget.set(target) pendingWifiTarget.set(target)
val generation = connectionGeneration.incrementAndGet() val generation = connectionGeneration.incrementAndGet()
cancelSessionControls()
_uiState.update { it.copy(isCommunicationConnecting = true) }
configurationRequired = true configurationRequired = true
val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트" val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트"
scope.launch(dispatchers.io) { launchSessionControl {
sessionMutex.withLock { sessionMutex.withLock {
if (generation != connectionGeneration.get()) return@withLock if (generation != connectionGeneration.get()) return@withLock
if (_uiState.value.isMeasuring || packetRepository.isConnected.value) { if (_uiState.value.isMeasuring || packetRepository.isConnected.value) {
@ -1609,7 +1657,7 @@ class MainViewModel(
wifiSocketManager.startDualMode( wifiSocketManager.startDualMode(
isGroupOwner = isGroupOwner, hostIp = hostIp, port = port, isGroupOwner = isGroupOwner, hostIp = hostIp, port = port,
onConnected = { _, remoteAddress -> onConnected = { _, remoteAddress ->
scope.launch(dispatchers.io) { launchSessionControl {
sessionMutex.withLock { sessionMutex.withLock {
if (generation != connectionGeneration.get()) return@withLock if (generation != connectionGeneration.get()) return@withLock
connectedWifiTarget.set(target) connectedWifiTarget.set(target)
@ -1635,7 +1683,10 @@ class MainViewModel(
DctResult.Loading -> Unit DctResult.Loading -> Unit
} }
} finally { } finally {
if (generation == connectionGeneration.get()) pendingWifiTarget.compareAndSet(target, null) if (generation == connectionGeneration.get()) {
pendingWifiTarget.compareAndSet(target, null)
_uiState.update { it.copy(isCommunicationConnecting = false) }
}
} }
} }
} }
@ -1650,7 +1701,7 @@ class MainViewModel(
onFailure = { error -> onFailure = { error ->
if (generation == connectionGeneration.get()) { if (generation == connectionGeneration.get()) {
pendingWifiTarget.compareAndSet(target, null) pendingWifiTarget.compareAndSet(target, null)
_uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, _uiState.update { it.copy(isConnected = false, isCommunicationConnecting = false, communicationType = CommunicationType.NONE,
connectedDeviceName = null, statusMessage = "Wi-Fi 연결 실패: $error") } connectedDeviceName = null, statusMessage = "Wi-Fi 연결 실패: $error") }
} }
} }
@ -1666,10 +1717,39 @@ class MainViewModel(
val wifiApState = _wifiApState.asStateFlow() val wifiApState = _wifiApState.asStateFlow()
private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint()) private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint())
val wifiApEndpoint = _wifiApEndpoint.asStateFlow() val wifiApEndpoint = _wifiApEndpoint.asStateFlow()
val canConnectCommunication = combine(
_wifiApState, _wifiApEndpoint, _uiState, wifiSocketManager.isConnected
) { ap, endpoint, ui, socket -> canStartWifiCommunication(ap, endpoint, ui, socket) }
.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false)
private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null
private fun canStartWifiCommunication(
ap: com.dct.app.data.communication.WifiApUiState,
endpoint: com.dct.app.data.communication.WifiApEndpoint,
ui: MainUiState,
socket: Boolean
): Boolean = ap.enabled && ap.permissionGranted && !ap.busy && endpoint.isValid &&
(ap.networkReady || ap.connectedNetworks.count { it.localAddress != null } == 1) &&
!ui.isConnected && !ui.isCommunicationConnecting && !socket && !ui.isMeasuring &&
ui.recordingState !in setOf(MeasurementRecordingState.STARTING,
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING)
/** Only the ribbon Connect action may start TCP and the DCT handshake. */
fun connectCommunication() {
val manager = registeredWifiApManager ?: return
if (!canStartWifiCommunication(manager.uiState.value, _wifiApEndpoint.value,
_uiState.value, wifiSocketManager.isConnected.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()
val ap = manager.uiState.value
if (!ap.networkReady) return
val endpoint = _wifiApEndpoint.value
connectWifi(ap.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port)
}
fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean { fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean {
if (!endpoint.isValid || _uiState.value.isMeasuring || if (!endpoint.isValid || _uiState.value.isCommunicationConnecting || _uiState.value.isMeasuring ||
_uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING, _uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING,
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) || MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) ||
registeredWifiApManager?.uiState?.value?.busy == true) return false registeredWifiApManager?.uiState?.value?.busy == true) return false
@ -1692,7 +1772,7 @@ class MainViewModel(
.onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) } .onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) }
} }
val manager = com.dct.app.data.communication.WifiApManager(context.applicationContext, val manager = com.dct.app.data.communication.WifiApManager(context.applicationContext,
onReady = { name -> val endpoint = _wifiApEndpoint.value; connectWifi(name, endpoint.host, endpoint.port) }, onReady = { /* Wi-Fi/IP readiness alone must not start TCP or the DCT protocol. */ },
onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") }, onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") },
onConnecting = { closeWifiTransportAfterGroupLoss(null) }, onConnecting = { closeWifiTransportAfterGroupLoss(null) },
endpointProvider = { _wifiApEndpoint.value }) endpointProvider = { _wifiApEndpoint.value })
@ -1706,21 +1786,16 @@ class MainViewModel(
fun refreshWifiAp() { registeredWifiApManager?.refresh() } fun refreshWifiAp() { registeredWifiApManager?.refresh() }
fun scanWifiAp() { registeredWifiApManager?.scan() } fun scanWifiAp() { registeredWifiApManager?.scan() }
fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) { fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) {
if (registeredWifiApManager?.uiState?.value?.canScan == true && ensureBackgroundExecution()) if (registeredWifiApManager?.uiState?.value?.canScan == true)
registeredWifiApManager?.connect(device, password) registeredWifiApManager?.connect(device, password)
} }
fun connectCurrentWifiAp() { fun connectCurrentWifiAp() {
if (ensureBackgroundExecution()) registeredWifiApManager?.connectCurrent() registeredWifiApManager?.connectCurrent()
} }
fun cancelWifiApConnection() { fun cancelWifiApConnection() {
registeredWifiApManager?.disconnect() registeredWifiApManager?.disconnect()
closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.") closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.")
} }
fun retryWifiApTcp() {
val endpoint = _wifiApEndpoint.value
if (registeredWifiApManager?.uiState?.value?.networkReady == true)
connectWifi(wifiApState.value.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port)
}
private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState()) private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState())
val wifiDirectState: StateFlow<com.dct.app.data.communication.WifiDirectUiState> = _wifiDirectState.asStateFlow() val wifiDirectState: StateFlow<com.dct.app.data.communication.WifiDirectUiState> = _wifiDirectState.asStateFlow()
@ -1781,9 +1856,11 @@ class MainViewModel(
private fun closeWifiTransportAfterGroupLoss(message: String? = "Wi-Fi Direct 연결이 해제되었습니다.") { private fun closeWifiTransportAfterGroupLoss(message: String? = "Wi-Fi Direct 연결이 해제되었습니다.") {
val generation = connectionGeneration.incrementAndGet() val generation = connectionGeneration.incrementAndGet()
cancelSessionControls()
pendingWifiTarget.set(null) pendingWifiTarget.set(null)
connectedWifiTarget.set(null) connectedWifiTarget.set(null)
configurationRequired = true configurationRequired = true
_uiState.update { it.copy(isCommunicationConnecting = false) }
// Invalidate immediately: queued cleanup must never close a newly formed group's socket. // Invalidate immediately: queued cleanup must never close a newly formed group's socket.
wifiSocketManager.invalidateConnection() wifiSocketManager.invalidateConnection()
scope.launch(dispatchers.io) { scope.launch(dispatchers.io) {
@ -1802,23 +1879,24 @@ class MainViewModel(
* Wi-Fi AP (or retained P2P) / TCP 통신 연결 해제. * Wi-Fi AP (or retained P2P) / TCP 통신 연결 해제.
*/ */
@Synchronized @Synchronized
fun disconnectCommunication(context: Context? = null) { fun disconnectCommunication(context: Context? = null, releaseWifiAp: Boolean = false): Job? {
if (registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect() if (releaseWifiAp && registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect()
registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager -> registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager ->
when { when {
manager.uiState.value.link != null -> manager.disconnect() manager.uiState.value.link != null -> manager.disconnect()
manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect() manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect()
else -> closeWifiTransportAfterGroupLoss() else -> closeWifiTransportAfterGroupLoss()
} }
return return null
} }
val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately. val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately.
cancelSessionControls()
pendingWifiTarget.set(null) pendingWifiTarget.set(null)
connectedWifiTarget.set(null) connectedWifiTarget.set(null)
configurationRequired = true configurationRequired = true
_uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, _uiState.update { it.copy(isConnected = false, isCommunicationConnecting = false, communicationType = CommunicationType.NONE,
connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") } connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") }
scope.launch(dispatchers.io) { return scope.launch(dispatchers.io) {
sessionMutex.withLock { sessionMutex.withLock {
if (disconnectGeneration != connectionGeneration.get()) return@withLock if (disconnectGeneration != connectionGeneration.get()) return@withLock
try { try {
@ -1828,7 +1906,7 @@ class MainViewModel(
finishMeasurementSession() finishMeasurementSession()
wifiSocketManager.disconnectSuspend() wifiSocketManager.disconnectSuspend()
// Keep the AP lease until STOP and log finalization have completed. // Keep the AP lease until STOP and log finalization have completed.
registeredWifiApManager?.disconnect() if (releaseWifiAp) registeredWifiApManager?.disconnect()
_uiState.update { _uiState.update {
it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE,
connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.")
@ -3189,11 +3267,15 @@ class MainViewModel(
fun syncDeviceConfiguration(onComplete: ((Boolean) -> Unit)? = null) { fun syncDeviceConfiguration(onComplete: ((Boolean) -> Unit)? = null) {
configurationRequired = true configurationRequired = true
val generation = connectionGeneration.get() val generation = connectionGeneration.get()
scope.launch(dispatchers.io) { launchSessionControl {
val success = sessionMutex.withLock { var success = false
generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation) try {
success = sessionMutex.withLock {
generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation)
}
} finally {
onComplete?.invoke(success)
} }
onComplete?.invoke(success)
} }
} }
@ -3210,15 +3292,23 @@ class MainViewModel(
} }
val configuration = createDeviceMeasurementConfiguration() val configuration = createDeviceMeasurementConfiguration()
val configResult = packetRepository.configureMeasurement(configuration) val configResult = packetRepository.configureMeasurement(configuration)
if (generation != connectionGeneration.get()) return false
if (configResult !is DctResult.Success) { if (configResult !is DctResult.Success) {
val message = if (configResult is DctResult.Failure) "장비 설정 실패: ${configResult.error.message}" val message = when {
else "장비 설정이 아직 완료되지 않았습니다." configResult is DctResult.Failure &&
configResult.error is com.dct.app.core.error.DctError.ProtocolError.ResponseTimeout &&
!packetRepository.isConnected.value ->
CONFIGURATION_TIMEOUT_MESSAGE
configResult is DctResult.Failure -> "장비 설정 실패: ${configResult.error.message}"
else -> "장비 설정이 아직 완료되지 않았습니다."
}
_uiState.update { it.copy(statusMessage = message) } _uiState.update { it.copy(statusMessage = message) }
return false return false
} }
if (generation != connectionGeneration.get()) return false if (generation != connectionGeneration.get()) return false
configuredMdbSelection = configuration.selectedMdbSignals.map { it.reclid to it.idx } configuredMdbSelection = configuration.selectedMdbSignals.map { it.reclid to it.idx }
configuredCanMessages = configuration.canMessages
configurationRequired = false configurationRequired = false
_uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") } _uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") }
return true return true
@ -3231,7 +3321,7 @@ class MainViewModel(
fun startMeasure(frequencyHz: Int = 30) { fun startMeasure(frequencyHz: Int = 30) {
if (packetRepository.connectionState.value == DeviceConnectionState.READY && !ensureBackgroundExecution()) return if (packetRepository.connectionState.value == DeviceConnectionState.READY && !ensureBackgroundExecution()) return
val generation = connectionGeneration.get() val generation = connectionGeneration.get()
scope.launch(dispatchers.io) { launchSessionControl {
sessionMutex.withLock { sessionMutex.withLock {
if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock
if (!_uiState.value.canDbcFileName.isNullOrBlank() && _canSignals.value.isEmpty()) { if (!_uiState.value.canDbcFileName.isNullOrBlank() && _canSignals.value.isEmpty()) {
@ -3247,14 +3337,15 @@ class MainViewModel(
setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.") setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.")
return@withLock return@withLock
} }
// Formulas/graph bindings can change after the initial connection handshake. // Bindings can change after Connect. Register MDB inputs and selected
// Register their raw MDB inputs before START, while the device is still READY. // CAN messages before START; a loaded DBC is not a monitoring selection.
val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty() val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty()
if (mdbSignals.isNotEmpty()) { val configuration = createDeviceMeasurementConfiguration()
val selected = selectVisibleMdbSignals(mdbSignals).map { it.reclid to it.idx } val mdbChanged = mdbSignals.isNotEmpty() &&
if (selected != configuredMdbSelection && !syncDeviceConfigurationLocked(generation, notifyProgress = false)) { configuration.selectedMdbSignals.map { it.reclid to it.idx } != configuredMdbSelection
return@withLock if ((mdbChanged || configuration.canMessages != configuredCanMessages) &&
} !syncDeviceConfigurationLocked(generation, notifyProgress = false)) {
return@withLock
} }
setStatusMessage("계측 시작 요청 중...") setStatusMessage("계측 시작 요청 중...")
measurementRequestMillis = System.currentTimeMillis() measurementRequestMillis = System.currentTimeMillis()
@ -3265,7 +3356,9 @@ class MainViewModel(
beginMeasurementSession(frequencyHz) beginMeasurementSession(frequencyHz)
} }
} }
is DctResult.Failure -> _uiState.update { it.copy(isMeasuring = false, statusMessage = result.error.message) } is DctResult.Failure -> if (generation == connectionGeneration.get()) {
_uiState.update { it.copy(isMeasuring = false, statusMessage = result.error.message) }
}
DctResult.Loading -> Unit DctResult.Loading -> Unit
} }
} }
@ -3280,14 +3373,22 @@ class MainViewModel(
private fun createDeviceMeasurementConfiguration(): com.dct.app.domain.model.protocol.DctMeasurementConfiguration { private fun createDeviceMeasurementConfiguration(): com.dct.app.domain.model.protocol.DctMeasurementConfiguration {
val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject()
val maxDlc = if (project.canMode == "CAN(FD)") 64 else 8 val canSignals = _canSignals.value
val messages = _canSignals.value val selectedKeys = selectedMeasurementKeys()
.filter { it.messageId in 0L..0xFFFFL } // PC CheckCANDBCIDList registers messages referenced by selected signals,
// not every schema entry in the loaded DBC. Keep the complete message DLC.
val selectedCanIds = canSignals.asSequence()
.filter { signal -> selectedKeys.any { com.dct.app.domain.model.mdb.CanSignalKey.matches(it, signal) } }
.map { it.messageId }.toHashSet()
val messages = canSignals
.filter { it.messageId in 0L..0xFFFFL && it.messageId in selectedCanIds }
.groupBy { it.messageId } .groupBy { it.messageId }
.map { (canId, signals) -> .map { (canId, signals) ->
val declaredDlc = signals.maxOfOrNull { it.messageDlc } ?: 0 val declaredDlc = signals.maxOfOrNull { it.messageDlc } ?: 0
com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc.coerceAtMost(maxDlc)) // PC SendCANDBCIDData preserves the DBC byte length regardless of CAN mode.
} // Mode selection is sent separately; silently truncating FD lengths changes the schema.
com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc)
}.sortedBy { it.canId }
Log.i( Log.i(
LOG_TAG, LOG_TAG,
"CAN 0x11 설정 준비: signals=${_canSignals.value.size}, messages=${messages.size}, " + "CAN 0x11 설정 준비: signals=${_canSignals.value.size}, messages=${messages.size}, " +
@ -3308,14 +3409,13 @@ class MainViewModel(
canMessages = messages, canMessages = messages,
sensorModules = _sensorModules.value, sensorModules = _sensorModules.value,
isHighSpeedCan = project.canMode == "CAN(HiSpeed)", isHighSpeedCan = project.canMode == "CAN(HiSpeed)",
isOldModel = project.forOldModel isOldModel = project.forOldModel,
didPreSetting = project.didInfo.preSetting
) )
} }
/** Register visible MDB signals and Formula inputs in 0x13, even without raw graph bindings. */ /** Selection snapshot for configuration only; never scan definitions for each packet. */
private fun selectVisibleMdbSignals( private fun selectedMeasurementKeys(): Set<String> {
mdbSignals: List<com.dct.app.domain.model.mdb.SignalDefinition>
): List<com.dct.app.domain.model.mdb.SignalDefinition> {
val selectedKeys = mutableSetOf<String>() val selectedKeys = mutableSetOf<String>()
fun addSeries(series: com.dct.app.domain.model.chart.ChartSignalSeries) { fun addSeries(series: com.dct.app.domain.model.chart.ChartSignalSeries) {
selectedKeys += series.signalId selectedKeys += series.signalId
@ -3343,8 +3443,14 @@ class MainViewModel(
if (dependency.startsWith("raw_sig_", ignoreCase = true)) selectedKeys += dependency.substring(4) if (dependency.startsWith("raw_sig_", ignoreCase = true)) selectedKeys += dependency.substring(4)
} }
} }
val foldedKeys = selectedKeys.mapTo(hashSetOf()) { it.lowercase(java.util.Locale.ROOT) } return selectedKeys.mapTo(hashSetOf()) { it.lowercase(java.util.Locale.ROOT) }
}
/** Register visible MDB signals and Formula inputs in 0x13, even without raw graph bindings. */
private fun selectVisibleMdbSignals(
mdbSignals: List<com.dct.app.domain.model.mdb.SignalDefinition>
): List<com.dct.app.domain.model.mdb.SignalDefinition> {
val foldedKeys = selectedMeasurementKeys()
val matched = mdbSignals.filter { signal -> val matched = mdbSignals.filter { signal ->
"sig_${signal.reclid}_${signal.idx}" in foldedKeys || signal.name.lowercase(java.util.Locale.ROOT) in foldedKeys "sig_${signal.reclid}_${signal.idx}" in foldedKeys || signal.name.lowercase(java.util.Locale.ROOT) in foldedKeys
} }
@ -4102,7 +4208,7 @@ class MainViewModel(
*/ */
fun stopMeasure() { fun stopMeasure() {
val generation = connectionGeneration.get() val generation = connectionGeneration.get()
scope.launch(dispatchers.io) { launchSessionControl {
sessionMutex.withLock { sessionMutex.withLock {
if (generation != connectionGeneration.get() || !_uiState.value.isMeasuring) return@withLock if (generation != connectionGeneration.get() || !_uiState.value.isMeasuring) return@withLock
setStatusMessage("계측 정지 요청 중...") setStatusMessage("계측 정지 요청 중...")

2
Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt

@ -273,7 +273,7 @@ private fun SeedKeyTab(state: SeedKeyState) {
Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("HKMC ASK 인증 상태", fontWeight = FontWeight.Bold) Text("HKMC ASK 인증 상태", fontWeight = FontWeight.Bold)
Text(text) Text(text)
Text("실제 인증에는 대상 ABI용 libHKMC_ASK.so와 nativeGenerateKey JNI 심볼이 필요합니다. 라이브러리가 없으면 앱은 키를 추정하지 않고 PC 프로토콜 실패 응답(0x32)을 보냅니다.", fontSize = 12.sp) Text("실제 인증에는 대상 ABI용 libHKMC_ASK.so와 nativeGenerateKey JNI 심볼이 필요합니다. 라이브러리가 없으면 앱은 키를 추정하지 않고 장치 프로토콜 실패 응답(0x32)을 보냅니다.", fontSize = 12.sp)
} }
} }

15
Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt

@ -137,6 +137,7 @@ fun DiagnosticDataMonitorScreen(
var showWifiDialog by remember { mutableStateOf(false) } var showWifiDialog by remember { mutableStateOf(false) }
var showMaintenanceDialog by remember { mutableStateOf(false) } var showMaintenanceDialog by remember { mutableStateOf(false) }
var showExitConfirmDialog by remember { mutableStateOf(false) } 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)) } var newProjectState by remember { mutableStateOf(com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context)) }
var pendingPropertyDocument by remember { mutableStateOf<Uri?>(null) } var pendingPropertyDocument by remember { mutableStateOf<Uri?>(null) }
var pendingNewProjectDocument by remember { mutableStateOf<Uri?>(null) } var pendingNewProjectDocument by remember { mutableStateOf<Uri?>(null) }
@ -503,6 +504,7 @@ fun DiagnosticDataMonitorScreen(
androidx.activity.compose.BackHandler { androidx.activity.compose.BackHandler {
when { when {
programExitInProgress -> Unit
showExitConfirmDialog -> showExitConfirmDialog = false showExitConfirmDialog -> showExitConfirmDialog = false
showNewProjectDialog -> { showNewProjectDialog -> {
if (!propertyApplying) { if (!propertyApplying) {
@ -1430,9 +1432,7 @@ fun DiagnosticDataMonitorScreen(
Button( Button(
onClick = { onClick = {
showExitConfirmDialog = false showExitConfirmDialog = false
viewModel.autoSaveOnExit(context) (context as? com.dct.app.presentation.main.MainActivity)?.exitProgram()
viewModel.disconnectCommunication(context.applicationContext)
(context as? android.app.Activity)?.finish()
}, },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626))
) { ) {
@ -1446,6 +1446,15 @@ fun DiagnosticDataMonitorScreen(
} }
) )
} }
if (programExitInProgress) {
AlertDialog(
onDismissRequest = {},
title = { Text("프로그램 종료 중") },
text = { Text("프로젝트 저장 및 통신·기록 정리를 마무리하고 있습니다.") },
confirmButton = {}
)
}
} }
/** /**

71
Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonLabel.kt

@ -0,0 +1,71 @@
package com.dct.app.presentation.monitor.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.dct.app.presentation.main.MeasurementRecordingState
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.util.Locale
/** The timer is UI-only: never publish one-second ticks to the measurement/recording pipeline. */
@Composable
internal fun RecordingRibbonLabel(
recordingState: MeasurementRecordingState,
startedAtMillis: Long?,
nowMillis: () -> Long = System::currentTimeMillis
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text("신호 기록", fontSize = 12.sp, color = ColorTextSecondary)
if (recordingState == MeasurementRecordingState.RECORDING && startedAtMillis != null) {
RecordingElapsedTime(startedAtMillis, nowMillis)
}
}
}
@Composable
private fun RecordingElapsedTime(startedAtMillis: Long, nowMillis: () -> Long) {
val lifecycleOwner = LocalLifecycleOwner.current
val readNow by rememberUpdatedState(nowMillis)
var currentMillis by remember(startedAtMillis) { mutableLongStateOf(nowMillis()) }
LaunchedEffect(lifecycleOwner, startedAtMillis) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive) {
// Recompute from the real recording start after resume; do not count UI ticks.
currentMillis = readNow()
delay(1_000L)
}
}
}
val elapsed = formatRecordingElapsedTime(startedAtMillis, currentMillis)
Text(
text = elapsed,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = Color(0xFFDC2626),
maxLines = 1,
softWrap = false,
modifier = Modifier.testTag("RecordingElapsedTime")
.semantics { contentDescription = "기록 경과 시간 $elapsed" }
)
}
internal fun formatRecordingElapsedTime(startedAtMillis: Long, nowMillis: Long): String {
val seconds = ((nowMillis - startedAtMillis).coerceAtLeast(0L)) / 1_000L
return String.format(Locale.US, "%02d:%02d:%02d", seconds / 3_600L, (seconds / 60L) % 60L, seconds % 60L)
}

92
Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt

@ -81,6 +81,49 @@ fun RibbonButton(
} }
} }
@Composable
internal fun WifiConnectionRibbonButtons(
ui: MainUiState,
ap: com.dct.app.data.communication.WifiApUiState,
canConnect: Boolean,
onWifiClick: () -> Unit,
onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit,
onWifiDisconnectClick: () -> Unit
) {
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,
onClick = onWifiDisconnectClick
)
RibbonButton(
icon = Icons.Default.Wifi,
tooltip = 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,
onClick = onWifiClick
)
RibbonButton(
icon = Icons.Default.Link,
tooltip = if (ui.isCommunicationConnecting) "장치 통신 연결 중…" else "장치 통신 연결 (Connect)",
label = "Connect",
enabled = canConnect,
tint = if (wifiConnected) Color(0xFF2563EB) else ColorSecondary,
onClick = onConnectClick
)
RibbonButton(
icon = Icons.Default.LinkOff,
tooltip = "통신 끊기 (Disconnect)",
label = "Disconnect",
enabled = connected || ui.isCommunicationConnecting,
tint = if (connected) Color(0xFFEF4444) else ColorSecondary,
onClick = onDisconnectClick
)
}
/** /**
* 상단 도구 리본바 컴포넌트 (Figma 1-553 규격 호환) * 상단 도구 리본바 컴포넌트 (Figma 1-553 규격 호환)
*/ */
@ -103,7 +146,8 @@ fun WorkspaceRibbonBar(
onWifiClick: () -> Unit, onWifiClick: () -> Unit,
onMaintenanceClick: () -> Unit, onMaintenanceClick: () -> Unit,
onResetGraphDataClick: () -> Unit, onResetGraphDataClick: () -> Unit,
onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() } onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() },
onWifiDisconnectClick: () -> Unit = { viewModel.disconnectCommunication(releaseWifiAp = true) }
) { ) {
var showFileMenu by remember { mutableStateOf(false) } var showFileMenu by remember { mutableStateOf(false) }
val currentProject by viewModel.currentProject.collectAsState() val currentProject by viewModel.currentProject.collectAsState()
@ -114,10 +158,12 @@ fun WorkspaceRibbonBar(
.fillMaxWidth() .fillMaxWidth()
.height(44.dp) .height(44.dp)
) { ) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxSize() .weight(1f)
.fillMaxHeight()
.horizontalScroll(rememberScrollState()) .horizontalScroll(rememberScrollState())
.padding(horizontal = 6.dp), .padding(horizontal = 6.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp) horizontalArrangement = Arrangement.spacedBy(2.dp)
@ -299,21 +345,14 @@ fun WorkspaceRibbonBar(
// Group 2: 통신 연결 // Group 2: 통신 연결
val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE
val apState by viewModel.wifiApState.collectAsState() val apState by viewModel.wifiApState.collectAsState()
val canConnectCommunication by viewModel.canConnectCommunication.collectAsState()
val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI
RibbonButton( WifiConnectionRibbonButtons(
icon = Icons.Default.LinkOff, ui = mainUiState, ap = apState, canConnect = canConnectCommunication,
tooltip = "통신 끊기 (Disconnect)", onWifiClick = onWifiClick, onConnectClick = viewModel::connectCommunication,
enabled = isCommConnected || apState.networkReady || apState.busy, onDisconnectClick = onDisconnectClick,
tint = if (isCommConnected) Color(0xFFEF4444) else ColorSecondary, onWifiDisconnectClick = onWifiDisconnectClick
onClick = onDisconnectClick
)
RibbonButton(
icon = Icons.Default.Wifi,
tooltip = if (apState.networkReady) "Wi-Fi AP 연결됨 (${apState.selectedSsid})" else "Wi-Fi AP 연결",
enabled = !isCommConnected || isWifiConnected,
tint = if (isWifiConnected) Color(0xFF2563EB) else ColorSecondary,
onClick = onWifiClick
) )
VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant)
@ -338,8 +377,8 @@ fun WorkspaceRibbonBar(
onClick = { viewModel.stopMeasure() } onClick = { viewModel.stopMeasure() }
) )
VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant)
Text("신호 기록", fontSize = 12.sp, color = ColorTextSecondary)
val recordingState = mainUiState.recordingState val recordingState = mainUiState.recordingState
RecordingRibbonLabel(recordingState, mainUiState.recordingStartedAtMillis)
val canStartRecording = isCommConnected && isMeasuring && val canStartRecording = isCommConnected && isMeasuring &&
recordingState !in setOf( recordingState !in setOf(
MeasurementRecordingState.STARTING, MeasurementRecordingState.STARTING,
@ -433,5 +472,26 @@ fun WorkspaceRibbonBar(
} }
) )
} }
DebugValueResetButton(onClick = viewModel::resetDebugValues)
}
}
}
/** Kept outside the scrolling ribbon so the debug action is always at the right edge. */
@Composable
internal fun DebugValueResetButton(
isDebugBuild: Boolean = com.dct.app.BuildConfig.DEBUG,
onClick: () -> Unit
) {
if (isDebugBuild) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 6.dp)) {
VerticalDivider(modifier = Modifier.height(20.dp).padding(end = 6.dp), color = ColorOutlineVariant)
RibbonButton(
icon = Icons.Default.RestartAlt,
tooltip = "값 초기화 (그래프 포함)",
label = "값 초기화",
onClick = onClick
)
}
} }
} }

102
Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt

@ -18,6 +18,8 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@DisplayName("Heartbeat 주기 루프 및 OTA 유지보수 단위 테스트") @DisplayName("Heartbeat 주기 루프 및 OTA 유지보수 단위 테스트")
class HeartbeatAndOtaParityTest { class HeartbeatAndOtaParityTest {
@ -80,8 +82,9 @@ class HeartbeatAndOtaParityTest {
assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x02.toByte() }) assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x02.toByte() })
} }
@Test @ParameterizedTest
fun `Heartbeat와 다른 수신이 모두 누락되어도 활성 TCP 소켓은 유지한다`() = runTest { @ValueSource(booleans = [false, true])
fun `재생 전 무응답은 종료하고 재생 요청 이후에는 실제 링크 단절까지 연결을 유지한다`(measuring: Boolean) = runTest {
val wifi = mockk<WifiSocketManager>(relaxed = true) val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 16) val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 16)
val isConnected = MutableStateFlow(true) val isConnected = MutableStateFlow(true)
@ -89,6 +92,10 @@ class HeartbeatAndOtaParityTest {
every { wifi.packetStream } returns packets every { wifi.packetStream } returns packets
every { wifi.currentSessionId } returns 1L every { wifi.currentSessionId } returns 1L
coEvery { wifi.disconnectSuspend() } coAnswers { isConnected.value = false } coEvery { wifi.disconnectSuspend() } coAnswers { isConnected.value = false }
every { wifi.invalidateConnectionIfSession(1L) } answers {
isConnected.value = false
true
}
val sentList = mutableListOf<DctPacket>() val sentList = mutableListOf<DctPacket>()
coEvery { wifi.sendCommand(capture(sentList)) } returns true coEvery { wifi.sendCommand(capture(sentList)) } returns true
@ -100,7 +107,8 @@ class HeartbeatAndOtaParityTest {
heartbeatDispatcher = testDispatcher, heartbeatDispatcher = testDispatcher,
heartbeatIntervalMs = 1000L, heartbeatIntervalMs = 1000L,
heartbeatTimeoutMs = 5000L, heartbeatTimeoutMs = 5000L,
enableHeartbeat = true enableHeartbeat = true,
monotonicTimeMillis = { testScheduler.currentTime }
) )
// 핸드셰이크 통과 설정 // 핸드셰이크 통과 설정
@ -127,43 +135,81 @@ class HeartbeatAndOtaParityTest {
assertTrue(repository.connect("") is DctResult.Success) assertTrue(repository.connect("") is DctResult.Success)
assertEquals(DeviceConnectionState.READY, repository.connectionState.value) assertEquals(DeviceConnectionState.READY, repository.connectionState.value)
if (measuring) {
coEvery { wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers {
packets.emit(DctPacket(command = 0xA0.toByte(), payload = byteArrayOf(1, 1)))
true
}
assertTrue(repository.startStreaming(30) is DctResult.Success)
}
// 1초 후 첫 번째 Heartbeat(0x05) 발송 시점 // 1초 후 첫 번째 Heartbeat(0x05) 발송 시점
advanceTimeBy(1001L) advanceTimeBy(1001L)
val heartbeat1 = sentList.find { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code } val heartbeat1 = sentList.find { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code }
assertNotNull(heartbeat1) assertNotNull(heartbeat1)
// 첫 누락만으로는 일시 지연일 수 있으므로 연결을 유지한다. // 5초 응답 대기 예산 안에서는 연결을 유지한다.
advanceTimeBy(5001L) advanceTimeBy(4998L)
assertEquals(DeviceConnectionState.READY, repository.connectionState.value)
assertTrue(isConnected.value)
// 다음 probe도 0x85와 기타 정상 패킷이 모두 없지만 Heartbeat만으로
// 활성 TCP 연결을 종료해서는 안 된다.
advanceTimeBy(6001L)
assertEquals(DeviceConnectionState.READY, repository.connectionState.value)
assertTrue(isConnected.value) assertTrue(isConnected.value)
advanceTimeBy(3L)
if (measuring) {
advanceTimeBy(18001L) // Several fully silent heartbeat periods must not close playback.
assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value)
assertTrue(repository.isConnected.value)
assertTrue(repository.isStreaming.value)
assertTrue(isConnected.value)
verify(exactly = 0) { wifi.invalidateConnectionIfSession(any()) }
assertFalse(sentList.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() })
coEvery { wifi.sendCommand(match { it.command == 0x21.toByte() }) } coAnswers {
packets.emit(DctPacket(0xA1.toByte(), byteArrayOf(1)))
true
}
assertTrue(repository.stopStreaming() is DctResult.Success)
advanceTimeBy(12001L)
assertEquals(DeviceConnectionState.READY, repository.connectionState.value)
assertTrue(isConnected.value) // STOP does not re-enable automatic liveness disconnect.
verify(exactly = 0) { wifi.invalidateConnectionIfSession(any()) }
isConnected.value = false // An actual transport/link loss still ends the session.
testScheduler.runCurrent()
assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value)
assertFalse(repository.isConnected.value)
assertFalse(repository.isStreaming.value)
every { wifi.currentSessionId } returns 2L
every { wifi.invalidateConnectionIfSession(2L) } answers { isConnected.value = false; true }
isConnected.value = true
assertTrue(repository.connect("") is DctResult.Success)
advanceTimeBy(6002L)
assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value)
verify(exactly = 1) { wifi.invalidateConnectionIfSession(2L) }
return@runTest
}
assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value)
assertFalse(repository.isConnected.value)
assertFalse(repository.isStreaming.value)
assertFalse(isConnected.value)
verify(exactly = 1) { wifi.invalidateConnectionIfSession(1L) }
// 무응답 장비에 STOP/DISABLE ACK를 추가로 기다리지 않는다.
assertFalse(sentList.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() })
coVerify(exactly = 0) { wifi.disconnectSuspend() } coVerify(exactly = 0) { wifi.disconnectSuspend() }
// 로그에서 재현된 것처럼 늦은 정상 응답이 도착해도 그대로 수신하고 // 종료 뒤 늦게 도착한 START ACK로 연결/계측을 되살리지 않는다.
// 이후 Heartbeat 감시를 계속할 수 있어야 한다.
packets.emit(DctPacket( packets.emit(DctPacket(
command = DctCommand.RES_START_TRANSMIT_DATA.code, command = DctCommand.RES_START_TRANSMIT_DATA.code,
payload = byteArrayOf(1, 1), payload = byteArrayOf(1, 1),
transportSessionId = 1L transportSessionId = 1L
)) ))
advanceTimeBy(1L)
assertEquals(DeviceConnectionState.READY, repository.connectionState.value)
assertTrue(isConnected.value)
// 제품 동작 검증 후 모의 전송 계층을 내려 반복 Heartbeat 작업을 정리한다.
isConnected.value = false
advanceTimeBy(6001L) advanceTimeBy(6001L)
assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value)
assertFalse(isConnected.value)
assertEquals(1, sentList.count { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code })
} }
@Test @ParameterizedTest
fun `계측 중에도 Heartbeat 루프가 데이터 스트림을 차단하지 않고 공존한다`() = runTest { @ValueSource(booleans = [false, true])
fun `계측 데이터 또는 Heartbeat 응답이 있으면 연결을 유지한다`(heartbeatReply: Boolean) = runTest {
val wifi = mockk<WifiSocketManager>(relaxed = true) val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 64) val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 64)
val isConnected = MutableStateFlow(true) val isConnected = MutableStateFlow(true)
@ -182,7 +228,8 @@ class HeartbeatAndOtaParityTest {
heartbeatDispatcher = testDispatcher, heartbeatDispatcher = testDispatcher,
heartbeatIntervalMs = 2000L, heartbeatIntervalMs = 2000L,
heartbeatTimeoutMs = 5000L, heartbeatTimeoutMs = 5000L,
enableHeartbeat = true enableHeartbeat = true,
monotonicTimeMillis = { testScheduler.currentTime }
) )
// 핸드셰이크 통과 // 핸드셰이크 통과
@ -227,16 +274,17 @@ class HeartbeatAndOtaParityTest {
// Heartbeat 주기(2000ms) 전진 // Heartbeat 주기(2000ms) 전진
advanceTimeBy(2001L) advanceTimeBy(2001L)
// 0x85 대신 계측 데이터가 들어와도 PC와 같이 장비 생존으로 판단한다. // 정상 데이터만 수신하는 경우와 0x85 응답만 받는 경우를 각각 검증한다.
packets.emit(DctPacket( packets.emit(DctPacket(
command = DctCommand.SEND_CAN_FRAME.code, command = if (heartbeatReply) DctCommand.RES_POSSIBLE_TO_CONNECT.code else DctCommand.SEND_CAN_FRAME.code,
payload = byteArrayOf(0, 8, 1, 2, 3, 4, 5, 6, 7, 8), payload = if (heartbeatReply) byteArrayOf() else byteArrayOf(1, 0xA4.toByte(), 1),
transportSessionId = 2L transportSessionId = 2L
)) ))
advanceTimeBy(5001L) advanceTimeBy(5001L)
assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value) assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value)
assertTrue(sentList.any { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code }) assertTrue(sentList.any { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code })
verify(exactly = 0) { wifi.invalidateConnectionIfSession(any()) }
repository.disconnect() repository.disconnect()
} }

18
Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt

@ -163,13 +163,29 @@ class DctProtocolRequestsTest {
assertThat(DctProtocolRequests.canDbcInfo(emptyList()).payload) assertThat(DctProtocolRequests.canDbcInfo(emptyList()).payload)
.isEqualTo(hex("00 00 00")) .isEqualTo(hex("00 00 00"))
assertThat(DctProtocolRequests.sensorModuleInfo(createDefaultSensorModules()).payload) assertThat(DctProtocolRequests.sensorModuleInfo(createDefaultSensorModules()).payload)
.isEqualTo(hex("01 01 FF FF FF FF")) .isEqualTo(hex("01 01 FF 00 00 00"))
assertThat(DctProtocolRequests.sensorModuleInfo(emptyList()).payload) assertThat(DctProtocolRequests.sensorModuleInfo(emptyList()).payload)
.isEqualTo(hex("01 00")) .isEqualTo(hex("01 00"))
assertThat(DctProtocolRequests.canMode(isHighSpeedCan = true, isOldModel = true).payload) assertThat(DctProtocolRequests.canMode(isHighSpeedCan = true, isOldModel = true).payload)
.isEqualTo(hex("06 11")) .isEqualTo(hex("06 11"))
} }
@Test
fun `미사용 센서 모듈은 저장된 채널 타입과 무관하게 PC처럼 0으로 전송한다`() {
val modules = createDefaultSensorModules().map { module ->
module.copy(isUsed = module.moduleIndex == 2)
}
assertThat(DctProtocolRequests.sensorModuleInfo(modules.reversed()).payload)
.isEqualTo(hex("01 04 00 00 FF 00"))
assertThat(DctProtocolRequests.sensorModuleInfo(modules.map { it.copy(isUsed = false) }).payload)
.isEqualTo(hex("01 00 00 00 00 00"))
val mixed = modules.map { module ->
module.copy(channels = module.channels.map { it.copy(isVoltage = it.channelIndex % 2 == 0) })
}
assertThat(DctProtocolRequests.sensorModuleInfo(mixed).payload)
.isEqualTo(hex("01 04 00 00 AA 00"))
}
@Test @Test
fun `MDB wire 폭 축소 후 중복되는 ID와 Index는 설정 전에 거절한다`() { fun `MDB wire 폭 축소 후 중복되는 ID와 Index는 설정 전에 거절한다`() {
val base = SignalDefinition(1, 1, "A", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 1.0) val base = SignalDefinition(1, 1, "A", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 1.0)

85
Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt

@ -84,6 +84,86 @@ class FormulaTelemetryRegressionTest {
assertEquals(3.0, vm.liveTelemetryValues.value["B"]) assertEquals(3.0, vm.liveTelemetryValues.value["B"])
} }
@Test fun debugResetClearsAllPagesAndValuesWithoutStoppingRecordingOrRemovingConfiguration() {
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<MainUiState>
ui.value = ui.value.copy(dashboardTiles = listOf(
com.dct.app.domain.model.dashboard.TileWidgetItem("Incar_Snr", "Incar_Snr")))
vm.startMeasurementRecording()
telemetry(2f)
renderers.forEach { assertTrue(it.seriesListFlow.value.single().buffer.size > 0) }
assertTrue(vm.uiState.value.dashboardTiles.any { it.historyBuffer.size > 0 })
val uiBefore = vm.uiState.value
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() }
val raw = field(vm, "latestMf4RawValues").get(vm) as MutableMap<String, Number>
raw.putAll(mapOf("sig_1_1" to 42, "can_1" to 55, "a2l_1" to 66))
vm.resetDebugValues()
assertTrue(vm.liveTelemetryValues.value.isEmpty())
assertTrue(raw.isEmpty())
assertEquals(0L, vm.uiState.value.packetCount)
assertTrue(vm.uiState.value.dashboardTiles.all { it.value == 0.0 && it.historyBuffer.size == 0 })
assertEquals(uiBefore.dashboardTiles.map { it.signalId }, vm.uiState.value.dashboardTiles.map { it.signalId })
assertEquals(project, vm.currentProject.value)
assertEquals(formulas, vm.formulas.value)
assertEquals(pages, vm.pages.value.map { it.id to it.title })
assertTrue(vm.uiState.value.isMeasuring)
assertTrue(vm.uiState.value.isConnected)
assertEquals(uiBefore.recordingState, vm.uiState.value.recordingState)
assertEquals(uiBefore.recordingSampleCount, vm.uiState.value.recordingSampleCount)
assertEquals(uiBefore.recordingStartedAtMillis, vm.uiState.value.recordingStartedAtMillis)
renderers.forEachIndexed { index, renderer ->
val series = renderer.seriesListFlow.value.single()
assertEquals(0, series.buffer.size)
assertEquals("formula_F", series.signalId)
assertEquals(0x123456L, series.color)
assertEquals(bindings[index].axes, renderer.exportWorkspaceState().axes)
}
coVerify(exactly = 0) { repository.stopStreaming() }
coVerify(exactly = 0) { repository.disconnect() }
telemetry(3f)
assertEquals(6.0, vm.liveTelemetryValues.value["F"])
assertEquals(1L, vm.uiState.value.packetCount)
assertEquals(uiBefore.recordingSampleCount + 1, vm.uiState.value.recordingSampleCount)
renderers.forEach { assertEquals(1, it.seriesListFlow.value.single().buffer.size) }
vm.stopMeasurementRecording()
val rows = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") }.readLines()
.filter { it.matches(Regex("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2},.*")) }
assertEquals(listOf("0", "1"), rows.map { it.split(',')[1].trim() })
assertEquals(listOf(4.0, 6.0), rows.map { it.substringAfterLast(',').toDouble() })
}
@Test fun debugResetWhileStoppedDoesNotReuseOldFormulaInputs() {
vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2"))
telemetry(2f)
assertEquals(4.0, vm.liveTelemetryValues.value["F"])
vm.resetDebugValues()
assertTrue(vm.liveTelemetryValues.value.isEmpty())
vm.resetDebugValues() // Repeated reset without a project/measurement is safe.
assertTrue(packets.tryEmit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 10, 0, 0))))
assertTrue(vm.liveTelemetryValues.value.isEmpty()) // A stopped session ignores stream data.
assertFalse(vm.uiState.value.isMeasuring)
vm.startMeasure()
assertTrue(packets.tryEmit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 10, 0, 0))))
assertTrue(vm.liveTelemetryValues.value.getValue("F").isNaN())
telemetry(5f)
assertEquals(10.0, vm.liveTelemetryValues.value["F"])
}
@Test fun unrelatedSmPacketAddsNeitherFormulaGraphNorCsvSample() { @Test fun unrelatedSmPacketAddsNeitherFormulaGraphNorCsvSample() {
vm.startMeasure() vm.startMeasure()
vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2")) vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2"))
@ -101,8 +181,11 @@ class FormulaTelemetryRegressionTest {
assertEquals(2, graph.buffer.size) assertEquals(2, graph.buffer.size)
vm.stopMeasurementRecording() vm.stopMeasurementRecording()
val csv = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") } val csv = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") }
val rows = csv.readLines().filter { it.matches(Regex("^\\d{2}:\\d{2}:\\d{2}\\.\\d{3},.*")) } // The PC-compatible default includes the date and omits milliseconds.
val rows = csv.readLines().filter { it.matches(Regex("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2},.*")) }
assertEquals(2, rows.size) assertEquals(2, rows.size)
assertEquals(listOf("0", "1"), rows.map { it.split(',')[1].trim() })
assertEquals(listOf(4.0, 6.0), rows.map { it.substringAfterLast(',').toDouble() })
} }
@Test fun editsRenameAndDeletionRefreshWithoutWaitingForAnotherPacket() { @Test fun editsRenameAndDeletionRefreshWithoutWaitingForAnotherPacket() {

137
Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt

@ -0,0 +1,137 @@
package com.dct.app.tests
import com.dct.app.core.common.DctResult
import com.dct.app.data.communication.WifiSocketManager
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.ExperimentalCoroutinesApi
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 HeartbeatIdleRegressionTest {
private class Fixture(scope: TestScope) {
val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 64)
val connected = MutableStateFlow(true)
val sent = mutableListOf<DctPacket>()
val acks = mutableListOf<DctPacket>()
var sessionId = 7L
val repository: PacketRepositoryImpl
init {
every { wifi.isConnected } returns connected
every { wifi.packetStream } returns packets
every { wifi.currentSessionId } answers { sessionId }
every { wifi.offerCommand(capture(acks)) } returns true
coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false }
every { wifi.invalidateConnectionIfSession(any()) } answers {
connected.value = false
true
}
coEvery { wifi.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>()
sent += request
val payload = when (request.command.toInt() and 255) {
0x00, 0x04, 0x09, 0x21 -> byteArrayOf(1)
0x01 -> byteArrayOf(4, 68, 67, 84, 0)
0x03 -> ByteArray(6)
0x20 -> byteArrayOf(1, 1)
else -> null // Probes intentionally remain silent.
}
if (payload != null) packets.emit(DctPacket(
((request.command.toInt() and 255) + 0x80).toByte(), payload,
transportSessionId = sessionId))
true
}
val dispatcher = StandardTestDispatcher(scope.testScheduler)
repository = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher,
heartbeatDispatcher = dispatcher, heartbeatIntervalMs = 1000,
heartbeatTimeoutMs = 5000, monotonicTimeMillis = { scope.testScheduler.currentTime })
}
suspend fun connect() { assertTrue(repository.connect("") is DctResult.Success) }
fun probes() = sent.count { it.command == 0x05.toByte() }
}
@ParameterizedTest
@ValueSource(ints = [0, 1, 2, 3])
fun continuousMdbCanSensorAndXcpKeepTheirAcksWithoutHeartbeatProbes(source: Int) = runTest {
val f = Fixture(this)
f.connect()
assertTrue(f.repository.startStreaming() is DctResult.Success)
val (command, payload, ack) = when (source) {
0 -> Triple(0xA3, byteArrayOf(1, 0xA0.toByte(), 42), 0x23)
1 -> Triple(0xA3, byteArrayOf(2, 1, 1, 1, 1, 0x23, 1, 42), 0x23)
2 -> Triple(0xA4, byteArrayOf(0), 0x24)
else -> Triple(0xD0, byteArrayOf(0, 1, 0, 42), 0x50)
}
repeat(20) {
advanceTimeBy(500)
f.packets.emit(DctPacket(command.toByte(), payload, transportSessionId = f.sessionId))
runCurrent()
}
assertEquals(0, f.probes())
assertEquals(20, f.acks.size)
assertTrue(f.acks.all { it.command == ack.toByte() && it.payload.contentEquals(byteArrayOf(1)) })
assertEquals(DeviceConnectionState.MEASURING, f.repository.connectionState.value)
f.repository.disconnect()
}
@Test fun probeWaitsForFullSilenceFromLastPacketNotTheOriginalTimerTick() = runTest {
val f = Fixture(this)
f.connect()
advanceTimeBy(900)
f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = f.sessionId))
runCurrent()
advanceTimeBy(999)
runCurrent()
assertEquals(0, f.probes())
advanceTimeBy(1)
runCurrent()
assertEquals(1, f.probes())
assertTrue(f.connected.value)
f.repository.disconnect()
}
@Test fun normalControlResponsesAlsoSuppressIdleProbes() = runTest {
val f = Fixture(this)
f.connect()
repeat(8) {
advanceTimeBy(750)
f.packets.emit(DctPacket(0x85.toByte(), byteArrayOf(), transportSessionId = f.sessionId))
runCurrent()
}
assertEquals(0, f.probes())
assertTrue(f.acks.isEmpty())
f.repository.disconnect()
}
@Test fun staleSessionPacketsCannotPostponeProbeAndReconnectStartsFreshBudget() = runTest {
val f = Fixture(this)
f.connect()
advanceTimeBy(900)
f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = f.sessionId))
runCurrent()
f.repository.disconnect()
f.sessionId++
f.connected.value = true
f.connect()
val before = f.probes()
advanceTimeBy(900)
f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = f.sessionId - 1))
runCurrent()
advanceTimeBy(100)
runCurrent()
assertEquals(before + 1, f.probes())
assertEquals(1, f.acks.size) // Only the first session's real packet was acknowledged.
f.repository.disconnect()
}
}

383
Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt

@ -40,6 +40,120 @@ import java.io.File
@DisplayName("M5 - MainViewModel 메인 뷰모델 및 오케스트레이션 단위 테스트") @DisplayName("M5 - MainViewModel 메인 뷰모델 및 오케스트레이션 단위 테스트")
class MainViewModelTest { class MainViewModelTest {
@Test fun canConfigurationPreservesDbcLengthsInHighSpeedMode() {
assertCanConfigurationPreservesDbcLengths("CAN(HiSpeed)", true)
}
@Test fun canConfigurationPreservesDbcLengthsInFdMode() {
assertCanConfigurationPreservesDbcLengths("CAN(FD)", false)
}
private fun assertCanConfigurationPreservesDbcLengths(mode: String, highSpeed: Boolean) {
viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(canMode = mode))
val lengths = listOf(0, 8, 12, 16, 20, 24, 32, 48, 64)
val signals = lengths.mapIndexed { index, length ->
sampleDb.allSignals.first().copy(
reclid = 0, idx = index, name = "CAN_$index",
messageId = 0x100L + index, messageDlc = length
)
}
@Suppress("UNCHECKED_CAST")
val definitions = MainViewModel::class.java.getDeclaredField("_canSignals")
.apply { isAccessible = true }.get(viewModel) as MutableStateFlow<List<SignalDefinition>>
// Multiple signals in one message must not create duplicate registrations.
definitions.value = signals + signals.last().copy(idx = 99, name = "SameMessage")
signals.forEach { signal ->
chartRenderer.bindSignal(CanSignalKey.stable(signal), signal.name, AxisPosition.LEFT_0, 0L)
}
val configuration = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration")
.apply { isAccessible = true }.invoke(viewModel) as
com.dct.app.domain.model.protocol.DctMeasurementConfiguration
assertEquals(lengths, configuration.canMessages.map { it.dlc })
assertEquals(highSpeed, configuration.isHighSpeedCan)
assertEquals(mode, viewModel.currentProject.value!!.canMode)
val payload = com.dct.app.data.protocol.DctProtocolRequests.canDbcInfo(configuration.canMessages).payload
val expected = byteArrayOf(1, 0, lengths.size.toByte()) + lengths.flatMapIndexed { index, length ->
listOf(1.toByte(), index.toByte(), length.toByte())
}.toByteArray()
assertArrayEquals(expected, payload)
definitions.value = emptyList()
val emptyConfiguration = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration")
.apply { isAccessible = true }.invoke(viewModel) as
com.dct.app.domain.model.protocol.DctMeasurementConfiguration
assertArrayEquals(byteArrayOf(0, 0, 0),
com.dct.app.data.protocol.DctProtocolRequests.canDbcInfo(emptyConfiguration.canMessages).payload)
}
private fun setCanDefinitions(signals: List<SignalDefinition>) {
@Suppress("UNCHECKED_CAST")
val state = MainViewModel::class.java.getDeclaredField("_canSignals")
.apply { isAccessible = true }.get(viewModel) as MutableStateFlow<List<SignalDefinition>>
state.value = signals
}
private fun deviceConfiguration() = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration")
.apply { isAccessible = true }.invoke(viewModel) as com.dct.app.domain.model.protocol.DctMeasurementConfiguration
@Test fun canConfigurationDoesNotRegisterAnUnselectedLoadedDbc() {
val signals = (0..170).map { index -> sampleDb.allSignals.first().copy(
reclid = 0, idx = index, name = "CAN_$index", messageId = index.toLong(), messageDlc = 32) }
setCanDefinitions(signals)
mdiViewModel.openOrFocusCategoryWindow(MdiWindowType.CAN_SIGNAL_LIST, "CAN Signal")
val project = com.dct.app.domain.model.project.DpcProject(
canDbcFileName = "Loaded.dbc", didInfo = com.dct.app.domain.model.project.DpcDidInfo(preSetting = 49))
viewModel.setCurrentProject(project)
val configuration = deviceConfiguration()
assertTrue(configuration.canMessages.isEmpty())
assertEquals(49, configuration.didPreSetting)
assertEquals("Loaded.dbc", viewModel.currentProject.value!!.canDbcFileName)
assertEquals(signals, viewModel.canSignals.value)
assertArrayEquals(byteArrayOf(0, 0, 0),
com.dct.app.data.protocol.DctProtocolRequests.canDbcInfo(configuration.canMessages).payload)
}
@Test fun canConfigurationUsesBindingsAliasesAndDeduplicatesMessagesWithoutShrinkingDlc() {
val signals = (0..4).map { index -> sampleDb.allSignals.first().copy(
reclid = 0, idx = index, name = "CAN_$index", messageId = 0x100L + index, messageDlc = 32) }
val duplicate = signals[0].copy(idx = 99, name = "SecondSignal", messageDlc = 64)
setCanDefinitions(signals + duplicate)
chartRenderer.bindSignal(CanSignalKey.stable(signals[0]), "Old name", AxisPosition.LEFT_0, 0L)
chartRenderer.bindSignal(CanSignalKey.legacy(duplicate), "Old name", AxisPosition.LEFT_0, 0L)
val alpha = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!!
viewModel.addAlphanumericalSignal(alpha, "CAN_0x101_CAN_1")
viewModel.addFormula(com.dct.app.domain.model.formula.FormulaDefinition(name = "F", expression = "CAN_2*2"))
val messages = deviceConfiguration().canMessages
assertEquals(listOf(0x100L, 0x101L, 0x102L), messages.map { it.canId })
assertEquals(listOf(64, 32, 32), messages.map { it.dlc })
}
@Test fun canConfigurationRefreshesBeforeStartWhenCanOnlyBindingsChange() {
viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(canDbcFileName = "Loaded.dbc"))
val signal = sampleDb.allSignals.first().copy(reclid = 0, idx = 7, name = "CAN_SPEED", messageId = 0x456, messageDlc = 32)
setCanDefinitions(listOf(signal))
connectionState.value = DeviceConnectionState.READY
repositoryConnected.value = true
val configurations = mutableListOf<com.dct.app.domain.model.protocol.DctMeasurementConfiguration>()
coEvery { packetRepository.configureMeasurement(any()) } answers {
configurations += firstArg<com.dct.app.domain.model.protocol.DctMeasurementConfiguration>()
DctResult.Success(Unit)
}
viewModel.syncDeviceConfiguration()
assertTrue(configurations.single().canMessages.isEmpty())
chartRenderer.bindSignal(CanSignalKey.stable(signal), signal.name, AxisPosition.LEFT_0, 0L)
viewModel.startMeasure()
assertEquals(listOf(0x456L), configurations.last().canMessages.map { it.canId })
assertEquals(2, configurations.size)
viewModel.stopMeasure()
chartRenderer.unbindSignal(CanSignalKey.stable(signal))
viewModel.startMeasure()
assertEquals(3, configurations.size)
assertTrue(configurations.last().canMessages.isEmpty())
coVerify(exactly = 2) { packetRepository.startStreaming(any()) }
viewModel.stopMeasure()
}
@Test fun propertyChangeKeepsUnchangedSourceSamplesAndMatchesMdbItems() { @Test fun propertyChangeKeepsUnchangedSourceSamplesAndMatchesMdbItems() {
viewModel.openMdbFromFile(File("Sample.mdb")) viewModel.openMdbFromFile(File("Sample.mdb"))
viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb")) viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb"))
@ -360,6 +474,175 @@ class MainViewModelTest {
private lateinit var sampleDb: MdbDatabase private lateinit var sampleDb: MdbDatabase
private var backgroundExecutionAllowed = true private var backgroundExecutionAllowed = true
private fun installWifiAp(
initial: com.dct.app.data.communication.WifiApUiState
): Pair<com.dct.app.data.communication.WifiApManager, MutableStateFlow<com.dct.app.data.communication.WifiApUiState>> {
val state = MutableStateFlow(initial)
val manager = mockk<com.dct.app.data.communication.WifiApManager>(relaxed = true)
every { manager.uiState } returns state
MainViewModel::class.java.getDeclaredField("registeredWifiApManager")
.apply { isAccessible = true }.set(viewModel, manager)
@Suppress("UNCHECKED_CAST")
val exposed = MainViewModel::class.java.getDeclaredField("_wifiApState")
.apply { isAccessible = true }.get(viewModel) as MutableStateFlow<com.dct.app.data.communication.WifiApUiState>
exposed.value = initial
return manager to state
}
private fun readyWifiAp() = com.dct.app.data.communication.WifiApUiState(
phase = com.dct.app.data.communication.WifiApPhase.CONNECTED,
enabled = true, permissionGranted = true, locationEnabled = true,
selectedSsid = "DCT", localAddress = "192.168.4.2")
@Test fun wifiNetworkActionsDoNotStartTcpProtocolOrRequireForegroundPermission() {
val (manager, _) = installWifiAp(readyWifiAp())
backgroundExecutionAllowed = false
viewModel.connectWifiAp(com.dct.app.data.communication.WifiApDevice("DCT", "", -40,
com.dct.app.data.communication.WifiApSecurity.WPA2), "12345678")
viewModel.connectCurrentWifiAp()
verify(exactly = 1) { manager.connect(any(), "12345678") }
verify(exactly = 1) { manager.connectCurrent() }
verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { packetRepository.connect(any()) }
assertFalse(viewModel.uiState.value.isCommunicationConnecting)
}
@Test fun apReadyAndDhcpReadyCallbacksNeverStartProtocolAutomatically() {
val context = mockk<android.content.Context>(relaxed = true)
every { context.applicationContext } returns context
every { context.getSystemService(android.net.wifi.WifiManager::class.java) } returns null
every { context.getSystemService(android.net.ConnectivityManager::class.java) } returns null
mockkConstructor(android.os.Handler::class)
mockkObject(com.dct.app.data.logger.MeasurementSafExports)
every { com.dct.app.data.logger.MeasurementSafExports.exportReady(context) } returns 0
viewModel.initializeWifiAp(context)
val manager = MainViewModel::class.java.getDeclaredField("registeredWifiApManager")
.apply { isAccessible = true }.get(viewModel) as com.dct.app.data.communication.WifiApManager
@Suppress("UNCHECKED_CAST")
val onReady = com.dct.app.data.communication.WifiApManager::class.java.getDeclaredField("onReady")
.apply { isAccessible = true }.get(manager) as (String) -> Unit
onReady("DCT")
onReady("DCT")
verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { packetRepository.connect(any()) }
assertFalse(viewModel.uiState.value.isCommunicationConnecting)
}
@Test fun connectStartsTcpOnlyOnceAndHandshakeOnlyAfterSocketReady() {
installWifiAp(readyWifiAp())
assertTrue(viewModel.canConnectCommunication.value)
var ready: ((Boolean, String) -> Unit)? = null
every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers {
ready = arg(3)
}
coEvery { packetRepository.connect(any()) } answers {
repositoryConnected.value = true
connectionState.value = DeviceConnectionState.READY
DctResult.Success(Unit)
}
viewModel.connectCommunication()
viewModel.connectCommunication()
assertTrue(viewModel.uiState.value.isCommunicationConnecting)
assertFalse(viewModel.canConnectCommunication.value)
assertFalse(viewModel.setWifiApEndpoint(com.dct.app.data.communication.WifiApEndpoint("192.168.1.10", 6000)))
verify(exactly = 1) { viewModel.wifiSocketManager.startDualMode(false, "192.168.4.1", 5000, any(), any(), any()) }
coVerify(exactly = 0) { packetRepository.connect(any()) }
ready!!.invoke(false, "192.168.4.1")
coVerify(exactly = 1) { packetRepository.connect("192.168.4.1") }
coVerify(exactly = 1) { packetRepository.configureMeasurement(any()) }
assertFalse(viewModel.uiState.value.isCommunicationConnecting)
}
@Test fun connectAdoptsExistingWifiButMissingIpv4NeverStartsTcp() {
val network = com.dct.app.data.communication.ConnectedWifiNetwork("1", "Office", "192.168.1.20")
val (manager, state) = installWifiAp(readyWifiAp().copy(
phase = com.dct.app.data.communication.WifiApPhase.IDLE, localAddress = null,
connectedNetworks = listOf(network)))
every { manager.connectCurrent() } answers { state.value = readyWifiAp() }
viewModel.connectCommunication()
verify(exactly = 1) { manager.connectCurrent() }
verify(exactly = 1) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) }
viewModel.disconnectCommunication()
state.value = state.value.copy(phase = com.dct.app.data.communication.WifiApPhase.IDLE,
localAddress = null, connectedNetworks = listOf(network.copy(localAddress = null)))
viewModel.connectCommunication()
verify(exactly = 1) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) }
verify(exactly = 0) { manager.disconnect() }
}
@Test fun connectRejectsUnavailableAmbiguousBusyAndAlreadyConnectedStates() {
val base = readyWifiAp()
val network = com.dct.app.data.communication.ConnectedWifiNetwork("1", "Office", "192.168.1.20")
for (unavailable in listOf(
base.copy(enabled = false), base.copy(permissionGranted = false),
base.copy(phase = com.dct.app.data.communication.WifiApPhase.CONNECTING),
base.copy(localAddress = null),
base.copy(phase = com.dct.app.data.communication.WifiApPhase.IDLE, localAddress = null,
connectedNetworks = listOf(network, network.copy(key = "2")))
)) {
installWifiAp(unavailable)
assertFalse(viewModel.canConnectCommunication.value)
viewModel.connectCommunication()
}
installWifiAp(base)
@Suppress("UNCHECKED_CAST")
val socket = viewModel.wifiSocketManager.isConnected as MutableStateFlow<Boolean>
socket.value = true
assertFalse(viewModel.canConnectCommunication.value)
viewModel.connectCommunication()
socket.value = false
connectionState.value = DeviceConnectionState.READY
assertFalse(viewModel.canConnectCommunication.value)
viewModel.connectCommunication()
connectionState.value = DeviceConnectionState.MEASURING
assertFalse(viewModel.canConnectCommunication.value)
viewModel.connectCommunication()
verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) }
}
@Test fun tcpFailureAllowsExplicitRetryAndDisconnectCancelsLateHandshakeWithoutLeavingWifi() {
val (manager, _) = installWifiAp(readyWifiAp())
var ready: ((Boolean, String) -> Unit)? = null
var failed: ((String) -> Unit)? = null
every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers {
ready = arg(3); failed = arg(5)
}
viewModel.connectCommunication()
failed!!.invoke("timeout")
assertFalse(viewModel.uiState.value.isCommunicationConnecting)
assertTrue(viewModel.canConnectCommunication.value)
viewModel.connectCommunication()
viewModel.disconnectCommunication()
ready!!.invoke(false, "192.168.4.1")
coVerify(exactly = 0) { packetRepository.connect(any()) }
verify(exactly = 0) { manager.disconnect() }
assertTrue(manager.uiState.value.networkReady)
viewModel.disconnectCommunication(releaseWifiAp = true)
verify(exactly = 1) { manager.disconnect() }
}
@Test fun disconnectCompletionWaitsForTransportAndApCleanupBeforeExit() = runTest {
val (manager, _) = installWifiAp(readyWifiAp())
val disconnected = kotlinx.coroutines.CompletableDeferred<Unit>()
coEvery { packetRepository.disconnect() } coAnswers {
disconnected.await()
DctResult.Success(Unit)
}
val cleanup = viewModel.disconnectCommunication(releaseWifiAp = true)!!
assertFalse(cleanup.isCompleted)
verify(exactly = 0) { manager.disconnect() }
coVerify(exactly = 0) { viewModel.wifiSocketManager.disconnectSuspend() }
disconnected.complete(Unit)
cleanup.join()
assertTrue(cleanup.isCompleted)
coVerify(exactly = 1) { viewModel.wifiSocketManager.disconnectSuspend() }
verify(exactly = 1) { manager.disconnect() }
assertFalse(viewModel.uiState.value.isConnected)
assertFalse(viewModel.uiState.value.isMeasuring)
}
@Test fun editableWifiTargetRejectsInvalidInputAndChangesDuringMeasurement() { @Test fun editableWifiTargetRejectsInvalidInputAndChangesDuringMeasurement() {
val original = viewModel.wifiApEndpoint.value val original = viewModel.wifiApEndpoint.value
assertFalse(viewModel.setWifiApEndpoint(com.dct.app.data.communication.WifiApEndpoint("0.0.0.0", 6000))) assertFalse(viewModel.setWifiApEndpoint(com.dct.app.data.communication.WifiApEndpoint("0.0.0.0", 6000)))
@ -372,6 +655,102 @@ class MainViewModelTest {
assertEquals(pc, viewModel.wifiApEndpoint.value) assertEquals(pc, viewModel.wifiApEndpoint.value)
} }
@Test fun disconnectCancelsInFlightStartWithoutWaitingForItsResponse() {
val (manager, _) = installWifiAp(readyWifiAp())
connectionState.value = DeviceConnectionState.READY
var cancelled = false
coEvery { packetRepository.startStreaming(any()) } coAnswers {
try { kotlinx.coroutines.awaitCancellation() } finally { cancelled = true }
}
viewModel.startMeasure()
viewModel.disconnectCommunication()
assertTrue(cancelled)
coVerify(exactly = 1) { packetRepository.disconnect() }
verify(exactly = 0) { manager.disconnect() }
assertFalse(viewModel.uiState.value.isConnected)
}
@Test fun configurationTimeoutShowsDisconnectedKeepsWifiAndAllowsExplicitReconnect() {
val (manager, _) = installWifiAp(readyWifiAp())
every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers {
arg<(Boolean, String) -> Unit>(3).invoke(false, "192.168.4.1")
}
coEvery { packetRepository.connect(any()) } coAnswers {
repositoryConnected.value = true
connectionState.value = DeviceConnectionState.READY
DctResult.Success(Unit)
}
coEvery { packetRepository.configureMeasurement(any()) } coAnswers {
repositoryConnected.value = false
connectionState.value = DeviceConnectionState.ERROR
DctResult.Failure(com.dct.app.core.error.DctError.ProtocolError.ResponseTimeout("0x09", "0x89"))
}
val project = com.dct.app.domain.model.project.DpcProject(projectFileName = "Preserved.dpc")
viewModel.setCurrentProject(project)
viewModel.connectCommunication()
assertFalse(viewModel.uiState.value.isConnected)
assertFalse(viewModel.uiState.value.isCommunicationConnecting)
assertFalse(viewModel.uiState.value.isMeasuring)
assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType)
assertEquals("장비 설정 응답 시간 초과로 통신 연결이 해제되었습니다. Connect로 다시 연결하세요.",
viewModel.uiState.value.statusMessage)
assertTrue(viewModel.wifiApState.value.networkReady)
assertEquals(project, viewModel.currentProject.value)
verify(exactly = 0) { manager.disconnect() }
viewModel.startMeasure()
coVerify(exactly = 0) { packetRepository.startStreaming(any()) }
coVerify(exactly = 1) { packetRepository.connect(any()) }
coEvery { packetRepository.configureMeasurement(any()) } returns DctResult.Success(Unit)
viewModel.connectCommunication()
assertTrue(viewModel.uiState.value.isConnected)
assertFalse(viewModel.uiState.value.isCommunicationConnecting)
assertTrue(viewModel.uiState.value.statusMessage!!.contains("설정 완료"))
coVerify(exactly = 2) { packetRepository.connect(any()) }
coVerify(exactly = 2) { packetRepository.configureMeasurement(any()) }
coVerify(exactly = 0) { packetRepository.startStreaming(any()) }
verify(exactly = 0) { manager.disconnect() }
}
@Test fun disconnectCancelsInFlightHandshakeAndDoesNotApplyConfiguration() {
val (manager, _) = installWifiAp(readyWifiAp())
every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers {
arg<(Boolean, String) -> Unit>(3).invoke(false, "192.168.4.1")
}
var cancelled = false
coEvery { packetRepository.connect(any()) } coAnswers {
try { kotlinx.coroutines.awaitCancellation() } finally { cancelled = true }
}
viewModel.connectCommunication()
assertTrue(viewModel.uiState.value.isCommunicationConnecting)
viewModel.disconnectCommunication()
assertTrue(cancelled)
coVerify(exactly = 1) { packetRepository.disconnect() }
coVerify(exactly = 0) { packetRepository.configureMeasurement(any()) }
verify(exactly = 0) { manager.disconnect() }
}
@Test fun disconnectCancelsConfigurationAndProjectDidMaskReachesSnapshot() {
installWifiAp(readyWifiAp())
viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(
didInfo = com.dct.app.domain.model.project.DpcDidInfo(preSetting = 0xA5)))
connectionState.value = DeviceConnectionState.READY
var captured: com.dct.app.domain.model.protocol.DctMeasurementConfiguration? = null
var cancelled = false
coEvery { packetRepository.configureMeasurement(any()) } coAnswers {
captured = firstArg()
try { kotlinx.coroutines.awaitCancellation() } finally { cancelled = true }
}
var completed: Boolean? = null
viewModel.syncDeviceConfiguration { completed = it }
assertEquals(0xA5, captured!!.didPreSetting)
viewModel.disconnectCommunication(releaseWifiAp = true)
assertTrue(cancelled)
assertEquals(false, completed)
coVerify(exactly = 1) { packetRepository.disconnect() }
}
@Test fun changingOnlyTheWifiPortStartsNewTransportButExactTargetIsIdempotent() = runTest { @Test fun changingOnlyTheWifiPortStartsNewTransportButExactTargetIsIdempotent() = runTest {
val host = "192.168.1.100" val host = "192.168.1.100"
every { viewModel.wifiSocketManager.connectedHost } returns MutableStateFlow(host) every { viewModel.wifiSocketManager.connectedHost } returns MutableStateFlow(host)
@ -540,7 +919,9 @@ class MainViewModelTest {
calibrationViewModel = calibrationViewModel, calibrationViewModel = calibrationViewModel,
eventBus = eventBus, eventBus = eventBus,
dispatchers = dispatchers, dispatchers = dispatchers,
wifiSocketManager = mockk(relaxed = true), // Lifecycle tests must not launch real asynchronous TCP attempts. wifiSocketManager = mockk(relaxed = true) {
every { isConnected } returns MutableStateFlow(false)
}, // Lifecycle tests must not launch real asynchronous TCP attempts.
packetRepository = packetRepository, packetRepository = packetRepository,
ensureBackgroundExecution = { backgroundExecutionAllowed } ensureBackgroundExecution = { backgroundExecutionAllowed }
) )

419
Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt

@ -3,7 +3,11 @@ package com.dct.app.tests
import com.dct.app.core.common.DctResult import com.dct.app.core.common.DctResult
import com.dct.app.data.communication.WifiSocketManager import com.dct.app.data.communication.WifiSocketManager
import com.dct.app.data.protocol.DctProtocolRequests import com.dct.app.data.protocol.DctProtocolRequests
import com.dct.app.data.parser.A2lParser
import com.dct.app.data.repository.PacketRepositoryImpl import com.dct.app.data.repository.PacketRepositoryImpl
import com.dct.app.domain.model.a2l.A2lDataType
import com.dct.app.domain.model.mdb.MdbAttrType
import com.dct.app.domain.model.mdb.SignalDefinition
import com.dct.app.domain.model.protocol.* import com.dct.app.domain.model.protocol.*
import com.dct.app.domain.model.project.createDefaultSensorModules import com.dct.app.domain.model.project.createDefaultSensorModules
import com.dct.app.domain.repository.DeviceConnectionState import com.dct.app.domain.repository.DeviceConnectionState
@ -14,8 +18,90 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.* import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
class PacketRepositoryResponseTest { class PacketRepositoryResponseTest {
@ParameterizedTest
@CsvSource("true,false", "false,true")
fun xcpCalibrationFollowsConfiguredEndianAcrossProjectChanges(firstMsb: Boolean, secondMsb: Boolean) = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
for (msb in listOf(firstMsb, secondMsb)) {
assertTrue(f.repository.configureMeasurement(xcpConfiguration(msb)) is DctResult.Success)
assertXcpCalibrationPackets(f, msb)
}
}
@ParameterizedTest
@ValueSource(strings = ["rejected", "timeout", "malformed"])
fun xcpFailedIfDataDoesNotReplaceAppliedEndian(failure: String) = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
if (failure == "timeout") f.sessionId = 7L
assertTrue(f.repository.connect("") is DctResult.Success)
assertTrue(f.repository.sendXcpIfDatas(A2lParser.XcpIfData(isMsbFirst = true)) is DctResult.Success)
when (failure) {
"rejected" -> f.reject = 0x41
"timeout" -> f.silent = 0x41
"malformed" -> coEvery { f.wifi.sendCommand(match { it.command == 0x41.toByte() }) } coAnswers {
f.packets.emit(DctPacket(0xC1.toByte(), byteArrayOf(1, 99)))
true
}
}
assertTrue(f.repository.configureMeasurement(xcpConfiguration(false)) is DctResult.Failure)
if (failure == "timeout") {
assertFalse(f.connected.value)
f.sessionId = 8L
f.connected.value = true
f.silent = null
assertTrue(f.repository.connect("") is DctResult.Success)
}
assertXcpCalibrationPackets(f, true)
}
@Test fun xcpIfDataAckUpdatesEndianEvenWhenLaterSetupFails() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
f.reject = 0x42
assertTrue(f.repository.configureMeasurement(xcpConfiguration(true)) is DctResult.Failure)
assertXcpCalibrationPackets(f, true)
}
@Test fun xcpMissingIfDataUsesDefaultLittleEndianAfterMsbProject() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
assertTrue(f.repository.sendXcpIfDatas(A2lParser.XcpIfData(isMsbFirst = true)) is DctResult.Success)
assertTrue(f.repository.configureMeasurement(xcpConfiguration(true).copy(xcpIfData = null)) is DctResult.Success)
assertXcpCalibrationPackets(f, false)
}
private fun xcpConfiguration(msb: Boolean) = DctMeasurementConfiguration(
a2lSignals = listOf(SignalDefinition(1, 1, "Calibration", MdbAttrType.ATTR_2_UINT16,
1.0, 0.0, "", 0, 0.0, 65535.0, adl = 0x12345678, a2lDataType = A2lDataType.UWORD)),
xcpIfData = A2lParser.XcpIfData(isMsbFirst = msb)
)
private suspend fun assertXcpCalibrationPackets(f: Fixture, msb: Boolean) {
val valueBytes = if (msb) byteArrayOf(0x10, 0x20) else byteArrayOf(0x20, 0x10)
coEvery { f.wifi.sendCommand(match { it.command == 0x52.toByte() }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
f.packets.emit(DctPacket(0xD2.toByte(), byteArrayOf(1) + valueBytes))
true
}
assertTrue(f.repository.writeXcpCharacteristic(0x12345678, A2lDataType.UWORD, 0x1020.toDouble()) is DctResult.Success)
val read = f.repository.readXcpCharacteristic(0x12345678, A2lDataType.UWORD)
assertTrue(read is DctResult.Success)
assertArrayEquals(valueBytes, (read as DctResult.Success).data)
// PC Characteristic payloads: [type][value][address] / [type][address].
val expectedWrite = if (msb) byteArrayOf(2, 0x10, 0x20, 0x12, 0x34, 0x56, 0x78)
else byteArrayOf(2, 0x20, 0x10, 0x78, 0x56, 0x34, 0x12)
val expectedRead = if (msb) byteArrayOf(2, 0x12, 0x34, 0x56, 0x78)
else byteArrayOf(2, 0x78, 0x56, 0x34, 0x12)
assertArrayEquals(expectedWrite, f.sentPackets.last { it.command == 0x51.toByte() }.payload)
assertArrayEquals(expectedRead, f.sentPackets.last { it.command == 0x52.toByte() }.payload)
}
@Test fun responseBudgetIncludesSlowSend() = runTest { @Test fun responseBudgetIncludesSlowSend() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler)) val f = Fixture(UnconfinedTestDispatcher(testScheduler))
coEvery { f.wifi.sendCommand(any()) } coAnswers { kotlinx.coroutines.delay(1000); true } coEvery { f.wifi.sendCommand(any()) } coAnswers { kotlinx.coroutines.delay(1000); true }
@ -43,16 +129,27 @@ class PacketRepositoryResponseTest {
val wifi = mockk<WifiSocketManager>(relaxed = true) val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 8) val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 8)
val connected = MutableStateFlow(true) val connected = MutableStateFlow(true)
var sessionId = 0L
var reject: Byte? = null var reject: Byte? = null
var silent: Byte? = null
var versionPayload = byteArrayOf(1, 65) var versionPayload = byteArrayOf(1, 65)
val sentPackets = mutableListOf<DctPacket>() val sentPackets = mutableListOf<DctPacket>()
init { init {
every { wifi.isConnected } returns connected every { wifi.isConnected } returns connected
every { wifi.packetStream } returns packets every { wifi.packetStream } returns packets
every { wifi.currentSessionId } answers { sessionId }
every { wifi.invalidateConnectionIfSession(any()) } answers {
if (firstArg<Long>() != 0L && firstArg<Long>() == wifi.currentSessionId && connected.value) {
sessionId = 0L
connected.value = false
true
} else false
}
coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false } coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false }
coEvery { wifi.sendCommand(any()) } coAnswers { coEvery { wifi.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>() val request = firstArg<DctPacket>()
sentPackets += request sentPackets += request
if (request.command == silent) return@coAnswers true
val subCmd = request.payload.firstOrNull() ?: 1 val subCmd = request.payload.firstOrNull() ?: 1
val payload = when (request.command.toInt()) { val payload = when (request.command.toInt()) {
1 -> versionPayload 1 -> versionPayload
@ -69,6 +166,73 @@ class PacketRepositoryResponseTest {
val repository = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher, responseTimeoutMs = 50) val repository = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher, responseTimeoutMs = 50)
} }
@Test fun firstEnvironmentQueryWaits200msAfterVersionWriteCompletes() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
var versionWrittenAt = -1L
var firstEnvAt = -1L
coEvery { f.wifi.sendCommand(match { it.command == 1.toByte() }) } coAnswers {
kotlinx.coroutines.delay(75)
f.packets.emit(DctPacket(0x81.toByte(), byteArrayOf(1, 65)))
versionWrittenAt = testScheduler.currentTime
true
}
coEvery { f.wifi.sendCommand(match { it.command == 9.toByte() }) } coAnswers {
if (firstEnvAt < 0) firstEnvAt = testScheduler.currentTime
f.packets.emit(DctPacket(0x89.toByte(), byteArrayOf(firstArg<DctPacket>().payload[0], 1)))
true
}
assertTrue(f.repository.connect("") is DctResult.Success)
assertTrue(versionWrittenAt >= 0)
assertEquals(200L, firstEnvAt - versionWrittenAt)
}
@ParameterizedTest
@CsvSource("0,false", "0,true", "9,false", "9,true", "18,false", "18,true", "3,false", "3,true", "4,false", "4,true")
fun failedHandshakeClosesOnlyFailedSessionAndCanReconnect(command: Int, nack: Boolean) = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
every { f.wifi.currentSessionId } returns 7L
every { f.wifi.invalidateConnectionIfSession(7L) } answers { f.connected.value = false; true }
if (nack) f.reject = command.toByte() else f.silent = command.toByte()
if (nack && command == 3) {
// HWID has no status byte: its failure path is a truncated address.
coEvery { f.wifi.sendCommand(match { it.command == 3.toByte() }) } coAnswers {
f.packets.emit(DctPacket(0x83.toByte(), ByteArray(if (f.reject == 3.toByte()) 5 else 6)))
true
}
}
f.repository.setInitialMeasurementConfiguration(DctMeasurementConfiguration(sensorModules = createDefaultSensorModules()))
assertTrue(f.repository.connect("") is DctResult.Failure)
assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value)
assertFalse(f.repository.isConnected.value)
assertFalse(f.connected.value)
verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) }
assertFalse(f.sentPackets.any { it.command == 0x0F.toByte() || it.command == 0x20.toByte() })
// A new session must not inherit the failed session's response quarantine.
every { f.wifi.currentSessionId } returns 8L
f.connected.value = true
f.reject = null
f.silent = null
assertTrue(f.repository.connect("") is DctResult.Success)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(8L) }
}
@Test fun cancelledHandshakeDuringPcGapClosesItsSocket() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
every { f.wifi.currentSessionId } returns 7L
every { f.wifi.invalidateConnectionIfSession(7L) } answers { f.connected.value = false; true }
val connect = async { f.repository.connect("") }
advanceTimeBy(100)
connect.cancel()
runCurrent()
assertTrue(connect.isCancelled)
assertFalse(f.connected.value)
assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value)
verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) }
coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 9.toByte() }) }
}
@Test fun negativeEnableNeverReachesReady() = runTest { @Test fun negativeEnableNeverReachesReady() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler)) val f = Fixture(UnconfinedTestDispatcher(testScheduler))
f.reject = 0 f.reject = 0
@ -106,7 +270,7 @@ class PacketRepositoryResponseTest {
val env = f.sentPackets.filter { it.command == 0x09.toByte() } val env = f.sentPackets.filter { it.command == 0x09.toByte() }
assertTrue(env.any { it.payload.contentEquals(byteArrayOf(0x06, 0x11)) }) assertTrue(env.any { it.payload.contentEquals(byteArrayOf(0x06, 0x11)) })
val sm = f.sentPackets.first { it.command == 0x12.toByte() } val sm = f.sentPackets.first { it.command == 0x12.toByte() }
assertTrue(sm.payload.contentEquals(byteArrayOf(0x01, 0x01, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()))) assertTrue(sm.payload.contentEquals(byteArrayOf(0x01, 0x01, 0xFF.toByte(), 0x00, 0x00, 0x00)))
} }
@Test fun truncatedVersionStillStopsHandshakeBeforeHardwareId() = runTest { @Test fun truncatedVersionStillStopsHandshakeBeforeHardwareId() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler)) val f = Fixture(UnconfinedTestDispatcher(testScheduler))
@ -126,6 +290,75 @@ class PacketRepositoryResponseTest {
) )
assertTrue(f.repository.configureMeasurement(config) is DctResult.Failure) assertTrue(f.repository.configureMeasurement(config) is DctResult.Failure)
coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 0x10.toByte() }) } coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 0x10.toByte() }) }
assertTrue(f.connected.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
}
@ParameterizedTest
@ValueSource(ints = [8, 9, 18, 65])
fun configurationTimeoutClosesTcpEvenWithHeartbeatAndIgnoresLateAck(command: Int) = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
f.sessionId = 7L
assertTrue(f.repository.connect("") is DctResult.Success)
f.sentPackets.clear()
f.silent = command.toByte()
val configuration = if (command == 65) xcpConfiguration(false)
else DctMeasurementConfiguration(projectName = "TEST")
val setup = async { f.repository.configureMeasurement(configuration) }
runCurrent()
if (command == 9 || command == 18) {
advanceTimeBy(120) // CAN-to-DID 100ms plus DID-to-environment 20ms.
runCurrent()
}
assertEquals(command, f.sentPackets.last().command.toInt() and 255)
f.packets.emit(DctPacket(0x85.toByte(), byteArrayOf(1), transportSessionId = 7L))
advanceTimeBy(49)
assertFalse(setup.isCompleted)
assertTrue(f.connected.value)
advanceTimeBy(1)
runCurrent()
val result = setup.await()
assertTrue(result is DctResult.Failure && result.error is com.dct.app.core.error.DctError.ProtocolError.ResponseTimeout)
assertFalse(f.connected.value)
assertFalse(f.repository.isConnected.value)
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value)
verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) }
val sentAtTimeout = f.sentPackets.size
f.packets.emit(DctPacket((command + 0x80).toByte(), byteArrayOf(0, 1), transportSessionId = 7L))
assertEquals(sentAtTimeout, f.sentPackets.size)
assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value)
assertTrue(f.repository.startStreaming() is DctResult.Failure)
assertFalse(f.sentPackets.any { it.command == 0x20.toByte() })
f.sessionId = 8L
f.connected.value = true
f.silent = null
assertTrue(f.repository.connect("") is DctResult.Success)
assertTrue(f.repository.configureMeasurement(configuration) is DctResult.Success)
f.packets.emit(DctPacket((command + 0x80).toByte(), byteArrayOf(0, 1), transportSessionId = 7L))
assertTrue(f.connected.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(8L) }
}
@Test fun oldConfigurationTimeoutDoesNotCloseNewerTcpSession() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
f.sessionId = 7L
assertTrue(f.repository.connect("") is DctResult.Success)
f.silent = 8
val setup = async { f.repository.configureMeasurement(DctMeasurementConfiguration(projectName = "TEST")) }
runCurrent()
f.sessionId = 8L
advanceTimeBy(50)
runCurrent()
assertTrue(setup.await() is DctResult.Failure)
verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) }
assertTrue(f.connected.value)
assertTrue(f.repository.isConnected.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(8L) }
} }
@Test fun timeoutKeepsTransportAndBlocksAmbiguousRetryUntilLateAck() = runTest { @Test fun timeoutKeepsTransportAndBlocksAmbiguousRetryUntilLateAck() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler)) val f = Fixture(UnconfinedTestDispatcher(testScheduler))
@ -143,15 +376,195 @@ class PacketRepositoryResponseTest {
} }
assertTrue(f.repository.sendCommand(command) is DctResult.Success) assertTrue(f.repository.sendCommand(command) is DctResult.Success)
} }
@Test fun startTimeoutKeepsTransportAliveForRetry() = runTest { @ParameterizedTest
@ValueSource(booleans = [false, true])
fun ambiguousStartTimeoutKeepsTcpUntilExplicitDisconnect(stopAckLost: Boolean) = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler)) val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success) assertTrue(f.repository.connect("") is DctResult.Success)
coEvery { f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) } returns true coEvery { f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
true
}
coEvery { f.wifi.sendCommand(match { it.command == 0x21.toByte() }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
// A late successful START must not revive the run during rollback.
f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1)))
if (!stopAckLost) f.packets.emit(DctPacket(0xA1.toByte(), byteArrayOf(1)))
true
}
assertTrue(f.repository.startStreaming(30) is DctResult.Failure) assertTrue(f.repository.startStreaming(30) is DctResult.Failure)
assertTrue(f.connected.value)
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
assertFalse(f.sentPackets.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() })
coVerify(exactly = 0) { f.wifi.disconnectSuspend() } coVerify(exactly = 0) { f.wifi.disconnectSuspend() }
f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1)))
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
// Only explicit Disconnect rolls back the uncertain START and closes TCP.
assertTrue(f.repository.disconnect() is DctResult.Success)
assertEquals(listOf(0x20, 0x21, 0x0F), f.sentPackets.takeLast(3).map { it.command.toInt() and 255 })
coVerify(exactly = 1) { f.wifi.disconnectSuspend() }
assertFalse(f.connected.value)
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.DISCONNECTED, f.repository.connectionState.value)
f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1)))
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.DISCONNECTED, f.repository.connectionState.value)
}
@Test fun cancelledStartStillSendsStopBeforeDisableOnDisconnect() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
true
}
val start = async { f.repository.startStreaming(30) }
runCurrent()
start.cancel()
start.join()
assertTrue(f.repository.disconnect() is DctResult.Success)
assertEquals(listOf(0x20, 0x21, 0x0F), f.sentPackets.takeLast(3).map { it.command.toInt() and 255 })
assertFalse(f.connected.value)
}
@ParameterizedTest
@ValueSource(longs = [0L, 75L])
fun pcPostCanSetupSendsDidWithoutWaitingForAckThenRefreshesEnvironment(writeDelayMs: Long) = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
f.sentPackets.clear()
var didWrittenAt = -1L
var didStartedAt = -1L
var firstEnvAt = -1L
coEvery { f.wifi.sendCommand(match { it.command == 0x17.toByte() }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
didStartedAt = testScheduler.currentTime
kotlinx.coroutines.delay(writeDelayMs)
didWrittenAt = testScheduler.currentTime
true // PC does not gate setup completion on a 0x97 response.
}
coEvery { f.wifi.sendCommand(match { it.command == 9.toByte() && it.payload.firstOrNull() == 0.toByte() }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
firstEnvAt = testScheduler.currentTime
f.packets.emit(DctPacket(0x89.toByte(), byteArrayOf(0, 1)))
true
}
val before = testScheduler.currentTime
assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration(
didPreSetting = 0x31, isHighSpeedCan = false, sensorModules = createDefaultSensorModules()
)) is DctResult.Success)
assertEquals(listOf(0x40, 0x11, 0x17, 0x09, 0x09, 0x09, 0x12),
f.sentPackets.map { it.command.toInt() and 255 })
assertEquals(100L, didStartedAt - before)
assertEquals(writeDelayMs, didWrittenAt - didStartedAt)
assertEquals(20L, firstEnvAt - didWrittenAt)
assertArrayEquals(byteArrayOf(0x31), f.sentPackets[2].payload)
assertEquals(listOf(0, 3, 6), f.sentPackets.filter { it.command == 9.toByte() }
.map { it.payload.first().toInt() })
assertArrayEquals(byteArrayOf(6, 0), f.sentPackets.last { it.command == 9.toByte() }.payload)
}
@Test fun cancellingDidEnvironmentGapDoesNotSendTheNextRequest() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
f.sentPackets.clear()
val setup = async { f.repository.configureMeasurement(DctMeasurementConfiguration()) }
advanceTimeBy(119)
runCurrent()
assertEquals(listOf(0x40, 0x11, 0x17), f.sentPackets.map { it.command.toInt() and 255 })
setup.cancel()
runCurrent()
advanceTimeBy(50)
runCurrent()
assertTrue(setup.isCancelled)
assertFalse(f.sentPackets.any { it.command == 9.toByte() || it.command == 0x20.toByte() })
}
@Test fun rejectedCanRegistrationDoesNotSendDidOrPostSetup() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
f.sentPackets.clear()
f.reject = 0x11
assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure)
assertEquals(listOf(0x40, 0x11), f.sentPackets.map { it.command.toInt() and 255 })
}
@Test fun didWriteFailureStopsTheSetupBeforeEnvironmentRefresh() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
f.sentPackets.clear()
coEvery { f.wifi.sendCommand(match { it.command == 0x17.toByte() }) } returns false
assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure)
assertEquals(listOf(0x40, 0x11), f.sentPackets.map { it.command.toInt() and 255 })
}
@ParameterizedTest
@ValueSource(ints = [0, 49, 255])
fun didPresetMaskIsSerializedWithoutOverwritingProjectIntent(mask: Int) = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration(didPreSetting = mask)) is DctResult.Success)
assertArrayEquals(byteArrayOf(mask.toByte()), f.sentPackets.single { it.command == 0x17.toByte() }.payload)
}
@Test fun explicitStartRejectionAndMalformedResponseKeepTcpWithoutPretendingToMeasure() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
assertTrue(f.repository.connect("") is DctResult.Success)
coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers {
f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(2, 1)))
true
}
assertTrue(f.repository.startStreaming(30) is DctResult.Failure)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
assertTrue(f.connected.value) assertTrue(f.connected.value)
coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers {
f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1)))
true
}
assertTrue(f.repository.startStreaming(30) is DctResult.Failure)
assertTrue(f.connected.value)
assertTrue(f.repository.isConnected.value)
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
assertFalse(f.sentPackets.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() })
coVerify(exactly = 0) { f.wifi.disconnectSuspend() }
}
@Test fun configurationTimeoutAfterPlaybackKeepsTcpUntilExplicitDisconnectAndNewSessionResetsPolicy() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
f.sessionId = 7L
assertTrue(f.repository.connect("") is DctResult.Success)
coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers {
f.sentPackets += firstArg<DctPacket>()
f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1)))
true
}
assertTrue(f.repository.startStreaming() is DctResult.Success)
assertTrue(f.repository.stopStreaming() is DctResult.Success)
f.silent = 9
assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
assertTrue(f.connected.value)
verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(any()) }
assertTrue(f.repository.disconnect() is DctResult.Success)
assertFalse(f.connected.value)
coVerify(exactly = 1) { f.wifi.disconnectSuspend() }
assertEquals(0x0F.toByte(), f.sentPackets.last().command)
f.sessionId = 8L
f.connected.value = true
f.silent = null
assertTrue(f.repository.connect("") is DctResult.Success)
f.silent = 9
assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure)
assertFalse(f.connected.value)
assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value)
verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(8L) }
} }
@Test fun stopTimeoutEndsLocalMeasurementButKeepsTransportAlive() = runTest { @Test fun stopTimeoutEndsLocalMeasurementButKeepsTransportAlive() = runTest {

22
Android/app/src/test/java/com/dct/app/tests/RecordingElapsedTimeTest.kt

@ -0,0 +1,22 @@
package com.dct.app.tests
import com.dct.app.presentation.monitor.components.formatRecordingElapsedTime
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class RecordingElapsedTimeTest {
@ParameterizedTest
@CsvSource(
"1000,1000,00:00:00",
"1000,1999,00:00:00",
"1000,2000,00:00:01",
"1000,61000,00:01:00",
"1000,3601000,01:00:00",
"1000,90062000,25:01:01",
"2000,1000,00:00:00"
)
fun durationUsesRecordingStartAndDoesNotWrapAtOneDay(start: Long, now: Long, expected: String) {
assertEquals(expected, formatRecordingElapsedTime(start, now))
}
}

193
Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt

@ -216,4 +216,197 @@ class SlidingRingBufferParserTest {
assertThat(result).hasSize(1) assertThat(result).hasSize(1)
assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code) assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code)
} }
private var nowNanos = 0L
private val recoveries = mutableListOf<SlidingRingBufferParser.FrameRecovery>()
private fun timedParser(capacity: Int = SlidingRingBufferParser.DEFAULT_BUFFER_CAPACITY): SlidingRingBufferParser =
SlidingRingBufferParser(bufferCapacity = capacity, clockNanos = { nowNanos }, onFrameRecovery = { recoveries.add(it) })
private fun control(command: DctCommand, payload: ByteArray = byteArrayOf()): ByteArray =
codec.encode(DctPacket.create(command, payload))
private fun stalledHeader(): ByteArray = byteArrayOf(0xA1.toByte(), 0x7F, 0x00, 0xA3.toByte(), 0x12, 0x34)
@Test
fun staleLargeLengthDoesNotBlockHeartbeatOrStopAfterIdle() {
for (command in listOf(DctCommand.RES_POSSIBLE_TO_CONNECT, DctCommand.RES_STOP_TRANSMIT_DATA,
DctCommand.RES_START_TRANSMIT_DATA)) {
for (payload in if (command == DctCommand.RES_START_TRANSMIT_DATA) listOf(byteArrayOf(1, 1))
else listOf(byteArrayOf(), byteArrayOf(1))) {
parser = timedParser()
assertThat(parser.parseStream(stalledHeader())).isEmpty()
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
val result = parser.parseStream(control(command, payload))
assertThat(result.map { it.command }).containsExactly(command.code)
assertThat(result.single().payload).isEqualTo(payload)
assertThat(parser.availableByteCount).isEqualTo(0)
assertThat(recoveries.last().expectedBytes).isEqualTo(0x7F00 + 5)
assertThat(recoveries.last().discardedBytes).isEqualTo(stalledHeader().size)
assertThat(recoveries.last().idleMillis).isEqualTo(2000)
assertThat(recoveries.last().blockedHeader).isEqualTo(stalledHeader())
}
}
}
@Test
fun recoveryAcceptsEveryControlSplitAndCoalescedFollowingDataInOrder() {
val stop = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1))
val heartbeat = control(DctCommand.RES_POSSIBLE_TO_CONNECT)
val data = control(DctCommand.SEND_CAN_FRAME, byteArrayOf(0x7E, 0xA1.toByte(), 3))
for (split in 1 until stop.size) {
parser = timedParser()
parser.parseStream(stalledHeader())
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
assertThat(parser.parseStream(stop.copyOfRange(0, split))).isEmpty()
val result = parser.parseStream(stop.copyOfRange(split, stop.size) + heartbeat + data)
assertThat(result.map { it.command }).containsExactly(
DctCommand.RES_STOP_TRANSMIT_DATA.code, DctCommand.RES_POSSIBLE_TO_CONNECT.code,
DctCommand.SEND_CAN_FRAME.code).inOrder()
assertThat(result.map { it.sequence }).containsExactly(0, 1, 2).inOrder()
}
}
@Test
fun corruptAndWrongLengthControlsCannotTriggerRecovery() {
parser = timedParser()
parser.parseStream(stalledHeader())
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
val corrupt = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)).also {
it[it.lastIndex - 1] = (it[it.lastIndex - 1].toInt() xor 1).toByte()
}
val invalidCandidates = corrupt + control(DctCommand.RES_POSSIBLE_TO_CONNECT, byteArrayOf(1, 2)) +
control(DctCommand.RES_START_TRANSMIT_DATA, byteArrayOf(1)) +
control(DctCommand.SEND_CAN_FRAME, byteArrayOf(1))
assertThat(parser.parseStream(invalidCandidates)).isEmpty()
assertThat(recoveries).isEmpty()
val result = parser.parseStream(control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)))
assertThat(result.single().command).isEqualTo(DctCommand.RES_STOP_TRANSMIT_DATA.code)
assertThat(recoveries.single().discardedBytes).isEqualTo(stalledHeader().size + invalidCandidates.size)
}
@Test
fun embeddedValidControlsInActiveFragmentedPayloadAreNeverScanned() {
parser = timedParser()
val payload = byteArrayOf(5, 6) + control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)) +
control(DctCommand.RES_POSSIBLE_TO_CONNECT) + ByteArray(6000) { 0x7E }
val frame = control(DctCommand.SEND_CAN_FRAME, payload)
val result = mutableListOf<DctPacket>()
frame.forEach { byte ->
nowNanos += 1_000_000L // Total assembly >2s, but ingress never stalls.
result.addAll(parser.parseStream(byteArrayOf(byte)))
}
assertThat(result.single().payload).isEqualTo(payload)
assertThat(recoveries).isEmpty()
}
@Test
fun preIdleEmbeddedControlsAreNotReinterpretedAfterGap() {
parser = timedParser()
val embedded = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1))
val payload = embedded + ByteArray(100) { 0x33 }
val frame = control(DctCommand.SEND_CAN_FRAME, payload)
val split = 4 + embedded.size
parser.parseStream(frame.copyOfRange(0, split))
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
assertThat(parser.parseStream(frame.copyOfRange(split, split + 10))).isEmpty()
assertThat(parser.parseStream(frame.copyOfRange(split + 10, frame.size)).single().payload).isEqualTo(payload)
assertThat(recoveries).isEmpty()
}
@Test
fun completedOriginalFrameWinsEvenWithPostIdleEmbeddedControl() {
parser = timedParser()
val payload = ByteArray(20) { 3 } + control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1))
val frame = control(DctCommand.SEND_CAN_FRAME, payload)
parser.parseStream(frame.copyOfRange(0, 24))
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
assertThat(parser.parseStream(frame.copyOfRange(24, frame.size)).single().payload).isEqualTo(payload)
assertThat(recoveries).isEmpty()
}
@Test
fun idleGapAloneDoesNotDiscardMaximumFrame() {
parser = timedParser()
val payload = ByteArray(DctPacketCodec.MAX_PAYLOAD_LENGTH) { (it % 251).toByte() }
val frame = control(DctCommand.SEND_CAN_FRAME, payload)
parser.parseStream(frame.copyOfRange(0, 100))
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS * 10
assertThat(parser.parseStream(frame.copyOfRange(100, 200))).isEmpty()
assertThat(parser.availableByteCount).isEqualTo(200)
assertThat(parser.parseStream(frame.copyOfRange(200, frame.size)).single().payload).isEqualTo(payload)
assertThat(recoveries).isEmpty()
}
@Test
fun idleThresholdIsNotRoundedUpAndEmptyCallsDoNotCountAsIngress() {
parser = timedParser()
parser.parseStream(stalledHeader())
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS - 1
assertThat(parser.parseStream(control(DctCommand.RES_POSSIBLE_TO_CONNECT))).isEmpty()
assertThat(recoveries).isEmpty()
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
parser.parseStream(byteArrayOf())
assertThat(parser.parseStream(control(DctCommand.RES_STOP_TRANSMIT_DATA)).single().command)
.isEqualTo(DctCommand.RES_STOP_TRANSMIT_DATA.code)
}
@Test
fun recoveryAcrossRingWrapKeepsFollowingFramesAndResetClearsOldBoundary() {
parser = timedParser(capacity = 64)
parser.parseStream(ByteArray(59)) // Put the stale header across the ring end.
val shortStall = byteArrayOf(0xA1.toByte(), 0, 55, 0xA3.toByte(), 2, 3)
parser.parseStream(shortStall)
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
val heartbeat = control(DctCommand.RES_POSSIBLE_TO_CONNECT)
assertThat(parser.parseStream(heartbeat + heartbeat)).hasSize(2)
assertThat(recoveries).hasSize(1)
parser.parseStream(shortStall)
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
parser.parseStream(heartbeat.copyOfRange(0, 3))
parser.reset()
val payload = heartbeat + ByteArray(30) { 4 }
val frame = control(DctCommand.SEND_CAN_FRAME, payload)
assertThat(parser.parseStream(frame.copyOfRange(0, 4 + heartbeat.size))).isEmpty()
assertThat(parser.parseStream(frame.copyOfRange(4 + heartbeat.size, frame.size)).single().payload).isEqualTo(payload)
assertThat(recoveries).hasSize(1)
}
@Test
fun fragmentedRecoveryControlCanStraddleTheRingEnd() {
parser = timedParser(capacity = 64)
parser.parseStream(ByteArray(53))
parser.parseStream(byteArrayOf(0xA1.toByte(), 0, 55, 0xA3.toByte(), 2, 3))
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
val stop = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1))
assertThat(parser.parseStream(stop.copyOfRange(0, 5))).isEmpty()
assertThat(parser.parseStream(stop.copyOfRange(5, stop.size)).single().command)
.isEqualTo(DctCommand.RES_STOP_TRANSMIT_DATA.code)
assertThat(recoveries).hasSize(1)
}
@Test
fun recoveryDoesNotRevalidateTheEntireAccumulatedBufferOnEachRead() {
val delegate = com.dct.app.data.protocol.DefaultChecksumCalculator()
var verifications = 0
val countingChecksum = object : com.dct.app.data.protocol.ChecksumCalculator {
override fun calculate(data: ByteArray, offset: Int, length: Int): Byte = delegate.calculate(data, offset, length)
override fun verify(packet: ByteArray, offset: Int, length: Int): Boolean {
verifications++
return delegate.verify(packet, offset, length)
}
}
parser = SlidingRingBufferParser(codec = DctPacketCodec(countingChecksum), clockNanos = { nowNanos })
parser.parseStream(stalledHeader())
nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS
val corrupt = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)).also {
it[it.lastIndex - 1] = (it[it.lastIndex - 1].toInt() xor 1).toByte()
}
val candidates = ByteArrayOutputStream().apply { repeat(100) { write(corrupt) } }.toByteArray()
assertThat(parser.parseStream(candidates)).isEmpty()
assertThat(verifications).isEqualTo(100)
repeat(1000) { assertThat(parser.parseStream(byteArrayOf(0))).isEmpty() }
assertThat(verifications).isAtMost(102)
assertThat(parser.parseStream(control(DctCommand.RES_STOP_TRANSMIT_DATA))).hasSize(1)
}
} }

27
Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt

@ -7,6 +7,33 @@ import org.junit.jupiter.api.Test
import java.net.Socket import java.net.Socket
class WifiConnectionGenerationTest { class WifiConnectionGenerationTest {
@Test fun livenessTimeoutClosesOnlyItsOwnSession() {
val manager = WifiSocketManager()
val socket = Socket() // No network activity.
WifiSocketManager::class.java.getDeclaredField("activeSocket").also { it.isAccessible = true }
.set(manager, socket)
WifiSocketManager::class.java.getDeclaredField("currentSessionId").also { it.isAccessible = true }
.setLong(manager, 7L)
@Suppress("UNCHECKED_CAST")
val connected = WifiSocketManager::class.java.getDeclaredField("_isConnected")
.also { it.isAccessible = true }.get(manager) as kotlinx.coroutines.flow.MutableStateFlow<Boolean>
connected.value = true
try {
assertFalse(manager.invalidateConnectionIfSession(6L))
assertFalse(manager.invalidateConnectionIfSession(0L))
assertTrue(manager.isConnected.value)
assertFalse(socket.isClosed)
assertEquals(7L, manager.currentSessionId)
assertTrue(manager.invalidateConnectionIfSession(7L))
assertTrue(socket.isClosed)
assertFalse(manager.isConnected.value)
assertEquals(0L, manager.currentSessionId)
assertFalse(manager.invalidateConnectionIfSession(7L))
} finally {
manager.invalidateConnection()
}
}
private fun next(manager: WifiSocketManager): Long = WifiSocketManager::class.java private fun next(manager: WifiSocketManager): Long = WifiSocketManager::class.java
.getDeclaredMethod("nextConnectionGeneration").also { it.isAccessible = true }.invoke(manager) as Long .getDeclaredMethod("nextConnectionGeneration").also { it.isAccessible = true }.invoke(manager) as Long
private fun attach(manager: WifiSocketManager, socket: Socket, generation: Long): Boolean = WifiSocketManager::class.java private fun attach(manager: WifiSocketManager, socket: Socket, generation: Long): Boolean = WifiSocketManager::class.java

Loading…
Cancel
Save