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. 3
      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. 196
      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. 98
      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 dismissed = false
private var disconnected = 0
private var retried = 0
private var current = 0
private var scanned = 0
private var settingsAction: String? = null
@ -30,6 +29,7 @@ class WifiApConnectionUiTest {
private val endpointEditable = mutableStateOf(true)
private val communicationReady = mutableStateOf(false)
private val socketConnected = mutableStateOf(false)
private val communicationFailed = mutableStateOf(false)
private val connectedEndpoint = mutableStateOf<WifiApEndpoint?>(null)
private var savedEndpoint: WifiApEndpoint? = null
@Test fun installedAppHasPermissionToRequestTheEquipmentNetwork() {
@ -41,13 +41,28 @@ class WifiApConnectionUiTest {
compose.setContent { MaterialTheme {
WifiApConnectionContent(state.value, communicationReady.value, socketConnected.value,
onScan = { scanned++ }, onConnect = { ap, password -> connected = ap to password },
onCancel = { cancelled++ }, onDisconnect = { disconnected++ }, onRetry = { retried++ },
onCancel = { cancelled++ }, onDisconnect = { disconnected++ },
onCurrent = { current++ }, onPermissions = {}, onSettings = { settingsAction = it },
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 })
} }
}
@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() {
show()
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist()
@ -110,7 +125,7 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist()
}
@Test fun connectedRowOpensDetailsForRetryAndDisconnect() {
@Test fun connectedRowOpensDetailsForWifiDisconnectOnly() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED,
selectedSsid = device.ssid, localAddress = "192.168.4.20")
show()
@ -118,9 +133,8 @@ class WifiApConnectionUiTest {
compose.onNodeWithText("연결 해제").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed()
compose.onNodeWithText("통신 재연결").performClick()
compose.runOnIdle { assertEquals(1, retried); assertEquals(0, disconnected) }
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("통신 재연결").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, disconnected) }
compose.onNodeWithText("연결 해제").performClick()
compose.runOnIdle { assertEquals(1, disconnected) }
}
@ -138,7 +152,7 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.onNodeWithText("연결 해제").assertDoesNotExist()
compose.onNodeWithText("확인").performClick()
compose.runOnIdle { assertEquals(0, current); assertEquals(0, retried); assertNull(connected) }
compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
}
@Test fun managedNetworkIsShownOnlyOnceAndOtherConnectedWifiRemainsVisible() {
@ -150,17 +164,17 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed()
compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist()
compose.onNodeWithTag("wifi:connected:102").assertIsDisplayed()
compose.onNodeWithText("통신 연결 대기").assertIsDisplayed()
compose.onAllNodesWithText("Wi-Fi 연결됨").assertCountEquals(2)
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)
state.value = state.value.copy(connectedNetworks = listOf(
ConnectedWifiNetwork("101", device.ssid, "192.168.1.20")))
show()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:connected:101").performClick()
compose.onNode(hasText("접속 설정: 192.168.1.100:6000") and
hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertIsDisplayed()
@ -169,21 +183,20 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
compose.onNodeWithText("확인").performClick()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled().performClick()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle {
assertEquals(1, current); assertEquals(0, retried); assertEquals(0, disconnected)
assertEquals(0, current); assertEquals(0, disconnected)
assertFalse(dismissed)
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 base = state.value.copy(connectedNetworks = listOf(network))
state.value = base
show()
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
listOf(base.copy(permissionGranted = false), base.copy(enabled = false),
base.copy(phase = WifiApPhase.CONNECTING),
base.copy(connectedNetworks = emptyList()),
@ -191,18 +204,18 @@ class WifiApConnectionUiTest {
base.copy(connectedNetworks = listOf(network, ConnectedWifiNetwork("102", "Other", "192.168.2.20"))))
.forEach { unavailable ->
compose.runOnIdle { state.value = unavailable }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
}
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.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
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.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { endpoint.value = WifiApEndpoint() }
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled()
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
}
@ -274,16 +287,53 @@ class WifiApConnectionUiTest {
show()
compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31)
compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled()
val connectBounds = compose.onNodeWithTag("wifi:connected-connect").fetchSemanticsNode().boundsInRoot
val closeBounds = compose.onNodeWithText("닫기").fetchSemanticsNode().boundsInRoot
assertTrue(closeBounds.left >= connectBounds.right)
assertEquals(connectBounds.center.y, closeBounds.center.y, 1f)
compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist()
compose.onNodeWithContentDescription("새로고침").assertIsDisplayed().performClick()
compose.onNodeWithText("닫기").assertIsDisplayed().performClick()
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() {
show()
compose.onNodeWithText("현재 Wi-Fi로 연결").assertDoesNotExist()
@ -296,15 +346,18 @@ class WifiApConnectionUiTest {
show()
compose.onNodeWithContentDescription("더보기").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-port").performTextReplacement("65536")
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-save").performClick()
compose.runOnIdle {
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint)
assertEquals(0, current); assertEquals(0, retried)
assertEquals(0, current)
}
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("연결 정보").performClick()
@ -315,26 +368,28 @@ class WifiApConnectionUiTest {
compose.runOnIdle { assertEquals(WifiApEndpoint(), savedEndpoint) }
}
@Test fun savingAndConnectingUsesNewTargetForCurrentWifiAndConnectedAp() {
@Test fun savingTargetNeverConnectsForCurrentWifiOrConnectedAp() {
show()
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100")
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000")
compose.onNodeWithTag("wifi:tcp-connect").performClick()
compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:tcp-save").performClick()
compose.runOnIdle {
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint)
assertEquals(1, current)
assertEquals(0, current)
state.value = state.value.copy(phase = WifiApPhase.CONNECTED,
selectedSsid = "PC LAN", localAddress = "192.168.1.20")
}
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("7000")
compose.onNodeWithTag("wifi:tcp-connect").performClick()
compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:tcp-save").performClick()
compose.runOnIdle {
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.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
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.toPixelMap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
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.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicLong
class RecordingRibbonButtonUiTest {
@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 {
val pixels = image.toPixelMap()
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. */
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() {
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -49,6 +50,10 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
onFailure = { Log.e("PcOperationalLogger", "운영 로그 저장 실패 (통신·계측은 유지)", it) }
)
private var observing = false
var isClosed = false
private set
var programExitRequested = false
private set
private var uiVisible = false
@Volatile private var serviceRequested = 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. */
@Synchronized
fun ensureForegroundService(): Boolean {
if (isClosed) return false
// A running service may already have queued stopSelf(). Deliver a fresh startId
// for a new connection instead of relying on a stale "running" flag.
if (serviceRequested && !serviceRunning.value) return true
@ -106,6 +112,7 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
}
fun onUiStarted() {
if (isClosed) return
uiVisible = true
viewModel.setApplicationVisible(true)
if (!observing) {
@ -123,4 +130,16 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
fun onUiStopped() { uiVisible = false; viewModel.setApplicationVisible(false) }
fun onServiceStarted() { serviceRequested = true; _serviceRunning.value = true }
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) {
Log.e(TAG, "Foreground promotion failed", error)
session.viewModel.setStatusMessage("백그라운드 계측 서비스를 시작하지 못했습니다: ${error.message}")
session.viewModel.disconnectCommunication()
session.viewModel.disconnectCommunication(releaseWifiAp = true)
intentionalStop = true
stopSelf()
return
@ -113,7 +113,8 @@ class MeasurementForegroundService : Service() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
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()
}
super.onDestroy()

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

@ -60,7 +60,13 @@ class WifiSocketManager(
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)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -444,6 +450,14 @@ class WifiSocketManager(
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
private fun disconnectActiveSocketOnly() {
if (activeSocket != null) Log.i("WifiSocketManager", "TCP local cleanup: session=$currentSessionId",

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

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

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 데이터 처리: 길이 검증을 통해 페이로드 데이터 바이트와 실제 푸터를 정확히 구분.
* 6. 체크섬 오류 패킷 안전 폐기 및 다음 패킷 복구.
* 7. 링 버퍼 오버플로우 방지 및 자동 복구.
* 8. Thread-Safe: 모든 주요 연산에 synchronized 적용.
* 8. 2초 수신 공백 뒤 검증된 제어 프레임으로 미완성 선두 프레임 복구.
* 9. Thread-Safe: 모든 주요 연산에 synchronized 적용.
*/
class SlidingRingBufferParser(
private val bufferCapacity: Int = DEFAULT_BUFFER_CAPACITY,
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 {
companion object {
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 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 scratchFrameBuffer = ByteArray(maxPacketSize)
private var head = 0
private var tail = 0
private var availableBytes = 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
override fun parseStream(buffer: ByteArray, length: Int): List<DctPacket> {
@ -40,6 +58,17 @@ class SlidingRingBufferParser(
if (length <= 0) return result
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.
var offset = 0
while (offset < length) {
@ -111,7 +140,8 @@ class SlidingRingBufferParser(
// 2.4 전체 프레임 수신 여부 확인
if (availableBytes < expectedTotalLength) {
// 프레임 전체가 아직 도착하지 않았으므로 추가 데이터 수신 대기
if (recoverStalledFrame(expectedTotalLength)) continue
// No timeout-only discard: ordinary fragments remain buffered.
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
override fun pollPacket(): DctPacket? = null
@ -155,6 +221,11 @@ class SlidingRingBufferParser(
tail = 0
availableBytes = 0
sequenceCounter = 0
lastInputNanos = 0L
hasReceivedInput = false
recoveryBoundary = -1
recoveryScanOffset = 0
recoveryIdleNanos = 0L
}
val availableByteCount: Int
@ -196,10 +267,11 @@ class SlidingRingBufferParser(
return ringBuffer[index]
}
private fun peekBytes(target: ByteArray, targetOffset: Int, count: Int) {
check(count <= availableBytes) { "Cannot peek $count bytes, only $availableBytes available" }
val firstChunk = (bufferCapacity - tail).coerceAtMost(count)
System.arraycopy(ringBuffer, tail, target, targetOffset, firstChunk)
private fun peekBytes(target: ByteArray, targetOffset: Int, count: Int, sourceOffset: Int = 0) {
check(sourceOffset + count <= availableBytes) { "Cannot peek $count bytes at $sourceOffset, only $availableBytes available" }
val start = (tail + sourceOffset) % bufferCapacity
val firstChunk = (bufferCapacity - start).coerceAtMost(count)
System.arraycopy(ringBuffer, start, target, targetOffset, firstChunk)
if (count > firstChunk) {
val secondChunk = count - firstChunk
System.arraycopy(ringBuffer, 0, target, targetOffset + firstChunk, secondChunk)
@ -210,5 +282,14 @@ class SlidingRingBufferParser(
val toSkip = count.coerceAtMost(availableBytes)
tail = (tail + toSkip) % bufferCapacity
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 heartbeatFailureThreshold: Int = DEFAULT_HEARTBEAT_FAILURE_THRESHOLD,
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 {
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
private val requestMutex = Mutex()
@ -57,15 +58,21 @@ class PacketRepositoryImpl(
private val responseQuarantineSession = ConcurrentHashMap<Byte, Long>()
private var heartbeatJob: Job? = null
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
* allowing an unbounded heap backlog. A full queue applies backpressure to
* the reader; already received packets are never silently discarded.
*/
private val packetDispatchQueue = Channel<DctPacket>(dispatchQueueCapacity.coerceAtLeast(1))
@Volatile
private var xcpIsMsbFirst = false
@Volatile
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)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -118,12 +125,31 @@ class PacketRepositoryImpl(
}
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
// each Wi-Fi callback, so a failed/stale session cannot leak its settings
// into a later reconnect.
val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null }
transition(DeviceConnectionState.CONNECTING)
_isStreaming.value = false
startMayHaveReachedDevice = false
playbackTransportSessionId = null
if (!hasLiveTransport()) {
transition(DeviceConnectionState.ERROR)
return@withContext failure("Wi-Fi TCP 전송 계층이 연결되지 않았습니다.")
@ -144,10 +170,11 @@ class PacketRepositoryImpl(
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) {
transition(DeviceConnectionState.ERROR)
return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 연결은 유지됩니다.")
return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 다시 연결하세요.")
}
val versionDeferred = CompletableDeferred<DctPacket>()
pendingResponses[DctCommand.RES_VERSION.code] = versionDeferred
@ -160,6 +187,11 @@ class PacketRepositoryImpl(
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 없이
// 보낼 수 있는 조회가 아니므로, 프로젝트 스냅샷이 있을 때만
// PC의 완전한 CAN 모드/SM 설정 payload를 추가합니다.
@ -184,9 +216,8 @@ class PacketRepositoryImpl(
try {
com.dct.app.data.protocol.DctResponseValidator.validateSetup(query, res.data)
} catch (e: IllegalArgumentException) {
// A malformed/NACK setup response must fail this handshake, but
// it is not a transport error. Keep the TCP/Wi-Fi session alive so
// a caller can inspect logs or retry without a second P2P pairing.
// connect() closes the failed TCP session without releasing
// the Wi-Fi AP, so a clean retry does not require pairing.
transition(DeviceConnectionState.ERROR)
return@withContext failure("연결 초기화 ${DctCommand.toHexString(query.command)}: ${e.message}")
}
@ -246,13 +277,15 @@ class PacketRepositoryImpl(
override suspend fun disconnect(): DctResult<Unit> = withContext(ioDispatcher) {
stopHeartbeatLoop()
try {
if (_isStreaming.value) stopStreaming()
if (_isStreaming.value || startMayHaveReachedDevice) stopStreaming()
if (hasLiveTransport()) sendCommand(DctProtocolRequests.disable())
} finally {
transition(DeviceConnectionState.DISCONNECTED)
failPendingRequests("연결이 해제되었습니다.")
wifiSocketManager?.disconnectSuspend()
_isStreaming.value = false
startMayHaveReachedDevice = false
playbackTransportSessionId = null
}
DctResult.Success(Unit)
}
@ -300,7 +333,7 @@ class PacketRepositoryImpl(
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId
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(
DctError.ProtocolError.ResponseTimeout(
DctCommand.toHexString(command.command),
@ -325,6 +358,7 @@ class PacketRepositoryImpl(
if (_connectionState.value != DeviceConnectionState.READY) {
return@withContext failure("장비 설정은 READY 상태에서만 적용할 수 있습니다.")
}
val sessionId = wifiSocketManager?.currentSessionId ?: 0L
val requests = try {
fun packet(step: String, build: () -> DctPacket): DctPacket = try {
@ -368,29 +402,57 @@ class PacketRepositoryImpl(
add(packet("XCP 미사용 모드") { DctProtocolRequests.xcpUse(0x02) })
}
// 4. CAN mode is a complete 0x09/0x06 request, not a
// subtype-only query. Reapply it here as well so a project
// change on an already paired device takes effect.
// PC registers CAN first, sends the DID preset after a 100ms
// gap, and refreshes the environment after RES_CANDBC_INFO.
// Keep one outstanding request per response code: older peers
// can omit the subtype needed to correlate overlapping 0x89s.
add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) })
add(packet("DID 사전 설정") {
com.dct.app.data.maintenance.DeviceMaintenanceProtocol.setupDid(configuration.didPreSetting)
})
add(packet("저장 주기 조회") { DctProtocolRequests.dctEnvQuery(0) })
add(packet("장비 환경 조회") { DctProtocolRequests.dctEnvQuery(3) })
add(packet("CAN 모드") {
DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel)
})
// 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
// module selection from the previous project.
add(packet("센서 모듈") {
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) {
return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}")
}
for (request in requests) {
if (request.command == DctCommand.CMD_SETUP_DID_INFO.code) {
kotlinx.coroutines.delay(100)
// PC does not wait for 0x97 in its connection setup chain.
// Await the actual write, but do not invent a mandatory ACK.
if (!sendLive(request.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0L))) {
return@withContext failure("DID 사전 설정 송신에 실패했습니다.")
}
// Controlled hardware comparison: separate the unacknowledged
// DID write from the following environment query, not from enqueue time.
kotlinx.coroutines.delay(DID_ENV_GAP_MS)
continue
}
val result = sendCommand(request)
if (result !is DctResult.Success || result.data == null) {
// Setup cannot resume after its response budget expires, even
// if heartbeat replies keep arriving. Close only this session
// so a late ACK cannot leave an unusable READY connection.
if (result is DctResult.Failure && result.error is DctError.ProtocolError.ResponseTimeout &&
playbackTransportSessionId != sessionId &&
wifiSocketManager?.invalidateConnectionIfSession(sessionId) == true) {
_isStreaming.value = false
failPendingRequests("장비 설정 응답 시간 초과로 TCP 연결을 종료했습니다.")
transition(DeviceConnectionState.ERROR)
android.util.Log.w("DctPacketRepository",
"Configuration response timeout; TCP session closed: session=$sessionId, command=${DctCommand.toHexString(request.command)}")
}
return@withContext if (result is DctResult.Failure) result else failure(
"장비 설정 응답 누락: ${DctCommand.toHexString(request.command)}"
)
@ -400,6 +462,11 @@ class PacketRepositoryImpl(
} catch (e: IllegalArgumentException) {
return@withContext failure("장비 설정 ${DctCommand.toHexString(request.command)}: ${e.message}")
}
if (request.command == DctCommand.CMD_XCP_IFDATAS.code) {
// Calibration must use the byte order the device actually acknowledged.
// Commit here, not after all setup: a later failure does not undo IFData.
xcpIsMsbFirst = request.payload[0] == 0x01.toByte()
}
}
DctResult.Success(Unit)
}
@ -416,6 +483,8 @@ class PacketRepositoryImpl(
// Wi-Fi Direct 브리지의 control writer가 밀린 실제 로그에서 0xA0이
// 약 12초 후 도착했다. 일반 설정 명령의 빠른 실패 기준은 유지하되
// 계측 시작 ACK만 PC 통신 환경에 맞는 별도 유예 시간을 사용한다.
playbackTransportSessionId = sessionId
startMayHaveReachedDevice = true
val result = sendCommandWithTimeout(
DctProtocolRequests.startAll(),
START_STREAM_RESPONSE_TIMEOUT_MS
@ -425,20 +494,29 @@ class PacketRepositoryImpl(
com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), response)
}.isSuccess) {
_isStreaming.value = true
startMayHaveReachedDevice = false
transition(DeviceConnectionState.MEASURING)
DctResult.Success(Unit)
} else if (result is DctResult.Failure) result
else failure("장비가 계측 시작을 거절했습니다.")
} else if (response?.payload?.let { it.size == 2 && it[1] == 1.toByte() && it[0] != 1.toByte() } == true) {
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) {
if (_connectionState.value != DeviceConnectionState.MEASURING) {
if (_connectionState.value != DeviceConnectionState.MEASURING && !startMayHaveReachedDevice) {
return@withContext failure("현재 계측 중이 아닙니다.")
}
val result = sendCommand(DctProtocolRequests.stop())
val response = (result as? DctResult.Success)?.data
if (response != null && (response.payload.isEmpty() || response.payload[0] == STATUS_SUCCESS)) {
_isStreaming.value = false
startMayHaveReachedDevice = false
transition(DeviceConnectionState.READY)
DctResult.Success(Unit)
} 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
// live TCP/P2P connection for the next command or retry.
_isStreaming.value = false
startMayHaveReachedDevice = false
transition(DeviceConnectionState.READY)
android.util.Log.w(
"DctPacketRepository",
@ -566,6 +645,7 @@ class PacketRepositoryImpl(
// PC는 패킷 종류와 무관하게 정상 수신이 계속되면 장비가
// 살아 있는 것으로 판단해 연결 감시 시간을 갱신한다.
inboundPacketGeneration.incrementAndGet()
lastInboundMillis.set(monotonicTimeMillis())
// Validate and enqueue the stream ACK before publishing to
// UI/log subscribers. SharedFlow emission is intentionally
// lossless and may suspend on a slow consumer; ACKs must not
@ -678,36 +758,45 @@ class PacketRepositoryImpl(
}
/**
* [ISSUE-R1-02] 연결 수립 시 백그라운드 코루틴으로 주기적(3초 기본)으로
* DctCommand.CMD_POSSIBLE_TO_CONNECT (0x05)를 발송하고 생존 여부를 확인합니다.
* PC처럼 정상 수신 자체를 생존 확인으로 사용한다. 마지막 수신 뒤
* 3초(기본) 동안 조용할 때만 0x05를 보내 MDB/XCP ACK 흐름과의 경합을 줄인다.
*/
private fun startHeartbeatLoop() {
if (!enableHeartbeat) 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) {
var consecutiveFailures = 0
var waitMillis = heartbeatIntervalMs.coerceAtLeast(1L)
while (isActive && hasLiveTransport() &&
(_connectionState.value == DeviceConnectionState.READY ||
_connectionState.value == DeviceConnectionState.MEASURING)
) {
kotlinx.coroutines.delay(heartbeatIntervalMs)
kotlinx.coroutines.delay(waitMillis)
if (!isActive || !hasLiveTransport() ||
(_connectionState.value != DeviceConnectionState.READY &&
_connectionState.value != DeviceConnectionState.MEASURING)
) break
waitMillis = remainingHeartbeatSilenceMillis()
if (waitMillis > 0L) {
consecutiveFailures = 0
continue
}
val sessionId = wifiSocketManager?.currentSessionId ?: break
val success = performHeartbeat()
waitMillis = heartbeatIntervalMs.coerceAtLeast(1L)
consecutiveFailures = if (success) 0 else consecutiveFailures + 1
if (!success && consecutiveFailures >= heartbeatFailureThreshold.coerceAtLeast(1) &&
isActive && hasLiveTransport() &&
(_connectionState.value == DeviceConnectionState.READY ||
_connectionState.value == DeviceConnectionState.MEASURING)
) {
// 0x05/0x85는 보조 생존 진단이다. 장비/테스트 프로그램이
// 제어 응답이나 대용량 스트림을 늦게 보내는 동안 Heartbeat만
// 누락될 수 있으므로 이것만으로 정상 TCP를 닫지 않는다.
// 실제 단선은 TCP EOF/I/O 오류 또는 P2P 그룹 해제에서 확정한다.
reportHeartbeatTimeout(consecutiveFailures)
// Before playback retain the connection watchdog. After a
// START request, silence is diagnostic only until actual
// transport loss or explicit Disconnect, including after STOP.
if (closeUnresponsiveSession(sessionId, consecutiveFailures)) break
consecutiveFailures = 0
}
}
@ -719,6 +808,11 @@ class PacketRepositoryImpl(
heartbeatJob = null
}
private fun remainingHeartbeatSilenceMillis(): Long {
val elapsed = (monotonicTimeMillis() - lastInboundMillis.get()).coerceAtLeast(0L)
return (heartbeatIntervalMs.coerceAtLeast(1L) - elapsed).coerceAtLeast(0L)
}
/**
* 계측 중(isMeasuring)일 때의 데이터 스트림 및 다른 동기 제어 명령과 충돌하지 않도록
* 안전하게 Mutex를 조율하며 0x05 발송 및 0x85 응답을 대기합니다.
@ -727,7 +821,6 @@ class PacketRepositoryImpl(
if (!hasLiveTransport()) return false
val sessionId = wifiSocketManager?.currentSessionId ?: return false
val expected = DctCommand.RES_POSSIBLE_TO_CONNECT.code
val inboundGenerationBeforeProbe = inboundPacketGeneration.get()
// 다른 제어 명령(UI 파라미터 변경 등)이 진행 중이면 이미 통신이 활성화된 상태이므로 경합을 피함
val locked = requestMutex.tryLock()
@ -735,10 +828,14 @@ class PacketRepositoryImpl(
return true
}
val inboundGenerationBeforeProbe = inboundPacketGeneration.get()
val deferred = CompletableDeferred<DctPacket>()
pendingResponses[expected] = deferred
val packet = DctProtocolRequests.possibleToConnect().copy(transportSessionId = sessionId)
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)) {
pendingResponses.remove(expected, deferred)
return false
@ -769,13 +866,24 @@ class PacketRepositoryImpl(
}
}
/** Heartbeat 누락은 진단만 남기며 활성 TCP 세션의 상태를 변경하지 않는다. */
private fun reportHeartbeatTimeout(consecutiveFailures: Int) {
private fun closeUnresponsiveSession(sessionId: Long, consecutiveFailures: Int): Boolean {
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(
"DctPacketRepository",
"Alive Check 경고: 하트비트와 정상 데이터 수신이 연속 ${consecutiveFailures}회 없었지만 " +
"TCP 연결은 유지합니다. 실제 EOF/I/O 오류 또는 Wi-Fi Direct 그룹 해제를 기다립니다."
"장비 무응답: session=$sessionId, 하트비트와 정상 데이터 수신이 " +
"${heartbeatTimeoutMs}ms 동안 없음 (${consecutiveFailures}회). PC와 동일하게 TCP 연결을 종료합니다."
)
_isStreaming.value = false
failPendingRequests("장비 응답이 없어 TCP 연결을 종료했습니다.")
transition(DeviceConnectionState.ERROR)
return true
}
private fun failPendingRequests(reason: String) {
@ -817,10 +925,12 @@ class PacketRepositoryImpl(
// payload copies in memory.
const val DEFAULT_DISPATCH_QUEUE_CAPACITY = 512
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
const val DEFAULT_HEARTBEAT_INTERVAL_MS = 3_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 STOP_BOUNDARY_POLL_MS = 25L
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 sensorModules: List<DpcSensorModuleConfig> = emptyList(),
val isHighSpeedCan: Boolean = true,
val isOldModel: Boolean = false
val isOldModel: Boolean = false,
val didPreSetting: Int = 0
)
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions
@ -39,6 +40,7 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
val state by viewModel.wifiApState.collectAsStateWithLifecycle()
val ui by viewModel.uiState.collectAsStateWithLifecycle()
val socket by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle()
val protocolState by viewModel.packetRepository.connectionState.collectAsStateWithLifecycle()
val endpoint by viewModel.wifiApEndpoint.collectAsStateWithLifecycle()
val connectedEndpoint by viewModel.wifiSocketManager.connectedEndpoint.collectAsStateWithLifecycle()
var requested by remember { mutableStateOf(false) }
@ -72,11 +74,13 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket,
onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp,
onCancel = viewModel::cancelWifiApConnection, onDisconnect = { viewModel.disconnectCommunication() },
onRetry = viewModel::retryWifiApTcp, onCurrent = viewModel::connectCurrentWifiAp,
onCancel = viewModel::cancelWifiApConnection,
onDisconnect = { viewModel.disconnectCommunication(releaseWifiAp = true) },
onCurrent = viewModel::connectCurrentWifiAp,
onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss,
endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint,
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.RECORDING,
com.dct.app.presentation.main.MeasurementRecordingState.STOPPING) && !state.busy)
@ -87,10 +91,11 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
internal fun WifiApConnectionContent(
state: WifiApUiState, communicationReady: Boolean, socketConnected: Boolean,
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,
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 addingNetwork by remember { mutableStateOf(false) }
@ -98,14 +103,19 @@ internal fun WifiApConnectionContent(
var showDetails by remember { mutableStateOf(false) }
var connectedDetailsKey by remember { mutableStateOf<String?>(null) }
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
// This shortcut uses the existing single-IPv4 Wi-Fi path, never an arbitrary second network.
val canConnectCurrent = endpointEditable && endpoint.isValid && state.enabled && state.permissionGranted &&
!communicationReady && !socketConnected && state.connectedNetworks.count { it.localAddress != null } == 1
val connectedStatus = when {
communicationReady -> "연결됨"
communicationFailed -> "통신 연결 실패"
socketConnected -> "장비 초기화 중…"
else -> "통신 연결 대기"
else -> "Wi-Fi 연결됨"
}
val configuredAddress = "접속 설정: ${endpoint.host}:${endpoint.port}"
val currentAddress = if (!state.busy && (socketConnected || communicationReady)) connectedEndpoint?.let {
@ -171,7 +181,7 @@ internal fun WifiApConnectionContent(
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
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") {
WifiApNetworkRow(state.selectedSsid ?: "DCT Wi-Fi",
status = if (state.busy) "연결 중…" else connectedStatus,
@ -211,8 +221,6 @@ internal fun WifiApConnectionContent(
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)) {
if (state.busy) TextButton(onClick = onCancel) { Text("연결 취소") }
Button(enabled = canConnectCurrent, onClick = onCurrent,
modifier = Modifier.testTag("wifi:connected-connect")) { Text("통신 연결") }
TextButton(onClick = onDismiss) { Text("닫기") }
}
}
@ -314,9 +322,6 @@ internal fun WifiApConnectionContent(
TextButton(enabled = endpointEditable, onClick = {
showDetails = false; editEndpoint = true
}) { Text("IP / 포트 설정") }
if (state.networkReady && !communicationReady) TextButton(onClick = {
showDetails = false; onRetry()
}) { Text("통신 재연결") }
TextButton(onClick = { showDetails = false }) { Text("확인") }
}
})
@ -326,23 +331,20 @@ internal fun WifiApConnectionContent(
var port by remember { mutableStateOf(endpoint.port.toString()) }
var saveError by remember { mutableStateOf(false) }
val target = WifiApEndpoint.fromInput(WifiConnectionInput(ip, port))
fun applyTarget(connectNow: Boolean) {
fun applyTarget() {
if (target == null || !endpointEditable || !onEndpointChange(target)) {
saveError = true
return
}
editEndpoint = false
if (connectNow) {
if (state.networkReady) onRetry() else onCurrent()
}
}
AlertDialog(onDismissRequest = { editEndpoint = false },
title = { Text("IP / 포트 설정") },
text = {
Column(Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("PC와 같은 Wi-Fi에서 PC의 IPv4 주소를 입력하세요. 테스트 서버 기본 포트는 6000입니다.",
style = MaterialTheme.typography.bodySmall)
Text("장치와 같은 Wi-Fi에 연결한 뒤 장치의 IPv4 주소와 포트를 입력하세요. 기본값은 ${DctWifiAp.HOST}:${DctWifiAp.PORT}입니다.",
style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("wifi:tcp-guide"))
OutlinedTextField(ip, onValueChange = { ip = it; saveError = false },
label = { Text("IP 주소") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
@ -364,11 +366,8 @@ internal fun WifiApConnectionContent(
dismissButton = { TextButton(onClick = { editEndpoint = false }) { Text("취소") } },
confirmButton = {
Row {
TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget(false) },
TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget() },
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.unit.dp
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.DctThemeTokens
import kotlinx.coroutines.launch
private const val PERMISSION_EXPLANATION_PREFS = "dct_permission_explanation"
private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown"
@ -43,8 +47,13 @@ private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown"
* DCT 안드로이드 태블릿 메인 액티비티
*/
class MainActivity : ComponentActivity() {
private lateinit var activitySession: DctSessionRuntime
var programExitRequested by mutableStateOf(false)
private set
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activitySession = (application as DctApplication).session
// 처음 실행할 때 시스템 권한 화면으로 바로 이동하지 않고, 먼저
// 권한의 용도와 제한 사항을 설명한다. 실제 요청은 사용자가
@ -90,6 +99,9 @@ class MainActivity : ComponentActivity() {
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() {
@ -102,14 +114,33 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
(application as com.dct.app.DctApplication).session.onUiStarted()
activitySession.onUiStarted()
}
override fun onStop() {
(application as com.dct.app.DctApplication).session.onUiStopped()
activitySession.onUiStopped()
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 {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {

196
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.TreeItemSelection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -94,6 +97,7 @@ data class MainUiState(
val dashboardTiles: List<TileWidgetItem> = emptyList(),
val currentSelection: TreeItemSelection? = null,
val isConnected: Boolean = false,
val isCommunicationConnecting: Boolean = false,
val communicationType: CommunicationType = CommunicationType.NONE,
val connectedDeviceName: String? = null,
val statusMessage: String? = null,
@ -125,6 +129,20 @@ class MainViewModel(
private val calibrationMutex = kotlinx.coroutines.sync.Mutex()
private var calibrationJob: Job? = null
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()
// A whole CSV/MF4 sample and start/close are indivisible, including STOP ACK/disconnect paths.
private val recordingSampleLock = Any()
@ -134,8 +152,10 @@ class MainViewModel(
@Volatile private var configurationRequired = false
private val chartTelemetryResolver = ChartTelemetryResolver()
private val formulaTelemetryLock = Any()
private val telemetryResetLock = Any()
private val formulaTelemetryEngine = com.dct.app.core.formula.FormulaTelemetryEngine()
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)
val isCalibrationBusy: StateFlow<Boolean> = _isCalibrationBusy.asStateFlow()
@ -197,7 +217,7 @@ class MainViewModel(
try {
val nowSec = packet.receivedAtSec.takeIf { it.isFinite() }
?: (packet.receivedAtMillis.takeIf { it > 0 } ?: System.currentTimeMillis()) / 1000.0
processIncomingPacket(packet, nowSec)
synchronized(telemetryResetLock) { processIncomingPacket(packet, nowSec) }
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
@ -237,7 +257,10 @@ class MainViewModel(
communicationType = if (isDisconnectedOrError) CommunicationType.NONE else it.communicationType,
connectedDeviceName = if (isDisconnectedOrError) null else it.connectedDeviceName,
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 "장비 연결이 해제되었습니다."
else -> it.statusMessage
}
@ -733,6 +756,27 @@ class MainViewModel(
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 서브윈도우 인스턴스 생성
*/
@ -1034,6 +1078,8 @@ class MainViewModel(
companion object {
const val MAX_WORKSPACE_PAGES = 5
private const val LOG_TAG = "DctMainViewModel"
private const val CONFIGURATION_TIMEOUT_MESSAGE =
"장비 설정 응답 시간 초과로 통신 연결이 해제되었습니다. Connect로 다시 연결하세요."
}
/**
@ -1590,9 +1636,11 @@ class MainViewModel(
if (!ensureBackgroundExecution()) return
pendingWifiTarget.set(target)
val generation = connectionGeneration.incrementAndGet()
cancelSessionControls()
_uiState.update { it.copy(isCommunicationConnecting = true) }
configurationRequired = true
val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트"
scope.launch(dispatchers.io) {
launchSessionControl {
sessionMutex.withLock {
if (generation != connectionGeneration.get()) return@withLock
if (_uiState.value.isMeasuring || packetRepository.isConnected.value) {
@ -1609,7 +1657,7 @@ class MainViewModel(
wifiSocketManager.startDualMode(
isGroupOwner = isGroupOwner, hostIp = hostIp, port = port,
onConnected = { _, remoteAddress ->
scope.launch(dispatchers.io) {
launchSessionControl {
sessionMutex.withLock {
if (generation != connectionGeneration.get()) return@withLock
connectedWifiTarget.set(target)
@ -1635,7 +1683,10 @@ class MainViewModel(
DctResult.Loading -> Unit
}
} 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 ->
if (generation == connectionGeneration.get()) {
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") }
}
}
@ -1666,10 +1717,39 @@ class MainViewModel(
val wifiApState = _wifiApState.asStateFlow()
private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint())
val wifiApEndpoint = _wifiApEndpoint.asStateFlow()
val canConnectCommunication = combine(
_wifiApState, _wifiApEndpoint, _uiState, wifiSocketManager.isConnected
) { ap, endpoint, ui, socket -> canStartWifiCommunication(ap, endpoint, ui, socket) }
.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false)
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 {
if (!endpoint.isValid || _uiState.value.isMeasuring ||
if (!endpoint.isValid || _uiState.value.isCommunicationConnecting || _uiState.value.isMeasuring ||
_uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING,
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) ||
registeredWifiApManager?.uiState?.value?.busy == true) return false
@ -1692,7 +1772,7 @@ class MainViewModel(
.onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) }
}
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 연결이 해제되었습니다.") },
onConnecting = { closeWifiTransportAfterGroupLoss(null) },
endpointProvider = { _wifiApEndpoint.value })
@ -1706,21 +1786,16 @@ class MainViewModel(
fun refreshWifiAp() { registeredWifiApManager?.refresh() }
fun scanWifiAp() { registeredWifiApManager?.scan() }
fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) {
if (registeredWifiApManager?.uiState?.value?.canScan == true && ensureBackgroundExecution())
if (registeredWifiApManager?.uiState?.value?.canScan == true)
registeredWifiApManager?.connect(device, password)
}
fun connectCurrentWifiAp() {
if (ensureBackgroundExecution()) registeredWifiApManager?.connectCurrent()
registeredWifiApManager?.connectCurrent()
}
fun cancelWifiApConnection() {
registeredWifiApManager?.disconnect()
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())
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 연결이 해제되었습니다.") {
val generation = connectionGeneration.incrementAndGet()
cancelSessionControls()
pendingWifiTarget.set(null)
connectedWifiTarget.set(null)
configurationRequired = true
_uiState.update { it.copy(isCommunicationConnecting = false) }
// Invalidate immediately: queued cleanup must never close a newly formed group's socket.
wifiSocketManager.invalidateConnection()
scope.launch(dispatchers.io) {
@ -1802,23 +1879,24 @@ class MainViewModel(
* Wi-Fi AP (or retained P2P) / TCP 통신 연결 해제.
*/
@Synchronized
fun disconnectCommunication(context: Context? = null) {
if (registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect()
fun disconnectCommunication(context: Context? = null, releaseWifiAp: Boolean = false): Job? {
if (releaseWifiAp && registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect()
registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager ->
when {
manager.uiState.value.link != null -> manager.disconnect()
manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect()
else -> closeWifiTransportAfterGroupLoss()
}
return
return null
}
val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately.
cancelSessionControls()
pendingWifiTarget.set(null)
connectedWifiTarget.set(null)
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 = "통신 연결 해제 중...") }
scope.launch(dispatchers.io) {
return scope.launch(dispatchers.io) {
sessionMutex.withLock {
if (disconnectGeneration != connectionGeneration.get()) return@withLock
try {
@ -1828,7 +1906,7 @@ class MainViewModel(
finishMeasurementSession()
wifiSocketManager.disconnectSuspend()
// Keep the AP lease until STOP and log finalization have completed.
registeredWifiApManager?.disconnect()
if (releaseWifiAp) registeredWifiApManager?.disconnect()
_uiState.update {
it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE,
connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.")
@ -3189,13 +3267,17 @@ class MainViewModel(
fun syncDeviceConfiguration(onComplete: ((Boolean) -> Unit)? = null) {
configurationRequired = true
val generation = connectionGeneration.get()
scope.launch(dispatchers.io) {
val success = sessionMutex.withLock {
launchSessionControl {
var success = false
try {
success = sessionMutex.withLock {
generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation)
}
} finally {
onComplete?.invoke(success)
}
}
}
private suspend fun syncDeviceConfigurationLocked(
generation: Long,
@ -3210,15 +3292,23 @@ class MainViewModel(
}
val configuration = createDeviceMeasurementConfiguration()
val configResult = packetRepository.configureMeasurement(configuration)
if (generation != connectionGeneration.get()) return false
if (configResult !is DctResult.Success) {
val message = if (configResult is DctResult.Failure) "장비 설정 실패: ${configResult.error.message}"
else "장비 설정이 아직 완료되지 않았습니다."
val message = when {
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) }
return false
}
if (generation != connectionGeneration.get()) return false
configuredMdbSelection = configuration.selectedMdbSignals.map { it.reclid to it.idx }
configuredCanMessages = configuration.canMessages
configurationRequired = false
_uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") }
return true
@ -3231,7 +3321,7 @@ class MainViewModel(
fun startMeasure(frequencyHz: Int = 30) {
if (packetRepository.connectionState.value == DeviceConnectionState.READY && !ensureBackgroundExecution()) return
val generation = connectionGeneration.get()
scope.launch(dispatchers.io) {
launchSessionControl {
sessionMutex.withLock {
if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock
if (!_uiState.value.canDbcFileName.isNullOrBlank() && _canSignals.value.isEmpty()) {
@ -3247,15 +3337,16 @@ class MainViewModel(
setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.")
return@withLock
}
// Formulas/graph bindings can change after the initial connection handshake.
// Register their raw MDB inputs before START, while the device is still READY.
// Bindings can change after Connect. Register MDB inputs and selected
// CAN messages before START; a loaded DBC is not a monitoring selection.
val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty()
if (mdbSignals.isNotEmpty()) {
val selected = selectVisibleMdbSignals(mdbSignals).map { it.reclid to it.idx }
if (selected != configuredMdbSelection && !syncDeviceConfigurationLocked(generation, notifyProgress = false)) {
val configuration = createDeviceMeasurementConfiguration()
val mdbChanged = mdbSignals.isNotEmpty() &&
configuration.selectedMdbSignals.map { it.reclid to it.idx } != configuredMdbSelection
if ((mdbChanged || configuration.canMessages != configuredCanMessages) &&
!syncDeviceConfigurationLocked(generation, notifyProgress = false)) {
return@withLock
}
}
setStatusMessage("계측 시작 요청 중...")
measurementRequestMillis = System.currentTimeMillis()
when (val result = packetRepository.startStreaming(frequencyHz)) {
@ -3265,7 +3356,9 @@ class MainViewModel(
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
}
}
@ -3280,14 +3373,22 @@ class MainViewModel(
private fun createDeviceMeasurementConfiguration(): com.dct.app.domain.model.protocol.DctMeasurementConfiguration {
val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject()
val maxDlc = if (project.canMode == "CAN(FD)") 64 else 8
val messages = _canSignals.value
.filter { it.messageId in 0L..0xFFFFL }
val canSignals = _canSignals.value
val selectedKeys = selectedMeasurementKeys()
// 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 }
.map { (canId, signals) ->
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_TAG,
"CAN 0x11 설정 준비: signals=${_canSignals.value.size}, messages=${messages.size}, " +
@ -3308,14 +3409,13 @@ class MainViewModel(
canMessages = messages,
sensorModules = _sensorModules.value,
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. */
private fun selectVisibleMdbSignals(
mdbSignals: List<com.dct.app.domain.model.mdb.SignalDefinition>
): List<com.dct.app.domain.model.mdb.SignalDefinition> {
/** Selection snapshot for configuration only; never scan definitions for each packet. */
private fun selectedMeasurementKeys(): Set<String> {
val selectedKeys = mutableSetOf<String>()
fun addSeries(series: com.dct.app.domain.model.chart.ChartSignalSeries) {
selectedKeys += series.signalId
@ -3343,8 +3443,14 @@ class MainViewModel(
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 ->
"sig_${signal.reclid}_${signal.idx}" in foldedKeys || signal.name.lowercase(java.util.Locale.ROOT) in foldedKeys
}
@ -4102,7 +4208,7 @@ class MainViewModel(
*/
fun stopMeasure() {
val generation = connectionGeneration.get()
scope.launch(dispatchers.io) {
launchSessionControl {
sessionMutex.withLock {
if (generation != connectionGeneration.get() || !_uiState.value.isMeasuring) return@withLock
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)) {
Text("HKMC ASK 인증 상태", fontWeight = FontWeight.Bold)
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 showMaintenanceDialog 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 pendingPropertyDocument by remember { mutableStateOf<Uri?>(null) }
var pendingNewProjectDocument by remember { mutableStateOf<Uri?>(null) }
@ -503,6 +504,7 @@ fun DiagnosticDataMonitorScreen(
androidx.activity.compose.BackHandler {
when {
programExitInProgress -> Unit
showExitConfirmDialog -> showExitConfirmDialog = false
showNewProjectDialog -> {
if (!propertyApplying) {
@ -1430,9 +1432,7 @@ fun DiagnosticDataMonitorScreen(
Button(
onClick = {
showExitConfirmDialog = false
viewModel.autoSaveOnExit(context)
viewModel.disconnectCommunication(context.applicationContext)
(context as? android.app.Activity)?.finish()
(context as? com.dct.app.presentation.main.MainActivity)?.exitProgram()
},
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 규격 호환)
*/
@ -103,7 +146,8 @@ fun WorkspaceRibbonBar(
onWifiClick: () -> Unit,
onMaintenanceClick: () -> Unit,
onResetGraphDataClick: () -> Unit,
onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() }
onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() },
onWifiDisconnectClick: () -> Unit = { viewModel.disconnectCommunication(releaseWifiAp = true) }
) {
var showFileMenu by remember { mutableStateOf(false) }
val currentProject by viewModel.currentProject.collectAsState()
@ -114,10 +158,12 @@ fun WorkspaceRibbonBar(
.fillMaxWidth()
.height(44.dp)
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.weight(1f)
.fillMaxHeight()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 6.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp)
@ -299,21 +345,14 @@ fun WorkspaceRibbonBar(
// Group 2: 통신 연결
val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE
val apState by viewModel.wifiApState.collectAsState()
val canConnectCommunication by viewModel.canConnectCommunication.collectAsState()
val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI
RibbonButton(
icon = Icons.Default.LinkOff,
tooltip = "통신 끊기 (Disconnect)",
enabled = isCommConnected || apState.networkReady || apState.busy,
tint = if (isCommConnected) Color(0xFFEF4444) else ColorSecondary,
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
WifiConnectionRibbonButtons(
ui = mainUiState, ap = apState, canConnect = canConnectCommunication,
onWifiClick = onWifiClick, onConnectClick = viewModel::connectCommunication,
onDisconnectClick = onDisconnectClick,
onWifiDisconnectClick = onWifiDisconnectClick
)
VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant)
@ -338,8 +377,8 @@ fun WorkspaceRibbonBar(
onClick = { viewModel.stopMeasure() }
)
VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant)
Text("신호 기록", fontSize = 12.sp, color = ColorTextSecondary)
val recordingState = mainUiState.recordingState
RecordingRibbonLabel(recordingState, mainUiState.recordingStartedAtMillis)
val canStartRecording = isCommConnected && isMeasuring &&
recordingState !in setOf(
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
)
}
}
}

98
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.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@DisplayName("Heartbeat 주기 루프 및 OTA 유지보수 단위 테스트")
class HeartbeatAndOtaParityTest {
@ -80,8 +82,9 @@ class HeartbeatAndOtaParityTest {
assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x02.toByte() })
}
@Test
fun `Heartbeat와 다른 수신이 모두 누락되어도 활성 TCP 소켓은 유지한다`() = runTest {
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun `재생 전 무응답은 종료하고 재생 요청 이후에는 실제 링크 단절까지 연결을 유지한다`(measuring: Boolean) = runTest {
val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 16)
val isConnected = MutableStateFlow(true)
@ -89,6 +92,10 @@ class HeartbeatAndOtaParityTest {
every { wifi.packetStream } returns packets
every { wifi.currentSessionId } returns 1L
coEvery { wifi.disconnectSuspend() } coAnswers { isConnected.value = false }
every { wifi.invalidateConnectionIfSession(1L) } answers {
isConnected.value = false
true
}
val sentList = mutableListOf<DctPacket>()
coEvery { wifi.sendCommand(capture(sentList)) } returns true
@ -100,7 +107,8 @@ class HeartbeatAndOtaParityTest {
heartbeatDispatcher = testDispatcher,
heartbeatIntervalMs = 1000L,
heartbeatTimeoutMs = 5000L,
enableHeartbeat = true
enableHeartbeat = true,
monotonicTimeMillis = { testScheduler.currentTime }
)
// 핸드셰이크 통과 설정
@ -127,43 +135,81 @@ class HeartbeatAndOtaParityTest {
assertTrue(repository.connect("") is DctResult.Success)
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) 발송 시점
advanceTimeBy(1001L)
val heartbeat1 = sentList.find { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code }
assertNotNull(heartbeat1)
// 첫 누락만으로는 일시 지연일 수 있으므로 연결을 유지한다.
advanceTimeBy(5001L)
assertEquals(DeviceConnectionState.READY, repository.connectionState.value)
// 5초 응답 대기 예산 안에서는 연결을 유지한다.
advanceTimeBy(4998L)
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() })
// 다음 probe도 0x85와 기타 정상 패킷이 모두 없지만 Heartbeat만으로
// 활성 TCP 연결을 종료해서는 안 된다.
advanceTimeBy(6001L)
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)
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() }
// 로그에서 재현된 것처럼 늦은 정상 응답이 도착해도 그대로 수신하고
// 이후 Heartbeat 감시를 계속할 수 있어야 한다.
// 종료 뒤 늦게 도착한 START ACK로 연결/계측을 되살리지 않는다.
packets.emit(DctPacket(
command = DctCommand.RES_START_TRANSMIT_DATA.code,
payload = byteArrayOf(1, 1),
transportSessionId = 1L
))
advanceTimeBy(1L)
assertEquals(DeviceConnectionState.READY, repository.connectionState.value)
assertTrue(isConnected.value)
// 제품 동작 검증 후 모의 전송 계층을 내려 반복 Heartbeat 작업을 정리한다.
isConnected.value = false
advanceTimeBy(6001L)
assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value)
assertFalse(isConnected.value)
assertEquals(1, sentList.count { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code })
}
@Test
fun `계측 중에도 Heartbeat 루프가 데이터 스트림을 차단하지 않고 공존한다`() = runTest {
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun `계측 데이터 또는 Heartbeat 응답이 있으면 연결을 유지한다`(heartbeatReply: Boolean) = runTest {
val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 64)
val isConnected = MutableStateFlow(true)
@ -182,7 +228,8 @@ class HeartbeatAndOtaParityTest {
heartbeatDispatcher = testDispatcher,
heartbeatIntervalMs = 2000L,
heartbeatTimeoutMs = 5000L,
enableHeartbeat = true
enableHeartbeat = true,
monotonicTimeMillis = { testScheduler.currentTime }
)
// 핸드셰이크 통과
@ -227,16 +274,17 @@ class HeartbeatAndOtaParityTest {
// Heartbeat 주기(2000ms) 전진
advanceTimeBy(2001L)
// 0x85 대신 계측 데이터가 들어와도 PC와 같이 장비 생존으로 판단한다.
// 정상 데이터만 수신하는 경우와 0x85 응답만 받는 경우를 각각 검증한다.
packets.emit(DctPacket(
command = DctCommand.SEND_CAN_FRAME.code,
payload = byteArrayOf(0, 8, 1, 2, 3, 4, 5, 6, 7, 8),
command = if (heartbeatReply) DctCommand.RES_POSSIBLE_TO_CONNECT.code else DctCommand.SEND_CAN_FRAME.code,
payload = if (heartbeatReply) byteArrayOf() else byteArrayOf(1, 0xA4.toByte(), 1),
transportSessionId = 2L
))
advanceTimeBy(5001L)
assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value)
assertTrue(sentList.any { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code })
verify(exactly = 0) { wifi.invalidateConnectionIfSession(any()) }
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)
.isEqualTo(hex("00 00 00"))
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)
.isEqualTo(hex("01 00"))
assertThat(DctProtocolRequests.canMode(isHighSpeedCan = true, isOldModel = true).payload)
.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
fun `MDB wire 폭 축소 후 중복되는 ID와 Index는 설정 전에 거절한다`() {
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"])
}
@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() {
vm.startMeasure()
vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2"))
@ -101,8 +181,11 @@ class FormulaTelemetryRegressionTest {
assertEquals(2, graph.buffer.size)
vm.stopMeasurementRecording()
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(listOf("0", "1"), rows.map { it.split(',')[1].trim() })
assertEquals(listOf(4.0, 6.0), rows.map { it.substringAfterLast(',').toDouble() })
}
@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 메인 뷰모델 및 오케스트레이션 단위 테스트")
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() {
viewModel.openMdbFromFile(File("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 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() {
val original = viewModel.wifiApEndpoint.value
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)
}
@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 {
val host = "192.168.1.100"
every { viewModel.wifiSocketManager.connectedHost } returns MutableStateFlow(host)
@ -540,7 +919,9 @@ class MainViewModelTest {
calibrationViewModel = calibrationViewModel,
eventBus = eventBus,
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,
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.data.communication.WifiSocketManager
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.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.project.createDefaultSensorModules
import com.dct.app.domain.repository.DeviceConnectionState
@ -14,8 +18,90 @@ 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.CsvSource
import org.junit.jupiter.params.provider.ValueSource
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 {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
coEvery { f.wifi.sendCommand(any()) } coAnswers { kotlinx.coroutines.delay(1000); true }
@ -43,16 +129,27 @@ class PacketRepositoryResponseTest {
val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 8)
val connected = MutableStateFlow(true)
var sessionId = 0L
var reject: Byte? = null
var silent: Byte? = null
var versionPayload = byteArrayOf(1, 65)
val sentPackets = mutableListOf<DctPacket>()
init {
every { wifi.isConnected } returns connected
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.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>()
sentPackets += request
if (request.command == silent) return@coAnswers true
val subCmd = request.payload.firstOrNull() ?: 1
val payload = when (request.command.toInt()) {
1 -> versionPayload
@ -69,6 +166,73 @@ class PacketRepositoryResponseTest {
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 {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
f.reject = 0
@ -106,7 +270,7 @@ class PacketRepositoryResponseTest {
val env = f.sentPackets.filter { it.command == 0x09.toByte() }
assertTrue(env.any { it.payload.contentEquals(byteArrayOf(0x06, 0x11)) })
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 {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
@ -126,6 +290,75 @@ class PacketRepositoryResponseTest {
)
assertTrue(f.repository.configureMeasurement(config) is DctResult.Failure)
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 {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
@ -143,15 +376,195 @@ class PacketRepositoryResponseTest {
}
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))
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.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() }
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)
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)
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 {

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[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
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
.getDeclaredMethod("nextConnectionGeneration").also { it.isAccessible = true }.invoke(manager) as Long
private fun attach(manager: WifiSocketManager, socket: Socket, generation: Long): Boolean = WifiSocketManager::class.java

Loading…
Cancel
Save