Browse Source

Improve Wi-Fi connection modes, measurement recovery and test server

master
seuuung 6 days ago
parent
commit
593c0968b1
  1. 15
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt
  2. 69
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt
  3. 94
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt
  4. 51
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt
  5. 32
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt
  6. 80
      Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt
  7. 2
      Android/app/src/main/AndroidManifest.xml
  8. 26
      Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt
  9. 1
      Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt
  10. 11
      Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt
  11. 41
      Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt
  12. 7
      Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionMode.kt
  13. 13
      Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt
  14. 94
      Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt
  15. 14
      Android/app/src/main/java/com/dct/app/data/preferences/DeveloperOptionsPreferences.kt
  16. 50
      Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt
  17. 429
      Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt
  18. 13
      Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt
  19. 19
      Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt
  20. 36
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt
  21. 62
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiConnectionDialog.kt
  22. 144
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt
  23. 40
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectPortDialog.kt
  24. 23
      Android/app/src/main/java/com/dct/app/presentation/main/DeveloperOptionsAccess.kt
  25. 2
      Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt
  26. 262
      Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt
  27. 24
      Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt
  28. 51
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialog.kt
  29. 43
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTime.kt
  30. 20
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt
  31. 212
      Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt
  32. 46
      Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt
  33. 24
      Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt
  34. 54
      Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt
  35. 95
      Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt
  36. 89
      Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt
  37. 7
      Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt
  38. 114
      Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt
  39. 9
      Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt
  40. 28
      Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt
  41. 396
      Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt
  42. 39
      Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt
  43. 239
      Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt
  44. 17
      Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt
  45. 8
      Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt
  46. 32
      Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt
  47. 54
      Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt
  48. 36
      Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt
  49. 206
      Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt
  50. 53
      Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt
  51. 28
      DCT_test/DCT_test/App.xaml.cs
  52. 15
      DCT_test/DCT_test/ClientSession.cs
  53. 28
      DCT_test/DCT_test/DCT_test.csproj
  54. 17
      DCT_test/DCT_test/DctTcpServer.cs
  55. 25
      DCT_test/DCT_test/MainWindow.xaml
  56. 97
      DCT_test/DCT_test/MainWindow.xaml.cs
  57. 243
      DCT_test/DCT_test/NetworkSupport.cs
  58. 25
      DCT_test/DCT_test/WifiDirectHost.cs
  59. 6
      DCT_test/Verify-ApMode.ps1
  60. 66
      DCT_test/Verify-NetworkSupport.ps1
  61. 29
      DCT_test/Verify-Regression.ps1
  62. 177
      DCT_test/Verify-Transport.cs
  63. 9
      DCT_test/Verify-Transport.ps1
  64. 148
      DCT_test/verification/Verify-WifiDirect.cs
  65. 23
      DCT_test/verification/Verify-WifiDirect.csproj

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

@ -125,17 +125,21 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist()
}
@Test fun connectedRowOpensDetailsForWifiDisconnectOnly() {
@Test fun connectedRowShowsInlineDisconnectWithoutOpeningDetails() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED,
selectedSsid = device.ssid, localAddress = "192.168.4.20")
show()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertDoesNotExist()
compose.onNodeWithText("연결 해제").assertDoesNotExist()
val disconnect = compose.onNodeWithTag("wifi:ap-current:disconnect").assertIsDisplayed()
val name = compose.onNodeWithText(device.ssid, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertTrue(name.right <= disconnect.fetchSemanticsNode().boundsInRoot.left)
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed()
compose.onNodeWithText("통신 재연결").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, disconnected) }
compose.onNodeWithText("연결 해제").performClick()
compose.onNodeWithText("확인").performClick()
disconnect.performClick()
compose.onNodeWithText("IP 주소: 192.168.4.20").assertDoesNotExist()
compose.runOnIdle { assertEquals(1, disconnected) }
}
@ -150,8 +154,11 @@ class WifiApConnectionUiTest {
compose.onNodeWithTag("wifi:connected:101").performClick()
compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed()
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.onNodeWithText("연결 해제").assertDoesNotExist()
compose.onNode(hasText("연결 해제") and hasAnyAncestor(hasTestTag("wifi:connected-details")))
.assertDoesNotExist()
compose.onNodeWithText("확인").performClick()
compose.onNodeWithTag("wifi:connected:101:disconnect").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals(android.provider.Settings.ACTION_WIFI_SETTINGS, settingsAction) }
compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
}

69
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiConnectionModeUiTest.kt

@ -0,0 +1,69 @@
package com.dct.app.presentation.dialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.SideEffect
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.dct.app.data.communication.WifiConnectionMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Rule
import org.junit.Test
class WifiConnectionModeUiTest {
@get:Rule val compose = createComposeRule()
@Test fun changingTabsKeepsTheSameDialogWindowBoundsAndTabPosition() {
var mode by mutableStateOf(WifiConnectionMode.TCP)
var windowView: android.view.View? = null
compose.setContent { MaterialTheme {
WifiConnectionDialogFrame(mode, true, { mode=it }, {}) {
val rootView = LocalView.current.rootView
SideEffect { windowView=rootView }
// Different content heights must not resize or replace the parent window.
if (mode == WifiConnectionMode.TCP) Text("AP 목록") else Column {
repeat(10) { Text("Direct 기기 $it") }
}
}
} }
val initialWindow = windowView
val initialBounds = compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot
val tabBounds = compose.onNodeWithTag("wifi-mode:TCP").fetchSemanticsNode().boundsInRoot
repeat(3) {
compose.onNodeWithTag("wifi-mode:DIRECT").performClick().assertIsSelected()
compose.onNodeWithText("Direct 기기 0").assertIsDisplayed()
compose.onNodeWithText("AP 목록").assertDoesNotExist()
assertEquals(initialBounds, compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot)
assertEquals(tabBounds, compose.onNodeWithTag("wifi-mode:TCP").fetchSemanticsNode().boundsInRoot)
compose.runOnIdle { assertSame(initialWindow, windowView) }
compose.onNodeWithTag("wifi-mode:TCP").performClick().assertIsSelected()
compose.onNodeWithText("AP 목록").assertIsDisplayed()
assertEquals(initialBounds, compose.onNodeWithTag("wifi:dialog").fetchSemanticsNode().boundsInRoot)
compose.runOnIdle { assertSame(initialWindow, windowView) }
}
compose.onAllNodes(isDialog()).assertCountEquals(1)
}
@Test fun tabsSwitchModesAndLockDuringAnActiveLink() {
var mode by mutableStateOf(WifiConnectionMode.TCP)
var enabled by mutableStateOf(true)
var clicks=0
compose.setContent { MaterialTheme {
WifiConnectionModeSelector(mode,enabled) { mode=it; clicks++ }
} }
compose.onNodeWithTag("wifi-mode:TCP").assertIsSelected()
compose.onNodeWithTag("wifi-mode:DIRECT").performClick().assertIsSelected()
compose.runOnIdle { enabled=false }
compose.onNodeWithTag("wifi-mode:TCP").assertIsNotEnabled()
compose.onNodeWithText("Wi-Fi 해제 후 전환할 수 있습니다.").assertIsDisplayed()
compose.runOnIdle { assertEquals(1,clicks); enabled=true }
compose.onNodeWithTag("wifi-mode:TCP").performClick().assertIsSelected()
compose.runOnIdle { assertEquals(2,clicks) }
}
}

94
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectDeviceListUiTest.kt

@ -0,0 +1,94 @@
package com.dct.app.presentation.dialog
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pInfo
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry
import com.dct.app.data.communication.*
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import java.io.File
class WifiDirectDeviceListUiTest {
@get:Rule val compose = createComposeRule()
private val peer = WifiP2pDevice().apply {
deviceName = "DCT_TEST_PEER"
deviceAddress = "02:00:00:00:00:01"
status = WifiP2pDevice.AVAILABLE
}
private val state = mutableStateOf(WifiDirectUiState(phase = WifiDirectPhase.IDLE, enabled = true,
permissionGranted = true, locationEnabled = true, synchronizedWithOs = true, peers = listOf(peer)))
private var disconnected = 0
private var portSettings = 0
private var cancelled = 0
private var selected: WifiP2pDevice? = null
private var refreshed = 0
private var dismissed = 0
private fun link() = WifiDirectLink(WifiP2pInfo().apply { groupFormed = true; isGroupOwner = true }, WifiP2pGroup())
private fun show() {
compose.setContent { MaterialTheme {
WifiConnectionDialogFrame(WifiConnectionMode.DIRECT, state.value.link == null, {}, {}) {
WifiDirectDeviceListContent(state.value, 5000, state.value.link == null,
onPortSettings = { portSettings++ }, onPermissions = {}, onSettings = {},
onDisconnect = { disconnected++ }, onCancel = { cancelled++ }, onConnect = { selected=it },
onRefresh = { refreshed++ }, onDismiss = { dismissed++ })
}
} }
}
@Test fun connectedSummaryKeepsStatusUnderNameAndDisconnectOnRightWithRoomForDiscovery() {
state.value = state.value.copy(phase = WifiDirectPhase.CONNECTED, link = link(), peers = (1..10).map { i ->
WifiP2pDevice().apply { deviceName="DCT_PEER_$i"; deviceAddress="peer_$i"; status=WifiP2pDevice.AVAILABLE }
})
show()
val name = compose.onNodeWithText("Wi-Fi Direct 그룹").assertIsDisplayed().fetchSemanticsNode().boundsInRoot
val status = compose.onNodeWithTag("wifi:state").assertTextEquals("연결됨").fetchSemanticsNode().boundsInRoot
val disconnect = compose.onNodeWithTag("wifi:direct-disconnect").assertIsEnabled().assertIsDisplayed()
val row = compose.onNodeWithTag("wifi:direct-current").fetchSemanticsNode().boundsInRoot
val list = compose.onNodeWithTag("wifi:direct-list").assertIsDisplayed().fetchSemanticsNode().boundsInRoot
assertTrue(status.top >= name.bottom)
assertTrue(name.right <= disconnect.fetchSemanticsNode().boundsInRoot.left)
with(compose.density) {
assertTrue("Connected summary should fit one compact row", row.height <= 64.dp.toPx())
assertTrue("Discovery list must remain usable after connecting", list.height >= 200.dp.toPx())
}
compose.onAllNodesWithText("연결됨").assertCountEquals(1)
compose.onNodeWithText("DCT_PEER_1").assertIsDisplayed()
compose.onNodeWithText("포트 설정").assertIsNotEnabled()
val bitmap = compose.onNodeWithTag("wifi:dialog").captureToImage().asAndroidBitmap()
File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "wifi-direct-compact.png")
.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
disconnect.performClick()
compose.runOnIdle { assertEquals(1, disconnected); assertNull(selected) }
}
@Test fun disconnectingShowsProgressInTheSameRowAndBlocksRepeatedDisconnect() {
state.value = state.value.copy(phase = WifiDirectPhase.DISCONNECTING, link = link())
show()
compose.onNodeWithTag("wifi:state").assertTextEquals("연결 해제 중…")
compose.onNodeWithTag("wifi:direct-disconnect").assertIsNotEnabled().performClick()
compose.runOnIdle { assertEquals(0, disconnected) }
compose.onNodeWithText("닫기").assertIsDisplayed()
}
@Test fun compactLayoutRetainsConnectCancelPortAndFooterActions() {
show()
compose.onNodeWithText("포트 설정").assertIsEnabled().performClick()
compose.onNodeWithText("연결").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, portSettings); assertSame(peer, selected) }
compose.runOnIdle { state.value=state.value.copy(phase=WifiDirectPhase.CONNECTING, target=peer) }
compose.onNodeWithText("취소").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, cancelled); state.value=state.value.copy(phase=WifiDirectPhase.IDLE, target=null) }
compose.onNodeWithText("새로고침").assertIsEnabled().performClick()
compose.onNodeWithText("닫기").performClick()
compose.runOnIdle { assertEquals(1, refreshed); assertEquals(1, dismissed) }
}
}

51
Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiDirectPortUiTest.kt

@ -0,0 +1,51 @@
package com.dct.app.presentation.dialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
class WifiDirectPortUiTest {
@get:Rule val compose=createComposeRule()
@Test fun default5000CanBeChangedAndSaved() {
var visible by mutableStateOf(true)
var saved:Int?=null
compose.setContent { MaterialTheme {
if (visible) WifiDirectPortDialog(5000,true,{ saved=it; true },{ visible=false })
} }
compose.onNodeWithTag("wifi-direct-port").assertTextContains("5000").performTextReplacement("6000")
compose.onNodeWithText("저장").performClick()
compose.onNodeWithText("Direct 포트 설정").assertDoesNotExist()
compose.runOnIdle { assertEquals(6000,saved) }
}
@Test fun invalidPortsAreRejectedAndCancelDoesNotSave() {
var calls=0
var visible by mutableStateOf(true)
compose.setContent { MaterialTheme {
if (visible) WifiDirectPortDialog(5000,true,{ calls++; true },{ visible=false })
} }
for (invalid in listOf("","0","65536","abc","999999999999999")) {
compose.onNodeWithTag("wifi-direct-port").performTextReplacement(invalid)
compose.onNodeWithText("저장").assertIsNotEnabled()
compose.onNodeWithText("1~65535 사이의 숫자를 입력하세요.").assertIsDisplayed()
}
compose.onNodeWithText("취소").performClick()
compose.runOnIdle { assertEquals(0,calls) }
}
@Test fun activeCommunicationDisablesSavingAndRejectedSaveKeepsDialogOpen() {
var canEdit by mutableStateOf(false)
compose.setContent { MaterialTheme { WifiDirectPortDialog(5000,canEdit,{ false },{}) } }
compose.onNodeWithText("저장").assertIsNotEnabled()
compose.runOnIdle { canEdit=true }
compose.onNodeWithText("저장").performClick()
compose.onNodeWithText("통신 상태가 변경되어 저장하지 못했습니다. 연결을 해제한 뒤 다시 시도하세요.").assertIsDisplayed()
compose.onNodeWithText("Direct 포트 설정").assertIsDisplayed()
}
}

32
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialogUiTest.kt

@ -0,0 +1,32 @@
package com.dct.app.presentation.monitor.components
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class DeveloperOptionsDialogUiTest {
@get:Rule val compose = createComposeRule()
@Test fun switchShowsCurrentSettingAndChangesItWithoutClosingTheDialog() {
var enabled by mutableStateOf(true)
var visible by mutableStateOf(true)
val changes = mutableListOf<Boolean>()
compose.setContent {
if (visible) DeveloperOptionsDialog(enabled, { enabled = it; changes += it }, { visible = false })
}
compose.onNodeWithText("개발자 옵션").assertIsDisplayed()
compose.onNodeWithTag("ReceiveAutoRecoverySwitch").assertIsOn().performClick()
compose.onNodeWithTag("ReceiveAutoRecoverySwitch").assertIsOff()
compose.onNodeWithText("꺼짐").assertIsDisplayed()
compose.onNodeWithTag("ReceiveAutoRecoverySwitch").performClick().assertIsOn()
compose.onNodeWithText("켜짐").assertIsDisplayed()
compose.runOnIdle { assertEquals(listOf(false, true), changes) }
compose.onNodeWithText("닫기").performClick()
compose.onNodeWithText("개발자 옵션").assertDoesNotExist()
}
}

80
Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTimeUiTest.kt

@ -0,0 +1,80 @@
package com.dct.app.presentation.monitor.components
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import com.dct.app.presentation.main.MainUiState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicLong
class MeasurementElapsedTimeUiTest {
@get:Rule val compose = createComposeRule()
@Test fun timerIsLeftOfPacketsAndTicksWithoutNewPacketsThenClearsAndRestarts() {
val ui = mutableStateOf(MainUiState(isMeasuring = true, measurementStartedAtElapsedMillis = 1_000L))
val clock = AtomicLong(6_000L)
compose.setContent {
Row(verticalAlignment = Alignment.CenterVertically) {
MeasurementElapsedTime(ui.value, nowMillis = clock::get)
Text("Packets: 0")
}
}
val timer = compose.onNodeWithTag("MeasurementElapsedTime").assertIsDisplayed()
.assertTextEquals("재생 00:00:05").fetchSemanticsNode().boundsInRoot
val packets = compose.onNodeWithText("Packets: 0").assertIsDisplayed().fetchSemanticsNode().boundsInRoot
assertTrue(timer.right <= packets.left)
assertEquals(timer.center.y, packets.center.y, 2f)
clock.set(8_500L)
compose.mainClock.advanceTimeBy(1_100)
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:07")
// The label must reset as soon as measuring ends, even before ViewModel cleanup clears the start.
compose.runOnIdle { ui.value = ui.value.copy(isMeasuring = false) }
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00")
clock.set(20_000L)
compose.mainClock.advanceTimeBy(1_100)
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00")
clock.set(22_000L)
compose.runOnIdle { ui.value = ui.value.copy(isMeasuring = true,
measurementStartedAtElapsedMillis = 20_000L) }
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:02")
}
@Test fun noSuccessfulStartShowsZero() {
compose.setContent { MeasurementElapsedTime(MainUiState(), nowMillis = { 99_000L }) }
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:00")
}
@Test fun backgroundStopsUiTicksAndResumeIncludesTimeAsleep() {
class Owner : LifecycleOwner {
val registry = LifecycleRegistry(this)
override val lifecycle: Lifecycle get() = registry
}
lateinit var owner: Owner
compose.runOnUiThread { owner = Owner().also { it.registry.currentState = Lifecycle.State.RESUMED } }
val clock = AtomicLong(6_000L)
compose.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides owner) {
MeasurementElapsedTime(MainUiState(isMeasuring = true, measurementStartedAtElapsedMillis = 1_000L),
nowMillis = clock::get)
}
}
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:05")
compose.runOnIdle { owner.registry.currentState = Lifecycle.State.CREATED }
clock.set(30_000L)
compose.mainClock.advanceTimeBy(1_100)
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:05")
compose.runOnIdle { owner.registry.currentState = Lifecycle.State.RESUMED }
compose.onNodeWithTag("MeasurementElapsedTime").assertTextEquals("재생 00:00:29")
}
}

2
Android/app/src/main/AndroidManifest.xml

@ -16,7 +16,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<!-- AP scanning needs precise location even on Android 13+. P2P is retained but UI-disabled. -->
<!-- AP scanning and Wi-Fi Direct discovery use their respective runtime permission checks. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

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

@ -71,25 +71,39 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
val mdi = MdiViewModel(dispatchers)
val calibration = CalibrationViewModel(mdb, dispatchers)
val wifi = WifiSocketManager(metrics, operationalLogger)
val developerOptions = com.dct.app.data.preferences.DeveloperOptionsPreferences(
appContext.getSharedPreferences(
com.dct.app.data.preferences.DeveloperOptionsPreferences.FILE_NAME, Context.MODE_PRIVATE))
val packets = PacketRepositoryImpl(wifiSocketManager = wifi,
ioDispatcher = dispatchers.io, metrics = metrics)
ioDispatcher = dispatchers.io, metrics = metrics,
initialReceiveAutoRecoveryEnabled = developerOptions.receiveAutoRecoveryEnabled())
return MainViewModel(mdb, chart, tree, mdi, calibration, EventBus(), dispatchers,
wifiSocketManager = wifi, packetRepository = packets, pipelineMetrics = metrics,
ensureBackgroundExecution = ::ensureForegroundService,
operationalLogger = operationalLogger) as T
operationalLogger = operationalLogger, developerOptionsPreferences = developerOptions) as T
}
}
ViewModelProvider(this, factory)[MainViewModel::class.java]
}
val executionState: StateFlow<MeasurementExecutionState> by lazy {
val links = combine(viewModel.wifiDirectState, viewModel.wifiApState, viewModel.wifiConnectionMode) { direct, ap, mode ->
if (mode == com.dct.app.data.communication.WifiConnectionMode.DIRECT)
direct to com.dct.app.data.communication.WifiApUiState()
else com.dct.app.data.communication.WifiDirectUiState(phase = com.dct.app.data.communication.WifiDirectPhase.IDLE) to ap
}
combine(viewModel.uiState, viewModel.packetRepository.connectionState,
viewModel.wifiSocketManager.isConnected, viewModel.wifiDirectState, viewModel.wifiApState) { ui, connection, socket, p2p, ap ->
MeasurementExecutionState.from(ui, connection, socket, p2p.phase, p2p.link != null, ap.phase, ap.networkReady)
viewModel.wifiSocketManager.isConnected, links) { ui, connection, socket, link ->
MeasurementExecutionState.from(ui, connection, socket, link.first.phase, link.first.link != null,
link.second.phase, link.second.networkReady)
}.stateIn(scope, SharingStarted.Eagerly, MeasurementExecutionState.from(viewModel.uiState.value,
viewModel.packetRepository.connectionState.value, viewModel.wifiSocketManager.isConnected.value,
viewModel.wifiDirectState.value.phase, viewModel.wifiDirectState.value.link != null,
viewModel.wifiApState.value.phase, viewModel.wifiApState.value.networkReady))
if (viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT)
viewModel.wifiDirectState.value.phase else com.dct.app.data.communication.WifiDirectPhase.IDLE,
viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT && viewModel.wifiDirectState.value.link != null,
if (viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP)
viewModel.wifiApState.value.phase else com.dct.app.data.communication.WifiApPhase.IDLE,
viewModel.wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP && viewModel.wifiApState.value.networkReady))
}
/** Start while handling a visible user connection request, before P2P/TCP negotiation begins. */

1
Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt

@ -30,6 +30,7 @@ data class MeasurementExecutionState(
apPhase == com.dct.app.data.communication.WifiApPhase.CONNECTING
val title = when {
ui.recordingState == MeasurementRecordingState.STOPPING -> "DCT · 기록 마무리 중"
ui.isStreamRecovering -> "DCT · 계측 데이터 수신 복구 중"
measuring && recording -> "DCT · 계측 및 기록 중"
measuring -> "DCT · 계측 중 (기록 안 함)"
recording -> "DCT · 기록 처리 중"

11
Android/app/src/main/java/com/dct/app/data/communication/OrderedPacketWriter.kt

@ -38,7 +38,7 @@ internal class OrderedPacketWriter(
private val worker = scope.launch(Dispatchers.IO) {
try {
while (isActive) {
while (isActive && !closed.get()) {
val request = controlQueue.tryReceive().getOrNull() ?: receiveNext()
// An expired queued START/STOP must not change the peer later.
if (request.result.isCancelled) continue
@ -49,9 +49,12 @@ internal class OrderedPacketWriter(
request.result.complete(false)
throw e
}
// Match the PC sender's Sleep(5): pause after each completed
// write, including consecutive ACKs, without blocking the reader.
delay(FRAME_INTERVAL_MS)
}
} catch (e: Exception) {
if (e !is CancellationException) onFailure(e)
if (e !is CancellationException && !closed.get()) onFailure(e)
} finally {
close()
}
@ -102,4 +105,8 @@ internal class OrderedPacketWriter(
closeTransport()
}
}
private companion object {
const val FRAME_INTERVAL_MS = 5L
}
}

41
Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt

@ -48,6 +48,11 @@ class WifiApManager(context: Context,
private var scanDeadline: Runnable? = null
private var route: WifiApRoute? = null
private var availableNetwork: Network? = null
// Memory-only retry target. Explicit Disconnect clears credentials and intent.
private var recoveryDevice: WifiApDevice? = null
private var recoveryPassword: String? = null
private var recoverySsid: String? = null
private var recoveryNetwork: Network? = null
private var observer: ConnectivityManager.NetworkCallback? = null
private val connectedNetworks = linkedMapOf<Network, ConnectedWifiNetwork>()
private val receiver = object : BroadcastReceiver() {
@ -209,7 +214,7 @@ class WifiApManager(context: Context,
}
@SuppressLint("MissingPermission")
@Synchronized fun connect(device: WifiApDevice, password: String) {
@Synchronized fun connect(device: WifiApDevice, password: String, recovery: Boolean = false) {
refresh()
if (closed || !state.value.canScan) return
if (device.ssid.isBlank() || !device.security.acceptsPassword(password)) {
@ -220,7 +225,11 @@ class WifiApManager(context: Context,
state.value = state.value.copy(error = "Android 10 미만은 Wi-Fi 설정에서 AP에 연결한 뒤 현재 AP로 통신 연결을 누르세요.")
return
}
onConnecting() // Invalidate old transport/cleanup before a new AP request owns the lease.
if (!recovery) onConnecting() // Explicit network changes end the old measurement intent.
recoveryDevice = device
recoveryPassword = password
recoverySsid = device.ssid
recoveryNetwork = null
val replacing = route != null
release()
if (replacing) onLost()
@ -247,7 +256,7 @@ class WifiApManager(context: Context,
/** Fallback for Settings-connected APs; never use the default cellular/VPN network. */
@SuppressLint("MissingPermission")
@Synchronized fun connectCurrent() {
@Synchronized fun connectCurrent(recovery: Boolean = false) {
refresh()
if (closed || !state.value.permissionGranted || !state.value.enabled || state.value.busy) return
val candidates = connectivity?.allNetworks.orEmpty().filter {
@ -260,9 +269,18 @@ class WifiApManager(context: Context,
return
}
val network = candidates.single()
if (recovery) {
if (recoverySsid != null) {
if (networkSsid(connectivity?.getNetworkCapabilities(network)) != recoverySsid) return
} else if (network != recoveryNetwork) return
}
val name = connectedNetworks[network]?.displayName ?: "현재 Wi-Fi"
if (route?.network == network) { onReady(route!!.ssid); return }
onConnecting()
if (!recovery) onConnecting()
recoveryDevice = null
recoveryPassword = null
recoverySsid = networkSsid(connectivity?.getNetworkCapabilities(network))
recoveryNetwork = network
val replacing = route != null
release()
if (replacing) onLost()
@ -323,7 +341,21 @@ class WifiApManager(context: Context,
@Synchronized fun currentRoute(): WifiApRoute = route?.copy(endpoint = endpointProvider())
?: throw IOException("Wi-Fi 연결/IP 할당이 준비되지 않았습니다.")
/** Restore only the previously selected AP; do not adopt an unrelated default Wi-Fi. */
@Synchronized fun recoverLastConnection(): Boolean {
if (closed) return false
if (route != null) return true
refresh()
if (state.value.busy || !state.value.enabled || !state.value.permissionGranted) return false
val device = recoveryDevice
if (device != null) connect(device, recoveryPassword ?: "", recovery = true)
else if (recoveryNetwork != null) connectCurrent(recovery = true)
return route != null
}
@Synchronized fun disconnect() {
recoveryDevice = null; recoveryPassword = null; recoverySsid = null; recoveryNetwork = null
release()
state.value = state.value.copy(phase = WifiApPhase.IDLE, localAddress = null, selectedSsid = null, activeNetworkKey = null, error = null)
}
@ -344,6 +376,7 @@ class WifiApManager(context: Context,
@Synchronized override fun close() {
if (closed) return
closed = true
recoveryDevice = null; recoveryPassword = null; recoverySsid = null; recoveryNetwork = null
release()
observer?.let { try { connectivity?.unregisterNetworkCallback(it) } catch (_: IllegalArgumentException) { } }
observer = null

7
Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionMode.kt

@ -0,0 +1,7 @@
package com.dct.app.data.communication
/** One selected network path owns the application's TCP session. */
enum class WifiConnectionMode(val label: String) {
TCP("Wi-Fi TCP"),
DIRECT("Wi-Fi Direct")
}

13
Android/app/src/main/java/com/dct/app/data/communication/WifiConnectionPreferences.kt

@ -16,6 +16,14 @@ data class WifiConnectionInput(val ip: String = "", val port: String = "") {
/** App-local manual connection draft, independent of project and connection success. */
class WifiConnectionPreferences(private val preferences: SharedPreferences) {
fun loadDirectPort(): Int = preferences.getInt("direct_port", DEFAULT_DIRECT_PORT)
.takeIf { it in 1..65535 } ?: DEFAULT_DIRECT_PORT
fun saveDirectPort(port: Int) {
require(port in 1..65535) { "TCP port must be between 1 and 65535" }
preferences.edit().putInt("direct_port", port).apply()
}
fun load() = WifiConnectionInput(preferences.getString("manual_ip", "").orEmpty(),
preferences.getString("manual_port", "").orEmpty())
@ -23,5 +31,8 @@ class WifiConnectionPreferences(private val preferences: SharedPreferences) {
preferences.edit().putString("manual_ip", input.ip).putString("manual_port", input.port).apply()
}
companion object { const val FILE_NAME = "wifi_connection_input" }
companion object {
const val FILE_NAME = "wifi_connection_input"
const val DEFAULT_DIRECT_PORT = 5000
}
}

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

@ -38,9 +38,13 @@ class WifiSocketManager(
private var serverJob: Job? = null
private var readJob: Job? = null
private val connectionGeneration = java.util.concurrent.atomic.AtomicLong()
val recoveryGeneration: Long get() = connectionGeneration.get()
@Volatile private var clientRecoveryEndpoint: WifiApEndpoint? = null
val recoveryEndpoint: WifiApEndpoint? get() = clientRecoveryEndpoint
private val ingressSequenceCounter = java.util.concurrent.atomic.AtomicLong()
@Volatile private var wifiDirectRouteProvider: (() -> WifiDirectRoute)? = null
@Volatile private var wifiApRouteProvider: (() -> WifiApRoute)? = null
@Volatile private var recoveryNetworkProvider: (() -> Boolean)? = null
private var connectingSocket: Socket? = null
/** Production sockets must use a verified current P2P group interface/address. */
@ -53,8 +57,14 @@ class WifiSocketManager(
wifiApRouteProvider = provider
}
fun setRecoveryNetworkProvider(provider: (() -> Boolean)?) { recoveryNetworkProvider = provider }
/** A temporary AP loss keeps the measurement owner's generation and recovery target. */
@Synchronized fun pauseForNetworkRecovery() { disconnectActiveSocketOnly() }
@Synchronized
private fun nextConnectionGeneration(): Long {
clientRecoveryEndpoint = null
connectingSocket?.close()
connectingSocket = null
return connectionGeneration.incrementAndGet()
@ -86,6 +96,10 @@ class WifiSocketManager(
)
val packetStream: SharedFlow<DctPacket> = _packetStream
fun diagnosticEvent(sessionId: Long, message: String) {
Log.i("WifiSocketManager", "session=$sessionId $message")
}
companion object {
const val DEFAULT_PORT = 6000 // Retained P2P/test API; AP production calls use DctWifiAp.PORT.
const val CONNECT_TIMEOUT_MS = 5000
@ -256,9 +270,10 @@ class WifiSocketManager(
}
}
private fun connectClientInternal(host: String, port: Int, generation: Long, timeoutMs: Int = CONNECT_TIMEOUT_MS) {
private fun connectClientInternal(host: String, port: Int, generation: Long, timeoutMs: Int = CONNECT_TIMEOUT_MS,
allowed: () -> Boolean = { true }) {
synchronized(this) {
if (generation != connectionGeneration.get()) throw CancellationException("Superseded connection")
if (generation != connectionGeneration.get() || !allowed()) throw CancellationException("Superseded connection")
disconnectActiveSocketOnly()
}
val apRoute = wifiApRouteProvider?.invoke()
@ -295,7 +310,11 @@ class WifiSocketManager(
"Wi-Fi TCP 연결 완료: local=${socket.localAddress.hostAddress}:${socket.localPort}, " +
"remote=$host:$port, interface=${route?.interfaceName}, p2pAddressVerified=${route != null}, networkBound=${apRoute != null || route?.network != null}"
)
synchronized(this) {
if (!allowed()) throw CancellationException("Recovery cancelled")
if (!attachSocket(socket, host, generation)) throw CancellationException("Superseded connection")
clientRecoveryEndpoint = WifiApEndpoint(host, port)
}
} catch (e: Exception) {
socket.close()
throw e
@ -304,6 +323,34 @@ class WifiSocketManager(
}
}
/** Reuse only the session owner's verified AP/P2P route; never supersede a manual connection. */
suspend fun reconnectForRecovery(generation: Long, previousSession: Long, endpoint: WifiApEndpoint): Boolean =
withContext(Dispatchers.IO) {
val owner = currentCoroutineContext()[Job]!!
synchronized(this@WifiSocketManager) {
if (generation != connectionGeneration.get() ||
(currentSessionId != 0L && currentSessionId != previousSession)) return@withContext false
}
try {
if (wifiApRouteProvider != null && recoveryNetworkProvider?.invoke() == false) return@withContext false
connectClientInternal(endpoint.host, endpoint.port, generation, allowed = { owner.isActive })
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("WifiSocketManager", "Recovery TCP connect failed: ${e.message}")
false
}
}
@Synchronized
fun cancelRecoveryConnection(generation: Long) {
if (generation == connectionGeneration.get()) {
connectingSocket?.close()
connectingSocket = null
}
}
/**
* 연결된 소켓을 활성 통신 소켓으로 등록하고 수신 루프 가동
*/
@ -344,11 +391,38 @@ class WifiSocketManager(
// 실제 하드웨어/원격 기기 바이트 스트림 수신 루프
readJob = scope.launch {
val buffer = ByteArray(4096)
val reads = java.util.concurrent.atomic.AtomicLong()
val rawBytes = java.util.concurrent.atomic.AtomicLong()
val lastRead = java.util.concurrent.atomic.AtomicLong()
val lastParsed = java.util.concurrent.atomic.AtomicLong()
val lastStream = java.util.concurrent.atomic.AtomicLong()
val emittingSince = java.util.concurrent.atomic.AtomicLong()
val healthJob = launch {
while (isActive) {
delay(5000)
val state = synchronized(this@WifiSocketManager) {
if (activeSocket !== socket) null else parser.diagnostics()
} ?: break
val now = System.nanoTime()
fun age(value: Long) = if (value == 0L) -1L else (now - value) / 1_000_000L
val pipeline = metrics.snapshot()
val message = "HEALTH reads=${reads.get()} bytes=${rawBytes.get()} " +
"rawAgeMs=${age(lastRead.get())} parsedAgeMs=${age(lastParsed.get())} streamAgeMs=${age(lastStream.get())} " +
"emitBlockedMs=${age(emittingSince.get())} subscribers=${_packetStream.subscriptionCount.value} " +
"parser=$state ack=${pipeline.ackEnqueued}/${pipeline.ackRejected} " +
"queue=${pipeline.dispatchQueueDepth} processed=${pipeline.processedPackets}"
Log.i("WifiSocketManager", "RX session=$sessionId $message")
}
}
try {
val inputStream = socket.getInputStream()
while (isActive && activeSocket === socket && !socket.isClosed) {
val bytesRead = inputStream.read(buffer)
if (bytesRead > 0) {
val rawAt = System.nanoTime()
reads.incrementAndGet()
rawBytes.addAndGet(bytesRead.toLong())
lastRead.set(rawAt)
metrics.tcpReadCalls.incrementAndGet()
metrics.tcpBytesRead.addAndGet(bytesRead.toLong())
val packets = synchronized(this@WifiSocketManager) {
@ -356,7 +430,11 @@ class WifiSocketManager(
else parser.parseStream(buffer, bytesRead)
}
metrics.parsedPackets.addAndGet(packets.size.toLong())
if (packets.isNotEmpty()) lastParsed.set(System.nanoTime())
for (packet in packets) {
if (packet.command == DctCommand.SEND_CAN_FRAME.code ||
packet.command == DctCommand.SEND_SM_DATA.code ||
packet.command == DctCommand.SEND_XCP_MEASUREMENT_DATA.code) lastStream.set(System.nanoTime())
val receivedAtNanos = System.nanoTime()
val receivedAtMillis = timestampBaseMillis +
((receivedAtNanos - timestampBaseNanos) / 1_000_000L)
@ -368,6 +446,7 @@ class WifiSocketManager(
packet.command == DctCommand.RES_STOP_TRANSMIT_DATA.code) {
Log.i("WifiSocketManager", "P2P RX control: session=$sessionId, command=${DctCommand.toHexString(packet.command)}, payload=${packet.payload.size}B")
}
emittingSince.set(System.nanoTime())
_packetStream.emit(packet.copy(
transportSessionId = sessionId,
receivedAtMillis = receivedAtMillis,
@ -375,16 +454,26 @@ class WifiSocketManager(
receivedAtSec = receivedAtSec,
ingressSequence = ingressSequence
))
emittingSince.set(0)
}
metrics.maybeLog("WifiSocketManager")
} else if (bytesRead < 0) {
diagnosticEvent(sessionId, "EOF")
Log.i("WifiSocketManager", "TCP peer EOF: session=$sessionId, host=$hostName")
break
}
}
} catch (e: IOException) {
diagnosticEvent(sessionId, "READ_ERROR localClosed=${socket.isClosed} reason=${e.message}")
Log.w("WifiSocketManager", "TCP read closed: session=$sessionId, localClosed=${socket.isClosed}, reason=${e.message}", e)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
diagnosticEvent(sessionId, "RX_WORKER_ERROR error=${e.javaClass.simpleName}: ${e.message}")
Log.e("WifiSocketManager", "TCP RX worker failed: session=$sessionId", e)
throw e
} finally {
healthJob.cancel()
synchronized(this@WifiSocketManager) {
if (activeSocket === socket) {
disconnectActiveSocketOnly()
@ -460,6 +549,7 @@ class WifiSocketManager(
@Synchronized
private fun disconnectActiveSocketOnly() {
if (activeSocket != null) diagnosticEvent(currentSessionId, "CLEANUP parser=${parser.diagnostics()}")
if (activeSocket != null) Log.i("WifiSocketManager", "TCP local cleanup: session=$currentSessionId",
Throwable("Socket cleanup caller"))
val job = readJob

14
Android/app/src/main/java/com/dct/app/data/preferences/DeveloperOptionsPreferences.kt

@ -0,0 +1,14 @@
package com.dct.app.data.preferences
import android.content.SharedPreferences
/** Settings survive app exit; access to the dialog is deliberately never persisted. */
class DeveloperOptionsPreferences(private val preferences: SharedPreferences) {
fun receiveAutoRecoveryEnabled(): Boolean = preferences.getBoolean("receive_auto_recovery", false)
fun setReceiveAutoRecoveryEnabled(enabled: Boolean) {
preferences.edit().putBoolean("receive_auto_recovery", enabled).apply()
}
companion object { const val FILE_NAME = "developer_options" }
}

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

@ -40,6 +40,32 @@ class SlidingRingBufferParser(
val recoveredCommand: Byte
)
enum class DiscardReason { NO_HEADER, UNKNOWN_COMMAND, STOP_LENGTH, INVALID_LENGTH, FOOTER, CHECKSUM, IDLE_RECOVERY, CAPACITY }
data class Diagnostics(
val inputBytes: Long, val frameBytes: Long, val frames: Long,
val pendingBytes: Int, val expectedBytes: Int?, val headerHex: String,
val discarded: Map<DiscardReason, Long>
) {
val discardedBytes: Long get() = discarded.values.sum()
}
private var inputBytes = 0L
private var frameBytes = 0L
private var frames = 0L
private val discarded = LongArray(DiscardReason.entries.size)
/** On-demand snapshot only: no per-byte diagnostic allocation while scanning noise. */
@Synchronized fun diagnostics(): Diagnostics = Diagnostics(
inputBytes, frameBytes, frames, availableBytes,
if (availableBytes >= 4) (((peek(1).toInt() and 255) shl 8) or (peek(2).toInt() and 255)) + DctPacketCodec.OVERHEAD_LENGTH else null,
(0 until minOf(8, availableBytes)).joinToString("") { "%02X".format(peek(it).toInt() and 255) },
DiscardReason.entries.associateWith { discarded[it.ordinal] }
)
private fun discard(count: Int, reason: DiscardReason) {
discarded[reason.ordinal] += count.coerceAtMost(availableBytes).toLong()
skipBytes(count)
}
private val ringBuffer = ByteArray(bufferCapacity)
private val scratchFrameBuffer = ByteArray(maxPacketSize)
private var head = 0
@ -58,6 +84,7 @@ class SlidingRingBufferParser(
if (length <= 0) return result
require(length <= buffer.size) { "Input length exceeds buffer size" }
inputBytes += length
val now = clockNanos()
if (availableBytes > 0 && hasReceivedInput && recoveryBoundary < 0 &&
now - lastInputNanos >= RECOVERY_IDLE_NANOS) {
@ -72,7 +99,7 @@ class SlidingRingBufferParser(
// Drain between chunks: a coalesced read larger than the ring must not erase older frames.
var offset = 0
while (offset < length) {
if (availableBytes == bufferCapacity) skipBytes(1) // Only an unrepresentable/noisy frame can fill it.
if (availableBytes == bufferCapacity) discard(1, DiscardReason.CAPACITY) // Only an unrepresentable/noisy frame can fill it.
val count = minOf(length - offset, bufferCapacity - availableBytes)
writeToRingBuffer(buffer, offset, count)
offset += count
@ -96,13 +123,13 @@ class SlidingRingBufferParser(
// START_BYTE가 없으면 버퍼 내 전체 데이터가 가비지이므로 폐기 후 종료
if (headerOffset == -1) {
skipBytes(availableBytes)
discard(availableBytes, DiscardReason.NO_HEADER)
break
}
// START_BYTE 앞쪽의 가비지 바이트 폐기 (tail을 0xA1 위치로 이동)
if (headerOffset > 0) {
skipBytes(headerOffset)
discard(headerOffset, DiscardReason.NO_HEADER)
}
// 2.2 최소 헤더 길이 확인: 0xA1(1) + LenH(1) + LenL(1) + CMD(1) = 4바이트 이상 필요
@ -119,14 +146,14 @@ class SlidingRingBufferParser(
val cmdByte = peek(3)
if (DctCommand.fromByte(cmdByte) == null) {
// 커맨드가 유효하지 않은 가짜 0xA1 헤더 노이즈 -> 1바이트 스킵
skipBytes(1)
discard(1, DiscardReason.UNKNOWN_COMMAND)
continue
}
// STOP ACK has only an optional one-byte status, never bulk data.
// Without this bound repeated A1 noise looks like a valid 41 KB STOP
// frame after enabling the full UInt16 telemetry length range.
if (cmdByte == DctCommand.RES_STOP_TRANSMIT_DATA.code && packLen > 2) {
skipBytes(1)
discard(1, DiscardReason.STOP_LENGTH)
continue
}
@ -134,7 +161,7 @@ class SlidingRingBufferParser(
if (packLen < 1 || expectedTotalLength < DctPacketCodec.MIN_PACKET_LENGTH || expectedTotalLength > minOf(maxPacketSize, bufferCapacity)) {
// 잘못된 길이 정보이므로 현재 0xA1은 가짜 헤더(노이즈)임!
// 1바이트만 스킵(백트래킹)하고 0xA1 다음 바이트부터 다시 재탐색
skipBytes(1)
discard(1, DiscardReason.INVALID_LENGTH)
continue
}
@ -149,7 +176,7 @@ class SlidingRingBufferParser(
val footerByte = peek(expectedTotalLength - 1)
if (footerByte != DctPacketCodec.END_BYTE) {
// 푸터가 0x7E가 아니면 가짜 헤더로 판정 -> 1바이트만 스킵하고 백트래킹
skipBytes(1)
discard(1, DiscardReason.FOOTER)
continue
}
@ -166,11 +193,13 @@ class SlidingRingBufferParser(
if (decoded != null) {
// 디코딩 및 체크섬 검증 성공 -> 실제 프레임 길이만큼 링버퍼에서 소비
skipBytes(expectedTotalLength)
frameBytes += expectedTotalLength
frames++
sequenceCounter++
result.add(decoded)
} else {
// 체크섬 검증 실패 등 -> 가짜 헤더로 판정하고 1바이트만 스킵하여 백트래킹
skipBytes(1)
discard(1, DiscardReason.CHECKSUM)
}
}
@ -204,7 +233,7 @@ class SlidingRingBufferParser(
peekBytes(header, 0, header.size)
val recovery = FrameRecovery(availableBytes, expectedLength, offset,
recoveryIdleNanos / 1_000_000L, header, command)
skipBytes(offset)
discard(offset, DiscardReason.IDLE_RECOVERY)
onFrameRecovery(recovery)
return true
}
@ -226,6 +255,7 @@ class SlidingRingBufferParser(
recoveryBoundary = -1
recoveryScanOffset = 0
recoveryIdleNanos = 0L
inputBytes = 0; frameBytes = 0; frames = 0; discarded.fill(0)
}
val availableByteCount: Int
@ -247,7 +277,7 @@ class SlidingRingBufferParser(
// 버퍼 오버플로우 방지: 초과분만큼 tail을 이동하여 가장 오래된 데이터 폐기
val overflow = (availableBytes + length) - bufferCapacity
if (overflow > 0) {
skipBytes(overflow)
discard(overflow, DiscardReason.CAPACITY)
}
val firstChunk = (bufferCapacity - head).coerceAtMost(length)

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

@ -14,6 +14,7 @@ import com.dct.app.domain.model.protocol.DctMeasurementConfiguration
import com.dct.app.domain.model.protocol.DctPacket
import com.dct.app.domain.repository.DeviceConnectionState
import com.dct.app.domain.repository.PacketRepository
import com.dct.app.domain.repository.StreamRecoveryState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -49,14 +51,46 @@ class PacketRepositoryImpl(
private val heartbeatFailureThreshold: Int = DEFAULT_HEARTBEAT_FAILURE_THRESHOLD,
private val enableHeartbeat: Boolean = true,
private val heartbeatDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val monotonicTimeMillis: () -> Long = { System.nanoTime() / 1_000_000L }
private val monotonicTimeMillis: () -> Long = { System.nanoTime() / 1_000_000L },
private val streamSilenceTimeoutMs: Long = 15_000L,
private val recoveryDataTimeoutMs: Long = 15_000L,
private val recoveryDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()),
initialReceiveAutoRecoveryEnabled: Boolean = false
) : PacketRepository {
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
private val requestMutex = Mutex()
private val pendingResponses = ConcurrentHashMap<Byte, CompletableDeferred<DctPacket>>()
/** Ambiguous response codes are blocked for this session until the late reply is drained. */
private val responseQuarantineSession = ConcurrentHashMap<Byte, Long>()
private var heartbeatJob: Job? = null
private val enableRefreshLock = Any()
private var enableRefreshJob: Job? = null
private var enableRefreshGeneration = 0L
@Volatile private var enableRefreshBlocked = false
@Volatile private var measurementRequested = false
private val recoveryEpoch = java.util.concurrent.atomic.AtomicLong()
private val validStreamGeneration = java.util.concurrent.atomic.AtomicLong()
private val lastValidStreamMillis = java.util.concurrent.atomic.AtomicLong()
private val recoveryLock = Any()
private var streamWatchdogJob: Job? = null
private var streamRecoveryJob: Job? = null
private var recoveryTransportGeneration = 0L
private var recoveryEndpoint: com.dct.app.data.communication.WifiApEndpoint? = null
@Volatile private var appliedConfiguration: DctMeasurementConfiguration? = null
private val _streamRecovery = MutableStateFlow(StreamRecoveryState.IDLE)
override val streamRecovery: StateFlow<StreamRecoveryState> = _streamRecovery.asStateFlow()
private val _receiveAutoRecoveryEnabled = MutableStateFlow(initialReceiveAutoRecoveryEnabled)
override val receiveAutoRecoveryEnabled: StateFlow<Boolean> = _receiveAutoRecoveryEnabled.asStateFlow()
override fun setReceiveAutoRecoveryEnabled(enabled: Boolean) {
synchronized(recoveryLock) {
if (_receiveAutoRecoveryEnabled.value == enabled) return
_receiveAutoRecoveryEnabled.value = enabled
// Invalidate in-flight setup as well as retries, while keeping the user's measurement intent.
cancelStreamRecoveryWork()
if (enabled && measurementRequested) startStreamWatchdog()
}
}
private val inboundPacketGeneration = java.util.concurrent.atomic.AtomicLong(0L)
private val lastInboundMillis = java.util.concurrent.atomic.AtomicLong(0L)
/**
@ -110,6 +144,16 @@ class PacketRepositoryImpl(
}
scope.launch(start = CoroutineStart.UNDISPATCHED) {
manager.isConnected.drop(1).collect { transportConnected ->
if (transportConnected && measurementRequested) {
scheduleStreamRecovery("TRANSPORT_REATTACHED")
return@collect
}
if (!transportConnected) cancelEnableRefresh()
if (!transportConnected && measurementRequested && _receiveAutoRecoveryEnabled.value) {
failPendingRequests("TCP 단절로 계측 수신을 복구합니다.")
scheduleStreamRecovery("TRANSPORT_LOST")
return@collect
}
if (!transportConnected && _connectionState.value !in setOf(
DeviceConnectionState.DISCONNECTED,
DeviceConnectionState.CONNECTING
@ -125,10 +169,16 @@ class PacketRepositoryImpl(
}
override suspend fun connect(target: String): DctResult<Unit> = withContext(ioDispatcher) {
cancelStreamRecovery()
appliedConfiguration = null
cancelEnableRefresh()
val sessionId = wifiSocketManager?.currentSessionId ?: 0L
var completed = false
try {
performHandshake().also { completed = it is DctResult.Success }
performHandshake().also {
completed = it is DctResult.Success
if (completed) enableRefreshBlocked = false
}
} finally {
// A failed/cancelled handshake cannot leave an apparently connected
// socket behind. Never invalidate a newer connection's session.
@ -141,39 +191,47 @@ class PacketRepositoryImpl(
}
}
private suspend fun performHandshake(): DctResult<Unit> = withContext(ioDispatcher) {
private suspend fun performHandshake(recovery: Long? = null): DctResult<Unit> = withContext(ioDispatcher) {
val session = wifiSocketManager?.currentSessionId ?: 0L
fun state(value: DeviceConnectionState) { if (recovery == null) transition(value) }
fun allowed(): Boolean = recovery == null || recoveryAllowed(recovery, session)
suspend fun exchange(request: DctPacket) = sendCommandWithTimeout(
request.copy(transportSessionId = session), responseTimeoutMs, isAllowed = ::allowed)
// Consume the snapshot once. MainViewModel supplies it immediately before
// each Wi-Fi callback, so a failed/stale session cannot leak its settings
// into a later reconnect.
val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null }
transition(DeviceConnectionState.CONNECTING)
val initialConfiguration = if (recovery != null) appliedConfiguration
else pendingInitialConfiguration.also { pendingInitialConfiguration = null }
state(DeviceConnectionState.CONNECTING)
if (recovery == null) {
_isStreaming.value = false
startMayHaveReachedDevice = false
playbackTransportSessionId = null
}
if (!hasLiveTransport()) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("Wi-Fi TCP 전송 계층이 연결되지 않았습니다.")
}
transition(DeviceConnectionState.HANDSHAKING)
state(DeviceConnectionState.HANDSHAKING)
// 1. SW -> HW: 0x00 (COMM_ENABLE)
val enableRes = sendCommand(DctProtocolRequests.enable())
val enableRes = exchange(DctProtocolRequests.enable())
if (enableRes !is DctResult.Success || enableRes.data == null) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext if (enableRes is DctResult.Failure) enableRes else failure("Handshake response missing for 0x00")
}
try {
com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.enable(), enableRes.data)
} catch (e: IllegalArgumentException) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("연결 초기화 0x00: ${e.message}")
}
// 2. Register before sending: the version response may arrive immediately
// or while the following environment/settings exchanges are in flight.
if (responseQuarantineSession[DctCommand.RES_VERSION.code] == wifiSocketManager?.currentSessionId) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 다시 연결하세요.")
}
val versionDeferred = CompletableDeferred<DctPacket>()
@ -181,9 +239,10 @@ class PacketRepositoryImpl(
val versionReq = DctProtocolRequests.version()
val versionSessionCommand = versionReq.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0)
try {
if (!allowed()) throw CancellationException("Recovery superseded")
if (!sendLive(versionSessionCommand)) {
pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred)
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("버전 요청(0x01) 송신에 실패했습니다.")
}
@ -195,20 +254,11 @@ class PacketRepositoryImpl(
// 3. 파란색 박스 인터리빙 시퀀스. 0x09/0x12는 payload 없이
// 보낼 수 있는 조회가 아니므로, 프로젝트 스냅샷이 있을 때만
// PC의 완전한 CAN 모드/SM 설정 payload를 추가합니다.
val envQueries = buildList {
add(DctProtocolRequests.dctEnvQuery(0x00.toByte()))
add(DctProtocolRequests.dctEnvQuery(0x03.toByte()))
initialConfiguration?.let { config ->
add(DctProtocolRequests.canMode(config.isHighSpeedCan, config.isOldModel))
if (config.sensorModules.isNotEmpty()) {
add(DctProtocolRequests.sensorModuleInfo(config.sensorModules))
}
}
}
val envQueries = environmentQueries(initialConfiguration)
for (query in envQueries) {
val res = sendCommand(query)
val res = exchange(query)
if (res !is DctResult.Success || res.data == null) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext if (res is DctResult.Failure) res else failure(
"Handshake response missing for ${DctCommand.toHexString(query.command)}"
)
@ -218,7 +268,7 @@ class PacketRepositoryImpl(
} catch (e: IllegalArgumentException) {
// connect() closes the failed TCP session without releasing
// the Wi-Fi AP, so a clean retry does not require pairing.
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("연결 초기화 ${DctCommand.toHexString(query.command)}: ${e.message}")
}
}
@ -231,7 +281,7 @@ class PacketRepositoryImpl(
} catch (e: Exception) {
if (e is CancellationException && e !is kotlinx.coroutines.TimeoutCancellationException) throw e
pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred)
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("버전 응답(0x81) 시간 초과: ${e.message}")
} finally {
pendingResponses.remove(DctCommand.RES_VERSION.code, versionDeferred)
@ -239,7 +289,7 @@ class PacketRepositoryImpl(
try {
com.dct.app.data.protocol.DctResponseValidator.validateSetup(versionReq, versionResult)
} catch (e: IllegalArgumentException) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("연결 초기화 0x01: ${e.message}")
}
@ -249,9 +299,9 @@ class PacketRepositoryImpl(
DctProtocolRequests.syncTime()
)
for (request in postHandshake) {
val result = sendCommand(request)
val result = exchange(request)
if (result !is DctResult.Success || result.data == null) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext if (result is DctResult.Failure) result else failure(
"Handshake response missing for ${DctCommand.toHexString(request.command)}"
)
@ -259,12 +309,12 @@ class PacketRepositoryImpl(
try {
com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, result.data)
} catch (e: IllegalArgumentException) {
transition(DeviceConnectionState.ERROR)
state(DeviceConnectionState.ERROR)
return@withContext failure("연결 초기화 ${DctCommand.toHexString(request.command)}: ${e.message}")
}
}
transition(DeviceConnectionState.READY)
state(DeviceConnectionState.READY)
DctResult.Success(Unit)
} finally {
if (!versionDeferred.isCompleted) {
@ -275,6 +325,11 @@ class PacketRepositoryImpl(
}
override suspend fun disconnect(): DctResult<Unit> = withContext(ioDispatcher) {
cancelStreamRecovery()
// STOP/DISABLE can still be waiting for I/O while the state is MEASURING.
// Do not let another ENABLE ACK restart queries during explicit teardown.
enableRefreshBlocked = true
cancelEnableRefresh()
stopHeartbeatLoop()
try {
if (_isStreaming.value || startMayHaveReachedDevice) stopStreaming()
@ -291,6 +346,9 @@ class PacketRepositoryImpl(
}
override suspend fun sendCommand(command: DctPacket): DctResult<DctPacket?> = withContext(ioDispatcher) {
if (measurementRequested && _streamRecovery.value == StreamRecoveryState.RECOVERING) {
return@withContext failure("계측 데이터 수신 복구 중입니다. 복구 완료 후 다시 시도하세요.")
}
sendCommandWithTimeout(command, responseTimeoutMs)
}
@ -301,11 +359,18 @@ class PacketRepositoryImpl(
*/
private suspend fun sendCommandWithTimeout(
command: DctPacket,
timeoutMs: Long
timeoutMs: Long,
onWritten: (() -> Unit)? = null,
isAllowed: () -> Boolean = { true }
): DctResult<DctPacket?> = withContext(ioDispatcher) {
val expectedResponse = expectedResponse(command.command)
val sessionCommand = command.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0)
if (command.transportSessionId != 0L && command.transportSessionId != sessionCommand.transportSessionId) {
return@withContext failure("명령의 TCP 세션이 변경되었습니다.")
}
if (!isAllowed()) throw CancellationException("Recovery superseded")
if (!hasLiveTransport()) return@withContext failure("전송 가능한 실장비 연결이 없습니다.")
if (expectedResponse == null) {
@ -314,6 +379,10 @@ class PacketRepositoryImpl(
}
requestMutex.withLock {
if (!isAllowed()) throw CancellationException("Recovery superseded while waiting for command")
if (!hasLiveTransport() || wifiSocketManager?.currentSessionId != sessionCommand.transportSessionId) {
return@withLock failure("명령 대기 중 TCP 세션이 변경되었습니다.")
}
val quarantinedSession = responseQuarantineSession[expectedResponse]
if (quarantinedSession == sessionCommand.transportSessionId) {
return@withLock failure("이전 ${DctCommand.toHexString(command.command)} 응답 지연을 정리하는 중입니다. 지연 응답 수신 후 재시도하거나 연결을 명시적으로 다시 설정하세요.")
@ -327,6 +396,7 @@ class PacketRepositoryImpl(
responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId
failure("명령 송신 실패: ${DctCommand.toHexString(command.command)}")
} else {
onWritten?.invoke()
DctResult.Success(deferred.await())
}
}
@ -358,6 +428,11 @@ class PacketRepositoryImpl(
if (_connectionState.value != DeviceConnectionState.READY) {
return@withContext failure("장비 설정은 READY 상태에서만 적용할 수 있습니다.")
}
applyMeasurementConfiguration(configuration)
}
private suspend fun applyMeasurementConfiguration(configuration: DctMeasurementConfiguration,
recovery: Long? = null): DctResult<Unit> = withContext(ioDispatcher) {
val sessionId = wifiSocketManager?.currentSessionId ?: 0L
val requests = try {
@ -427,11 +502,12 @@ class PacketRepositoryImpl(
}
for (request in requests) {
if (recovery != null && !recoveryAllowed(recovery, sessionId)) throw CancellationException("Recovery superseded")
if (request.command == DctCommand.CMD_SETUP_DID_INFO.code) {
kotlinx.coroutines.delay(100)
// PC does not wait for 0x97 in its connection setup chain.
// Await the actual write, but do not invent a mandatory ACK.
if (!sendLive(request.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0L))) {
if (!sendLive(request.copy(transportSessionId = sessionId))) {
return@withContext failure("DID 사전 설정 송신에 실패했습니다.")
}
// Controlled hardware comparison: separate the unacknowledged
@ -439,12 +515,13 @@ class PacketRepositoryImpl(
kotlinx.coroutines.delay(DID_ENV_GAP_MS)
continue
}
val result = sendCommand(request)
val result = sendCommandWithTimeout(request.copy(transportSessionId = sessionId), responseTimeoutMs,
isAllowed = { recovery == null || recoveryAllowed(recovery, sessionId) })
if (result !is DctResult.Success || result.data == null) {
// Setup cannot resume after its response budget expires, even
// if heartbeat replies keep arriving. Close only this session
// so a late ACK cannot leave an unusable READY connection.
if (result is DctResult.Failure && result.error is DctError.ProtocolError.ResponseTimeout &&
if (recovery == null && result is DctResult.Failure && result.error is DctError.ProtocolError.ResponseTimeout &&
playbackTransportSessionId != sessionId &&
wifiSocketManager?.invalidateConnectionIfSession(sessionId) == true) {
_isStreaming.value = false
@ -468,6 +545,10 @@ class PacketRepositoryImpl(
xcpIsMsbFirst = request.payload[0] == 0x01.toByte()
}
}
appliedConfiguration = configuration.copy(mdbSignals = configuration.mdbSignals.toList(),
selectedMdbSignals = configuration.selectedMdbSignals.toList(), a2lSignals = configuration.a2lSignals.toList(),
canMessages = configuration.canMessages.toList(),
sensorModules = configuration.sensorModules.map { it.copy(channels = it.channels.toList()) })
DctResult.Success(Unit)
}
@ -495,6 +576,7 @@ class PacketRepositoryImpl(
}.isSuccess) {
_isStreaming.value = true
startMayHaveReachedDevice = false
armStreamWatchdog()
transition(DeviceConnectionState.MEASURING)
DctResult.Success(Unit)
} else if (response?.payload?.let { it.size == 2 && it[1] == 1.toByte() && it[0] != 1.toByte() } == true) {
@ -509,6 +591,14 @@ class PacketRepositoryImpl(
}
override suspend fun stopStreaming(): DctResult<Unit> = withContext(ioDispatcher) {
val wasRequested = measurementRequested
cancelStreamRecovery()
if (wasRequested && !hasLiveTransport()) {
_isStreaming.value = false
startMayHaveReachedDevice = false
transition(DeviceConnectionState.DISCONNECTED)
return@withContext DctResult.Success(Unit)
}
if (_connectionState.value != DeviceConnectionState.MEASURING && !startMayHaveReachedDevice) {
return@withContext failure("현재 계측 중이 아닙니다.")
}
@ -637,9 +727,21 @@ class PacketRepositoryImpl(
}
private suspend fun collectTransportPackets(stream: SharedFlow<DctPacket>) {
var lastPacket: DctPacket? = null
var failures = 0L
var failedAt = 0L
while (true) {
try {
stream.collect { packet ->
stream.onSubscription {
if (failedAt != 0L) {
val message = "COLLECTOR_RESUBSCRIBED failures=$failures gapMs=${(System.nanoTime() - failedAt) / 1_000_000L} " +
"failedSession=${lastPacket?.transportSessionId} currentSession=${wifiSocketManager?.currentSessionId}"
android.util.Log.w("DctPacketRepository", message)
wifiSocketManager?.let { it.diagnosticEvent(it.currentSessionId, message) }
failedAt = 0
}
}.collect { packet ->
lastPacket = packet
if (packet.transportSessionId != 0L &&
packet.transportSessionId != wifiSocketManager?.currentSessionId) return@collect
// PC는 패킷 종류와 무관하게 정상 수신이 계속되면 장비가
@ -676,6 +778,13 @@ class PacketRepositoryImpl(
else -> null
}
if (ack != null) {
if (decodedStreamPayload != null) {
validStreamGeneration.incrementAndGet()
lastValidStreamMillis.set(monotonicTimeMillis())
// Cancelling recovery must not hide telemetry that subsequently resumes on its own.
if (measurementRequested && !_receiveAutoRecoveryEnabled.value &&
_connectionState.value == DeviceConnectionState.MEASURING) _isStreaming.value = true
}
val offered = offerStreamAck(
ack.copy(transportSessionId = packet.transportSessionId)
)
@ -704,6 +813,9 @@ class PacketRepositoryImpl(
"DctPacketRepository",
"RX response without waiter: session=${packet.transportSessionId}, command=${DctCommand.toHexString(packet.command)}, payload=${packet.payload.size}B"
)
if (!quarantined && packet.command == DctCommand.RES_ENABLE.code) {
scheduleEnableRefresh(packet)
}
}
// Do not discard packets when the graph is busy. The
// bounded queue makes sustained overload observable and
@ -715,13 +827,107 @@ class PacketRepositoryImpl(
}
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
// 예외 발생 시 잠시 대기 후 스트림 수집 루프 재개
} catch (error: Throwable) {
failedAt = System.nanoTime()
failures++
val message = "COLLECTOR_ERROR failures=$failures session=${lastPacket?.transportSessionId} " +
"ingress=${lastPacket?.ingressSequence} command=${lastPacket?.command?.let(DctCommand::toHexString)} " +
"error=${error.javaClass.simpleName}: ${error.message}; retry=100ms replay=0"
android.util.Log.e("DctPacketRepository", message, error)
wifiSocketManager?.let { it.diagnosticEvent(it.currentSessionId, message) }
// Preserve the existing retry policy, but expose the replay=0 loss window.
kotlinx.coroutines.delay(100)
}
}
}
private fun environmentQueries(configuration: DctMeasurementConfiguration? = null): List<DctPacket> = buildList {
add(DctProtocolRequests.dctEnvQuery(0x00.toByte()))
add(DctProtocolRequests.dctEnvQuery(0x03.toByte()))
configuration?.let {
add(DctProtocolRequests.canMode(it.isHighSpeedCan, it.isOldModel))
if (it.sensorModules.isNotEmpty()) add(DctProtocolRequests.sensorModuleInfo(it.sensorModules))
}
}
private fun cancelEnableRefresh() {
val job = synchronized(enableRefreshLock) {
enableRefreshGeneration++
enableRefreshJob.also { enableRefreshJob = null }
}
job?.cancel()
}
private fun isCurrentEnableRefresh(sessionId: Long, generation: Long): Boolean =
synchronized(enableRefreshLock) { generation == enableRefreshGeneration } &&
!enableRefreshBlocked && wifiSocketManager?.currentSessionId == sessionId && hasLiveTransport() &&
_connectionState.value in setOf(DeviceConnectionState.READY, DeviceConnectionState.MEASURING)
private fun scheduleEnableRefresh(packet: DctPacket) {
// A duplicate initial handshake ACK, NACK or malformed reply must not
// recursively restart initialization. Only an unsolicited live ACK qualifies.
if (runCatching {
com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.enable(), packet)
}.isFailure) return
val sessionId = wifiSocketManager?.currentSessionId ?: return
if (packet.transportSessionId != 0L && packet.transportSessionId != sessionId) return
if (measurementRequested) {
scheduleStreamRecovery("UNSOLICITED_ENABLE")
return
}
val job = synchronized(enableRefreshLock) {
if (_connectionState.value !in setOf(DeviceConnectionState.READY, DeviceConnectionState.MEASURING) ||
enableRefreshBlocked || !hasLiveTransport() || enableRefreshJob?.isCompleted == false) return
val generation = ++enableRefreshGeneration
scope.launch(start = CoroutineStart.LAZY) {
fun ensureCurrent() {
if (!isCurrentEnableRefresh(sessionId, generation)) throw CancellationException("Enable refresh superseded")
}
fun diagnostic(message: String) {
android.util.Log.i("DctPacketRepository", message)
wifiSocketManager?.diagnosticEvent(sessionId, message)
}
try {
ensureCurrent()
diagnostic("ENABLE_REFRESH_START session=$sessionId")
var writtenAt = 0L
suspend fun query(request: DctPacket, version: Boolean = false) {
ensureCurrent()
val result = sendCommandWithTimeout(request.copy(transportSessionId = sessionId), responseTimeoutMs,
onWritten = if (version) ({ writtenAt = monotonicTimeMillis() }) else null)
ensureCurrent()
val response = (result as? DctResult.Success)?.data
?: throw IllegalStateException((result as? DctResult.Failure)?.error?.message ?: "재조회 응답 누락")
com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, response)
}
query(DctProtocolRequests.version(), version = true)
// Match the PC's VERSION -> 200ms -> environment query gap
// from write completion, even if the VERSION reply is delayed.
val elapsed = (monotonicTimeMillis() - writtenAt).coerceAtLeast(0L)
kotlinx.coroutines.delay((VERSION_ENV_GAP_MS - elapsed).coerceAtLeast(0L))
// Re-read only: do not rewrite project/CAN/SM settings, send
// ENABLE again, or change an ongoing measurement/recording.
for (request in environmentQueries()) query(request)
ensureCurrent()
diagnostic("ENABLE_REFRESH_COMPLETE session=$sessionId")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (isCurrentEnableRefresh(sessionId, generation)) {
diagnostic("ENABLE_REFRESH_FAILED session=$sessionId reason=${e.message}")
}
} finally {
synchronized(enableRefreshLock) {
if (generation == enableRefreshGeneration) enableRefreshJob = null
}
}
}.also { enableRefreshJob = it }
}
// Never await the refresh in the collector: it must keep matching replies
// and sending stream ACKs while these requests are in flight.
job.start()
}
/**
* Stream ACKs are normally accepted immediately. If the bounded writer
* queue is briefly full, retry for a few milliseconds so a transient write
@ -737,6 +943,147 @@ class PacketRepositoryImpl(
return false
}
/** The measurement intent owns recovery; heartbeat/control replies never renew this clock. */
private fun armStreamWatchdog() {
cancelStreamRecovery()
measurementRequested = true
recoveryTransportGeneration = wifiSocketManager?.recoveryGeneration ?: 0L
recoveryEndpoint = wifiSocketManager?.recoveryEndpoint
lastValidStreamMillis.set(monotonicTimeMillis())
startStreamWatchdog()
}
private fun startStreamWatchdog() {
if (!_receiveAutoRecoveryEnabled.value) return
val epoch = recoveryEpoch.get()
if (!hasLiveTransport()) scheduleStreamRecovery("TRANSPORT_LOST_AFTER_START")
streamWatchdogJob = scope.launch(recoveryDispatcher) {
while (isActive && measurementRequested && recoveryEpoch.get() == epoch) {
kotlinx.coroutines.delay((streamSilenceTimeoutMs / 3).coerceIn(1L, 1_000L))
if (monotonicTimeMillis() - lastValidStreamMillis.get() >= streamSilenceTimeoutMs) {
scheduleStreamRecovery("STREAM_SILENCE")
}
}
}
}
private fun cancelStreamRecovery() {
measurementRequested = false
cancelStreamRecoveryWork()
}
private fun cancelStreamRecoveryWork() {
recoveryEpoch.incrementAndGet()
streamWatchdogJob?.cancel()
streamWatchdogJob = null
synchronized(recoveryLock) {
streamRecoveryJob?.cancel()
streamRecoveryJob = null
}
wifiSocketManager?.cancelRecoveryConnection(recoveryTransportGeneration)
_streamRecovery.value = StreamRecoveryState.IDLE
}
private fun recoveryAllowed(epoch: Long, session: Long? = null): Boolean =
_receiveAutoRecoveryEnabled.value && measurementRequested && recoveryEpoch.get() == epoch &&
wifiSocketManager?.recoveryGeneration == recoveryTransportGeneration &&
(session == null || (hasLiveTransport() && wifiSocketManager?.currentSessionId == session))
private fun scheduleStreamRecovery(reason: String) {
val epoch = recoveryEpoch.get()
if (!recoveryAllowed(epoch)) return
val before = validStreamGeneration.get()
val job = synchronized(recoveryLock) {
if (streamRecoveryJob?.isCompleted == false || !recoveryAllowed(epoch)) return
scope.launch(recoveryDispatcher, start = CoroutineStart.LAZY) {
if (!recoveryAllowed(epoch)) return@launch
cancelEnableRefresh()
_isStreaming.value = false
_streamRecovery.value = StreamRecoveryState.RECOVERING
var attempt = 0
var failedSession: Long? = null
fun diagnostic(message: String) {
android.util.Log.i("DctPacketRepository", message)
wifiSocketManager?.let { it.diagnosticEvent(it.currentSessionId, message) }
}
try {
diagnostic("STREAM_RECOVERY_START reason=$reason")
// A duplicate ENABLE during otherwise healthy streaming needs no reset.
if (reason == "UNSOLICITED_ENABLE") kotlinx.coroutines.delay(1_000)
if (!recoveryAllowed(epoch)) return@launch
if (validStreamGeneration.get() != before && hasLiveTransport()) {
_isStreaming.value = true
_streamRecovery.value = StreamRecoveryState.RECEIVING
diagnostic("STREAM_RECOVERY_COMPLETE reason=DATA_RESUMED_WITHOUT_RESET")
return@launch
}
while (isActive && recoveryAllowed(epoch)) {
attempt++
val manager = wifiSocketManager ?: break
diagnostic("STREAM_RECOVERY_ATTEMPT attempt=$attempt")
try {
var session = manager.currentSessionId
if (!hasLiveTransport() || session == failedSession) {
val endpoint = recoveryEndpoint
check(endpoint != null) { "TCP 서버 모드는 상대 장비의 재접속을 기다립니다." }
check(manager.reconnectForRecovery(recoveryTransportGeneration, session, endpoint)) {
"TCP 재접속 실패"
}
session = manager.currentSessionId
}
fun allowed() = recoveryAllowed(epoch, session)
suspend fun query(request: DctPacket, timeout: Long = responseTimeoutMs) {
if (!allowed()) throw CancellationException("Recovery superseded")
val result = sendCommandWithTimeout(request.copy(transportSessionId = session),
timeout, isAllowed = ::allowed)
if (!allowed()) throw CancellationException("Recovery superseded")
val response = (result as? DctResult.Success)?.data
?: throw IllegalStateException((result as? DctResult.Failure)?.error?.message ?: "응답 없음")
if (request.command != DctCommand.CMD_STOP_TRANSMIT_DATA.code || response.payload.isNotEmpty())
com.dct.app.data.protocol.DctResponseValidator.validateSetup(request, response)
}
// Establish a device-side boundary without ending the app's chart/recording session.
query(DctProtocolRequests.stop())
check(performHandshake(epoch) is DctResult.Success) { "장비 재초기화 실패" }
appliedConfiguration?.let {
check(applyMeasurementConfiguration(it, epoch) is DctResult.Success) { "계측 설정 복원 실패" }
}
val dataBeforeStart = validStreamGeneration.get()
try {
query(DctProtocolRequests.startAll(), START_STREAM_RESPONSE_TIMEOUT_MS)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Real telemetry is stronger evidence than a missing START ACK.
if (!allowed() || validStreamGeneration.get() == dataBeforeStart ||
monotonicTimeMillis() - lastValidStreamMillis.get() >= streamSilenceTimeoutMs) throw e
}
val received = kotlinx.coroutines.withTimeoutOrNull(recoveryDataTimeoutMs.coerceAtLeast(1)) {
while (allowed() && validStreamGeneration.get() == dataBeforeStart) kotlinx.coroutines.delay(25)
allowed() && validStreamGeneration.get() != dataBeforeStart
} == true
check(received) { "START 응답 후 실제 계측 데이터 없음" }
_isStreaming.value = true
_streamRecovery.value = StreamRecoveryState.RECEIVING
playbackTransportSessionId = session
diagnostic("STREAM_RECOVERY_COMPLETE attempt=$attempt session=$session")
break
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
failedSession = manager.currentSessionId
diagnostic("STREAM_RECOVERY_FAILED attempt=$attempt reason=${e.message}")
kotlinx.coroutines.delay((2_000L shl (attempt - 1).coerceAtMost(4)).coerceAtMost(30_000L))
}
}
} finally {
if (recoveryEpoch.get() == epoch && !measurementRequested) _streamRecovery.value = StreamRecoveryState.IDLE
}
}.also { streamRecoveryJob = it }
}
job.start()
}
private suspend fun sendLive(packet: DctPacket): Boolean {
wifiSocketManager?.let { manager ->
if (manager.isConnected.value && manager.sendCommand(packet)) return true
@ -867,6 +1214,10 @@ class PacketRepositoryImpl(
}
private fun closeUnresponsiveSession(sessionId: Long, consecutiveFailures: Int): Boolean {
if (measurementRequested) {
scheduleStreamRecovery("HEARTBEAT_TIMEOUT")
return false
}
if (playbackTransportSessionId == sessionId) {
android.util.Log.w("DctPacketRepository",
"재생 요청 이후 수신 지연: session=$sessionId, 정상 패킷/Heartbeat 응답이 없어도 TCP 연결을 유지합니다.")

13
Android/app/src/main/java/com/dct/app/domain/repository/PacketRepository.kt

@ -23,6 +23,14 @@ interface PacketRepository {
*/
val isStreaming: StateFlow<Boolean>
/** Recovery completes only on a valid measurement frame, never on a heartbeat/START ACK. */
val streamRecovery: StateFlow<StreamRecoveryState> get() = noStreamRecovery
val receiveAutoRecoveryEnabled: StateFlow<Boolean> get() = defaultReceiveAutoRecovery
/** Controls automatic recovery only; manual commands and stream ACKs are unaffected. */
fun setReceiveAutoRecoveryEnabled(enabled: Boolean) {}
/**
* 수신된 바이너리 패킷 실시간 스트림.
*/
@ -105,6 +113,11 @@ interface PacketRepository {
suspend fun readXcpCharacteristic(address: Long, dataType: com.dct.app.domain.model.a2l.A2lDataType): DctResult<ByteArray>
}
enum class StreamRecoveryState { IDLE, RECOVERING, RECEIVING }
private val noStreamRecovery: StateFlow<StreamRecoveryState> =
kotlinx.coroutines.flow.MutableStateFlow(StreamRecoveryState.IDLE)
private val defaultReceiveAutoRecovery: StateFlow<Boolean> = kotlinx.coroutines.flow.MutableStateFlow(false)
enum class DeviceConnectionState {
DISCONNECTED,
CONNECTING,

19
Android/app/src/main/java/com/dct/app/domain/usecase/chart/ChartBatchRenderer.kt

@ -877,18 +877,19 @@ class ChartBatchRenderer(
// panning would move an endpoint out of view. Switch to Manual first
// when historical browsing is required.
if (xAxisPreset.isHold) return
// Already following: a forward drag must not freeze or replace the live preset.
if (deltaSec > 0.0 && isXAxisAuto) return
rememberNavigationHome()
if (isXAxisAuto) setXAxisManualMode()
val baseRight = timeViewport.currentRange?.max
?: (navigationRange().max - panOffsetSec)
val latest = latestTimestamp()
val maxOffset = ((latest ?: baseRight) - baseRight).coerceAtLeast(0.0)
val wasBeforeLatest = latest != null && baseRight + panOffsetSec < latest
val nextOffset = panOffsetSec + deltaSec
if (!nextOffset.isFinite()) return
panOffsetSec = nextOffset.coerceAtMost(maxOffset)
val resumedFollowing = deltaSec > 0.0 && followLatestIfReached(baseRight + panOffsetSec, latest)
panOffsetSec = nextOffset
// Returning from history still resumes live following. A drag starting
// at or beyond the live edge may move into empty future time instead.
val resumedFollowing = deltaSec > 0.0 && wasBeforeLatest &&
followLatestIfReached(baseRight + panOffsetSec, latest)
// Measurement stop/disconnect cancels the render loop. Navigation must still publish
// frames from retained samples; markDirty alone has no consumer in that state.
if (resumedFollowing || renderJob?.isActive != true) {
@ -921,14 +922,16 @@ class ChartBatchRenderer(
val newSpan = (oldSpan / factor).coerceIn(0.05, 86400.0)
val anchor = range.min + oldSpan * anchorFraction.coerceIn(0.0, 1.0)
val newMin = anchor - newSpan * anchorFraction.coerceIn(0.0, 1.0)
timeViewport.pin(newMin, newMin + newSpan, latestTimestamp(), lockToRange = true)
val latest = latestTimestamp()
val canResumeFollowing = isXAxisAuto || (latest != null && range.max <= latest)
timeViewport.pin(newMin, newMin + newSpan, latest, lockToRange = true)
panOffsetSec = 0.0
timeWindowSec = newSpan
isAutoTimeWindow = false
isXAxisAuto = false
xAxisPreset = TimeWindowPreset.MANUAL
// Keep the pinch anchor in history; snap to live only when its right edge is reached.
followLatestIfReached(newMin + newSpan, latestTimestamp())
// A manually panned future range keeps its anchor instead of snapping back.
if (canResumeFollowing) followLatestIfReached(newMin + newSpan, latest)
requestSnapshot()
markDirty()
}

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

@ -29,13 +29,12 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.dct.app.data.communication.*
import com.dct.app.presentation.main.MainViewModel
@Composable
fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
internal fun WifiApConnectionPane(viewModel: MainViewModel, onDismiss: () -> Unit) {
val context = LocalContext.current
val state by viewModel.wifiApState.collectAsStateWithLifecycle()
val ui by viewModel.uiState.collectAsStateWithLifecycle()
@ -71,7 +70,6 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
if (searchOnReady && state.canScan) { searchOnReady = false; viewModel.scanWifiAp() }
}
// No onDispose disconnect: the application, not this sheet, owns the AP lease.
Dialog(onDismissRequest = onDismiss) {
WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket,
onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp,
onCancel = viewModel::cancelWifiApConnection,
@ -83,8 +81,8 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
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)
}
com.dct.app.presentation.main.MeasurementRecordingState.STOPPING) && !state.busy,
modifier = Modifier.fillMaxSize())
}
@Composable
@ -95,7 +93,8 @@ internal fun WifiApConnectionContent(
onPermissions: () -> Unit, onSettings: (String) -> Unit, onDismiss: () -> Unit,
endpoint: WifiApEndpoint = WifiApEndpoint(), canEditEndpoint: Boolean = true,
onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null,
communicationFailed: Boolean = false
communicationFailed: Boolean = false,
modifier: Modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth().heightIn(max = 520.dp)
) {
var selected by remember { mutableStateOf<WifiApDevice?>(null) }
var addingNetwork by remember { mutableStateOf(false) }
@ -122,8 +121,8 @@ internal fun WifiApConnectionContent(
"통신 주소: ${it.host}:${it.port}"
} ?: "통신 주소 확인 중…" else configuredAddress
Surface(shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth()) {
Column(Modifier.heightIn(max = 520.dp)) {
modifier = modifier) {
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 8.dp, top = 12.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text("Wi-Fi", Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall)
@ -180,21 +179,23 @@ internal fun WifiApConnectionContent(
Text(it, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
LazyColumn(Modifier.fillMaxWidth().weight(1f, fill = false).heightIn(max = 320.dp)
LazyColumn(Modifier.fillMaxWidth().weight(1f)
.testTag("wifi:ap-list"), state = networkListState) {
if (state.networkReady || state.busy) item(key = "current") {
WifiApNetworkRow(state.selectedSsid ?: "DCT Wi-Fi",
status = if (state.busy) "연결 중…" else connectedStatus,
address = currentAddress,
secured = false, enabled = state.networkReady,
tag = "wifi:ap-current", onClick = { showDetails = true })
tag = "wifi:ap-current", onClick = { showDetails = true },
onDisconnect = if (state.networkReady) onDisconnect else null)
}
items(state.connectedNetworks.filterNot { it.key == state.activeNetworkKey },
key = { "connected:${it.key}" }) { network ->
WifiApNetworkRow(network.displayName, status = "Wi-Fi 연결됨",
address = configuredAddress,
secured = false, enabled = true, tag = "wifi:connected:${network.key}",
onClick = { connectedDetailsKey = network.key })
onClick = { connectedDetailsKey = network.key },
onDisconnect = { onSettings(Settings.ACTION_WIFI_SETTINGS) })
}
item(key = "available-title") {
Text("사용 가능한 네트워크",
@ -312,11 +313,6 @@ internal fun WifiApConnectionContent(
Modifier.testTag("wifi:ap-endpoint"))
}
},
dismissButton = {
if (state.networkReady) TextButton(onClick = { showDetails = false; onDisconnect() }) {
Text("연결 해제")
}
},
confirmButton = {
Row {
TextButton(enabled = endpointEditable, onClick = {
@ -376,7 +372,8 @@ internal fun WifiApConnectionContent(
@Composable
private fun WifiApNetworkRow(
ssid: String, status: String? = null, secured: Boolean, enabled: Boolean,
tag: String, onClick: () -> Unit, address: String? = null
tag: String, onClick: () -> Unit, address: String? = null,
onDisconnect: (() -> Unit)? = null
) {
Row(Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 14.dp).testTag(tag),
@ -395,5 +392,10 @@ private fun WifiApNetworkRow(
}
if (secured) Icon(Icons.Default.Lock, contentDescription = "보안 네트워크",
modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
onDisconnect?.let { disconnect ->
TextButton(onClick = disconnect, modifier = Modifier.testTag("$tag:disconnect")) {
Text("연결 해제", style = MaterialTheme.typography.labelMedium)
}
}
}
}

62
Android/app/src/main/java/com/dct/app/presentation/dialog/WifiConnectionDialog.kt

@ -0,0 +1,62 @@
package com.dct.app.presentation.dialog
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.dct.app.data.communication.WifiConnectionMode
import com.dct.app.presentation.main.MainViewModel
@Composable
fun WifiConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
val mode by viewModel.wifiConnectionMode.collectAsState()
val canChange by viewModel.canChangeWifiConnectionMode.collectAsState()
WifiConnectionDialogFrame(mode, canChange, { viewModel.setWifiConnectionMode(it) }, onDismiss) {
when (mode) {
WifiConnectionMode.TCP -> WifiApConnectionPane(viewModel, onDismiss)
WifiConnectionMode.DIRECT -> WifiDirectDeviceListPane(viewModel, onDismiss)
}
}
}
/** Keep the window, size and tabs stable when only the selected network pane changes. */
@Composable
internal fun WifiConnectionDialogFrame(mode: WifiConnectionMode, canChange: Boolean,
onSelect: (WifiConnectionMode) -> Unit, onDismiss: () -> Unit, content: @Composable () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth()
.heightIn(max = 560.dp).fillMaxHeight().testTag("wifi:dialog")) {
Column(Modifier.fillMaxSize()) {
WifiConnectionModeSelector(mode, canChange, onSelect)
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
}
}
}
}
@Composable
internal fun WifiConnectionModeSelector(mode: WifiConnectionMode, enabled: Boolean,
onSelect: (WifiConnectionMode) -> Unit) {
Column {
TabRow(selectedTabIndex = mode.ordinal) {
WifiConnectionMode.entries.forEach { option ->
Tab(selected = mode == option, enabled = enabled || mode == option,
onClick = { onSelect(option) }, text = { Text(option.label) },
modifier = Modifier.testTag("wifi-mode:${option.name}"))
}
}
if (!enabled) Text("Wi-Fi 해제 후 전환할 수 있습니다.",
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall)
}
}

144
Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectDeviceListDialog.kt

@ -13,25 +13,35 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.dct.app.data.communication.*
import com.dct.app.presentation.main.MainViewModel
/** App-owned Material 3 UI; OS commands go exclusively through the ViewModel. */
@Composable
fun WifiDirectDeviceListDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
internal fun WifiDirectDeviceListPane(viewModel: MainViewModel, onDismiss: () -> Unit) {
val context = LocalContext.current
val state by viewModel.wifiDirectState.collectAsStateWithLifecycle()
val mainState by viewModel.uiState.collectAsStateWithLifecycle()
val tcpConnected by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle()
val port by viewModel.wifiDirectPort.collectAsStateWithLifecycle()
val canEditPort by viewModel.canEditWifiDirectPort.collectAsStateWithLifecycle()
var showPortSettings by remember { mutableStateOf(false) }
var searchOnReady by remember { mutableStateOf(true) }
var permissionRequested by remember { mutableStateOf(false) }
var settingsError by remember { mutableStateOf<String?>(null) }
val permissions = remember { WifiDirectRequirements.runtimePermissions(Build.VERSION.SDK_INT) }
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) viewModel.refreshWifiDirect()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
viewModel.refreshWifiDirect()
searchOnReady = it.values.all { granted -> granted }
@ -62,71 +72,105 @@ fun WifiDirectDeviceListDialog(viewModel: MainViewModel, onDismiss: () -> Unit)
// Closing the sheet does not cancel an invitation or remove a formed group.
onDispose { viewModel.stopWifiDiscovery() }
}
Dialog(onDismissRequest = onDismiss) {
Surface(shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.widthIn(max = 560.dp).fillMaxWidth()) {
Column(Modifier.padding(24.dp).heightIn(max = 660.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Wi-Fi Direct", style = MaterialTheme.typography.headlineSmall)
state.thisDevice?.let { Text("내 기기: ${it.deviceName}", style = MaterialTheme.typography.bodySmall) }
WifiDirectDeviceListContent(state.copy(error = settingsError ?: state.error), port, canEditPort,
onPortSettings = { showPortSettings = true }, onPermissions = { permissionLauncher.launch(permissions) },
onSettings = { settings(it, it == Settings.ACTION_APPLICATION_DETAILS_SETTINGS) },
onDisconnect = viewModel::removeWifiDirectGroup, onCancel = viewModel::cancelWifiDirectConnection,
onConnect = viewModel::connectWifiDirect,
onRefresh = { searchOnReady = true; viewModel.refreshWifiDirect() }, onDismiss = onDismiss)
if (showPortSettings) WifiDirectPortDialog(port, canEditPort, viewModel::setWifiDirectPort) { showPortSettings=false }
}
@Composable
internal fun WifiDirectDeviceListContent(state: WifiDirectUiState, port: Int, canEditPort: Boolean,
onPortSettings: () -> Unit, onPermissions: () -> Unit, onSettings: (String) -> Unit,
onDisconnect: () -> Unit, onCancel: () -> Unit, onConnect: (WifiP2pDevice) -> Unit,
onRefresh: () -> Unit, onDismiss: () -> Unit) {
val connectedAddresses = state.link?.peers.orEmpty().map { it.deviceAddress }.toSet()
val availablePeers = state.peers.filterNot { it.deviceAddress in connectedAddresses }
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Wi-Fi Direct", style = MaterialTheme.typography.titleMedium)
Text("TCP 포트: $port", style = MaterialTheme.typography.bodySmall)
}
TextButton(onClick = onPortSettings, enabled = canEditPort) {
Text("포트 설정", style = MaterialTheme.typography.labelMedium)
}
}
if (state.busy || state.discovering) LinearProgressIndicator(Modifier.fillMaxWidth())
Text(when (state.phase) {
if (state.link == null) Text(when (state.phase) {
WifiDirectPhase.CHECKING -> "Wi-Fi Direct 상태 확인 중..."
WifiDirectPhase.UNSUPPORTED -> "이 기기는 Wi-Fi Direct를 지원하지 않습니다."
WifiDirectPhase.DISABLED -> "Wi-Fi가 꺼져 있습니다."
WifiDirectPhase.PERMISSION_REQUIRED -> "주변 기기 권한이 필요합니다."
WifiDirectPhase.CONNECTING -> "연결 중... 상대 기기의 연결 요청을 승인해 주세요."
WifiDirectPhase.CONNECTING -> "연결 중..."
WifiDirectPhase.CANCELLING -> "연결 취소 확인 중..."
WifiDirectPhase.DISCONNECTING -> "연결 해제 확인 중..."
WifiDirectPhase.CONNECTED -> "연결됨"
WifiDirectPhase.DISCOVERING -> "검색 중..."
WifiDirectPhase.ERROR -> "작업을 완료하지 못했습니다."
WifiDirectPhase.IDLE -> "연결할 기기를 선택하세요."
}, Modifier.testTag("wifi:state"))
(settingsError ?: state.error)?.let { Text(it, color = MaterialTheme.colorScheme.error) }
WifiDirectPhase.IDLE -> "연결 대기"
}, Modifier.testTag("wifi:state"), style = MaterialTheme.typography.bodySmall)
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall) }
if (state.supported && !state.permissionGranted) {
Row {
TextButton(onClick = { permissionLauncher.launch(permissions) }) { Text("권한 허용") }
TextButton(onClick = { settings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, true) }) { Text("앱 설정") }
TextButton(onClick = onPermissions) { Text("권한 허용", style = MaterialTheme.typography.labelMedium) }
TextButton(onClick = { onSettings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) }) {
Text("앱 설정", style = MaterialTheme.typography.labelMedium)
}
}
} else if (state.supported && !state.enabled) {
TextButton(onClick = { settings(Settings.ACTION_WIFI_SETTINGS) }) { Text("Wi-Fi 설정") }
TextButton(onClick = { onSettings(Settings.ACTION_WIFI_SETTINGS) }) {
Text("Wi-Fi 설정", style = MaterialTheme.typography.labelMedium)
}
}
if (state.permissionGranted && !state.locationEnabled) {
Text("기기 검색을 위해 위치 서비스를 켜 주세요.")
TextButton(onClick = { settings(Settings.ACTION_LOCATION_SOURCE_SETTINGS) }) { Text("위치 설정") }
Text("기기 검색을 위해 위치 서비스를 켜 주세요.", style = MaterialTheme.typography.bodySmall)
TextButton(onClick = { onSettings(Settings.ACTION_LOCATION_SOURCE_SETTINGS) }) {
Text("위치 설정", style = MaterialTheme.typography.labelMedium)
}
}
state.link?.let { link ->
Text(link.displayName, style = MaterialTheme.typography.titleMedium)
Text("Group Owner IP: ${link.info.groupOwnerAddress?.hostAddress ?: "할당 대기 중"}")
Text("역할: ${if (link.info.isGroupOwner) "Group Owner" else "Client"}")
Text("인터페이스: ${link.group.`interface` ?: "확인 중"}", style = MaterialTheme.typography.bodySmall)
Text(when {
mainState.isConnected -> "DCT 통신 준비 완료 · 포트 ${WifiSocketManager.DEFAULT_PORT}"
tcpConnected -> "DCT 장비 초기화 중..."
else -> "DCT 통신 연결 대기 · 포트 ${WifiSocketManager.DEFAULT_PORT}"
})
Row {
TextButton(onClick = viewModel::removeWifiDirectGroup,
enabled = state.phase != WifiDirectPhase.DISCONNECTING) { Text("연결 해제") }
if (!mainState.isConnected) TextButton(onClick = viewModel::retryWifiDirectTcp,
enabled = state.phase == WifiDirectPhase.CONNECTED) { Text("통신 재연결") }
Row(Modifier.fillMaxWidth().testTag("wifi:direct-current"), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(link.displayName, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (state.phase == WifiDirectPhase.DISCONNECTING) "연결 해제 중…" else "연결됨",
Modifier.testTag("wifi:state"), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary)
}
TextButton(onClick = onDisconnect, modifier = Modifier.testTag("wifi:direct-disconnect"),
enabled = state.phase != WifiDirectPhase.DISCONNECTING) {
Text("연결 해제", style = MaterialTheme.typography.labelMedium)
}
}
}
if (state.phase in setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING)) {
Text(state.target?.deviceName?.ifBlank { state.target?.deviceAddress.orEmpty() } ?: "상대 기기")
TextButton(onClick = viewModel::cancelWifiDirectConnection,
enabled = state.phase == WifiDirectPhase.CONNECTING) { Text("취소") }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(state.target?.deviceName?.ifBlank { state.target?.deviceAddress.orEmpty() } ?: "상대 기기",
Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium,
maxLines = 1, overflow = TextOverflow.Ellipsis)
TextButton(onClick = onCancel, enabled = state.phase == WifiDirectPhase.CONNECTING) {
Text("취소", style = MaterialTheme.typography.labelMedium)
}
}
}
HorizontalDivider()
LazyColumn(Modifier.weight(1f, fill = false).heightIn(min = 80.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.peers.isEmpty()) item { Text("주변 Wi-Fi Direct 기기를 찾을 수 없습니다.") }
items(state.peers, key = { it.deviceAddress }) { device ->
val connected = state.link?.peers?.any { it.deviceAddress == device.deviceAddress } == true
LazyColumn(Modifier.weight(1f).fillMaxWidth().testTag("wifi:direct-list"),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (availablePeers.isEmpty()) item {
Text("주변 Wi-Fi Direct 기기를 찾을 수 없습니다.", style = MaterialTheme.typography.bodySmall)
}
items(availablePeers, key = { it.deviceAddress }) { device ->
val pending = state.target?.deviceAddress == device.deviceAddress && state.busy
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(device.deviceName.ifBlank { device.deviceAddress }, style = MaterialTheme.typography.titleSmall)
Text(device.deviceName.ifBlank { device.deviceAddress }, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(when {
connected -> "연결됨"
pending || device.status == WifiP2pDevice.INVITED -> "연결 중..."
device.status == WifiP2pDevice.FAILED -> "연결 실패 · 재시도 가능"
device.status == WifiP2pDevice.UNAVAILABLE -> "사용 불가"
@ -134,19 +178,19 @@ fun WifiDirectDeviceListDialog(viewModel: MainViewModel, onDismiss: () -> Unit)
else -> "사용 가능"
}, style = MaterialTheme.typography.bodySmall)
}
if (!connected && !pending) TextButton(
onClick = { viewModel.connectWifiDirect(device) },
if (!pending) TextButton(
onClick = { onConnect(device) },
enabled = state.canConnect && device.status in setOf(WifiP2pDevice.AVAILABLE, WifiP2pDevice.FAILED)
) { Text(if (device.status == WifiP2pDevice.FAILED) "재시도" else "연결") }
) { Text(if (device.status == WifiP2pDevice.FAILED) "재시도" else "연결",
style = MaterialTheme.typography.labelMedium) }
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { searchOnReady = true; viewModel.refreshWifiDirect() },
enabled = state.supported && !state.busy && !state.discovering) { Text("새로고침") }
TextButton(onClick = onDismiss) { Text("닫기") }
}
TextButton(onClick = onRefresh, enabled = state.supported && !state.busy && !state.discovering) {
Text("새로고침", style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = onDismiss) { Text("닫기", style = MaterialTheme.typography.labelMedium) }
}
}
}

40
Android/app/src/main/java/com/dct/app/presentation/dialog/WifiDirectPortDialog.kt

@ -0,0 +1,40 @@
package com.dct.app.presentation.dialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import com.dct.app.data.communication.WifiConnectionInput
@Composable
internal fun WifiDirectPortDialog(port: Int, canEdit: Boolean,
onSave: (Int) -> Boolean, onDismiss: () -> Unit) {
var draft by remember { mutableStateOf(port.toString()) }
var saveRejected by remember { mutableStateOf(false) }
val parsed = WifiConnectionInput(port=draft).portNumber
AlertDialog(
onDismissRequest=onDismiss,
title={ Text("Direct 포트 설정") },
text={ Column {
Text("장치와 같은 TCP 포트 번호를 입력하세요.", style=MaterialTheme.typography.bodySmall)
OutlinedTextField(value=draft, onValueChange={ draft=it; saveRejected=false },
label={ Text("TCP 포트") }, singleLine=true, isError=parsed == null,
keyboardOptions=KeyboardOptions(keyboardType=KeyboardType.Number),
modifier=Modifier.testTag("wifi-direct-port"))
if (parsed == null) Text("1~65535 사이의 숫자를 입력하세요.", color=MaterialTheme.colorScheme.error)
if (saveRejected) Text("통신 상태가 변경되어 저장하지 못했습니다. 연결을 해제한 뒤 다시 시도하세요.",
color=MaterialTheme.colorScheme.error)
} },
confirmButton={ TextButton(enabled=canEdit && parsed != null, onClick={
parsed?.let { if (onSave(it)) onDismiss() else saveRejected=true }
}) { Text("저장") } },
dismissButton={ TextButton(onClick=onDismiss) { Text("취소") } }
)
}

23
Android/app/src/main/java/com/dct/app/presentation/main/DeveloperOptionsAccess.kt

@ -0,0 +1,23 @@
package com.dct.app.presentation.main
/** Owned by the current app session, including rotation and temporary backgrounding. */
class DeveloperOptionsAccess {
private var unlocked = false
private var taps = 0
private var lastTapMillis: Long? = null
fun lock() {
unlocked = false
taps = 0
lastTapMillis = null
}
fun onTitleTap(nowMillis: Long): Boolean {
if (unlocked) return true
val previous = lastTapMillis
taps = if (previous != null && nowMillis - previous in 0L..1_000L) taps + 1 else 1
lastTapMillis = nowMillis
if (taps == 5) unlocked = true
return unlocked
}
}

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

@ -135,6 +135,8 @@ class MainActivity : ComponentActivity() {
}
override fun onDestroy() {
// Closing the UI task relocks entry even if a foreground measurement retains the runtime.
if (isFinishing) activitySession.viewModel.lockDeveloperOptions()
super.onDestroy()
if (isFinishing && programExitRequested) {
(application as DctApplication).releaseSession(activitySession)

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

@ -89,6 +89,8 @@ data class MainUiState(
val projectFileName: String? = null,
val isMdbLoading: Boolean = false,
val isMeasuring: Boolean = false,
val measurementStartedAtElapsedMillis: Long? = null,
val isStreamRecovering: Boolean = false,
val recordingState: MeasurementRecordingState = MeasurementRecordingState.IDLE,
val recordingStartedAtMillis: Long? = null,
val recordingSampleCount: Long = 0L,
@ -122,9 +124,23 @@ class MainViewModel(
),
val pipelineMetrics: PacketPipelineMetrics = PacketPipelineMetrics(),
private val ensureBackgroundExecution: () -> Boolean = { true },
private val operationalLogger: com.dct.app.data.logger.PcOperationalLogger? = null
private val operationalLogger: com.dct.app.data.logger.PcOperationalLogger? = null,
private val developerOptionsPreferences: com.dct.app.data.preferences.DeveloperOptionsPreferences? = null,
private val measurementClock: () -> Long = android.os.SystemClock::elapsedRealtime
) : ViewModel() {
private val developerOptionsAccess = DeveloperOptionsAccess()
val receiveAutoRecoveryEnabled: StateFlow<Boolean> = packetRepository.receiveAutoRecoveryEnabled
fun onDctTitleClick(): Boolean = developerOptionsAccess.onTitleTap(android.os.SystemClock.elapsedRealtime())
fun lockDeveloperOptions() = developerOptionsAccess.lock()
fun setReceiveAutoRecoveryEnabled(enabled: Boolean) {
developerOptionsPreferences?.setReceiveAutoRecoveryEnabled(enabled)
packetRepository.setReceiveAutoRecoveryEnabled(enabled)
}
val scope: CoroutineScope = CoroutineScope(dispatchers.mainImmediate + SupervisorJob())
private val calibrationMutex = kotlinx.coroutines.sync.Mutex()
private var calibrationJob: Job? = null
@ -232,6 +248,19 @@ class MainViewModel(
}
}
}
scope.launch(dispatchers.mainImmediate) {
packetRepository.streamRecovery.collect { recovery ->
val recovering = recovery == com.dct.app.domain.repository.StreamRecoveryState.RECOVERING
if (recovering) markChartDataGap()
_uiState.update { current ->
current.copy(isStreamRecovering = recovering, statusMessage = when (recovery) {
com.dct.app.domain.repository.StreamRecoveryState.RECOVERING -> "계측 데이터 수신 복구 중입니다."
com.dct.app.domain.repository.StreamRecoveryState.RECEIVING -> "계측 데이터 수신이 복구되었습니다."
else -> current.statusMessage
})
}
}
}
scope.launch(dispatchers.mainImmediate) {
packetRepository.connectionState.collect { state ->
val transportReady = state == DeviceConnectionState.READY || state == DeviceConnectionState.MEASURING
@ -759,6 +788,12 @@ class MainViewModel(
/** Debug-only display reset; never changes device settings, recording or project bindings. */
fun resetDebugValues() {
if (!com.dct.app.BuildConfig.DEBUG) return
resetMeasuredValues(resetPacketCount = true)
setStatusMessage("값을 초기화했습니다. 계측 중에는 다음 수신부터 다시 표시됩니다.")
}
/** Reset samples and live caches together while retaining the project and signal bindings. */
private fun resetMeasuredValues(resetPacketCount: Boolean) {
// A packet must finish as a whole before its values and graph samples are cleared.
synchronized(telemetryResetLock) {
clearFormulaTelemetry()
@ -766,12 +801,11 @@ class MainViewModel(
resetGraphData()
_uiState.update { current ->
current.copy(
packetCount = 0L,
packetCount = if (resetPacketCount) 0L else current.packetCount,
dashboardTiles = current.dashboardTiles.map { tile ->
tile.historyBuffer.clear()
tile.copy(value = 0.0)
},
statusMessage = "값을 초기화했습니다. 계측 중에는 다음 수신부터 다시 표시됩니다."
}
)
}
}
@ -1599,6 +1633,7 @@ class MainViewModel(
projectFileName = null,
isMeasuring = false,
packetCount = 0L,
measurementStartedAtElapsedMillis = null,
dashboardTiles = emptyList(),
currentSelection = null,
statusMessage = null
@ -1656,7 +1691,9 @@ class MainViewModel(
}
wifiSocketManager.startDualMode(
isGroupOwner = isGroupOwner, hostIp = hostIp, port = port,
onConnected = { _, remoteAddress ->
onConnected = connected@{ _, remoteAddress ->
// A returning server-mode peer belongs to the active recovery owner.
if (packetRepository.connectionState.value == DeviceConnectionState.MEASURING) return@connected
launchSessionControl {
sessionMutex.withLock {
if (generation != connectionGeneration.get()) return@withLock
@ -1717,28 +1754,90 @@ class MainViewModel(
val wifiApState = _wifiApState.asStateFlow()
private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint())
val wifiApEndpoint = _wifiApEndpoint.asStateFlow()
val canConnectCommunication = combine(
_wifiApState, _wifiApEndpoint, _uiState, wifiSocketManager.isConnected
) { ap, endpoint, ui, socket -> canStartWifiCommunication(ap, endpoint, ui, socket) }
private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState())
val wifiDirectState: StateFlow<com.dct.app.data.communication.WifiDirectUiState> = _wifiDirectState.asStateFlow()
private val _wifiConnectionMode = MutableStateFlow(com.dct.app.data.communication.WifiConnectionMode.TCP)
val wifiConnectionMode = _wifiConnectionMode.asStateFlow()
private val _wifiDirectPort = MutableStateFlow(com.dct.app.data.communication.WifiConnectionPreferences.DEFAULT_DIRECT_PORT)
val wifiDirectPort = _wifiDirectPort.asStateFlow()
val canEditWifiDirectPort = combine(_uiState, wifiSocketManager.isConnected) { ui, socket ->
isWifiCommunicationIdle(ui, socket)
}.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false)
private val wifiRouteReady = combine(_wifiApState, _wifiApEndpoint, _wifiDirectState, _wifiConnectionMode) { ap, endpoint, direct, mode ->
if (mode == com.dct.app.data.communication.WifiConnectionMode.TCP) isApCommunicationReady(ap, endpoint)
else isDirectCommunicationReady(direct)
}
val canConnectCommunication = combine(wifiRouteReady, _uiState, wifiSocketManager.isConnected) { ready, ui, socket ->
ready && isWifiCommunicationIdle(ui, socket)
}
.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false)
val canChangeWifiConnectionMode = combine(_wifiApState, _wifiDirectState, _wifiConnectionMode,
_uiState, wifiSocketManager.isConnected) { ap, direct, mode, ui, socket ->
canChangeWifiMode(ap, direct, mode, ui, socket)
}.stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false)
private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null
private fun canStartWifiCommunication(
private fun isApCommunicationReady(
ap: com.dct.app.data.communication.WifiApUiState,
endpoint: com.dct.app.data.communication.WifiApEndpoint,
ui: MainUiState,
socket: Boolean
endpoint: com.dct.app.data.communication.WifiApEndpoint
): Boolean = ap.enabled && ap.permissionGranted && !ap.busy && endpoint.isValid &&
(ap.networkReady || ap.connectedNetworks.count { it.localAddress != null } == 1) &&
(ap.networkReady || ap.connectedNetworks.count { it.localAddress != null } == 1)
private fun isDirectCommunicationReady(direct: com.dct.app.data.communication.WifiDirectUiState): Boolean =
direct.enabled && direct.permissionGranted && direct.synchronizedWithOs &&
direct.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED && direct.link?.info?.groupOwnerAddress != null
private fun isWifiCommunicationIdle(ui: MainUiState, socket: Boolean): Boolean =
!ui.isConnected && !ui.isCommunicationConnecting && !socket && !ui.isMeasuring &&
ui.recordingState !in setOf(MeasurementRecordingState.STARTING,
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING)
private fun canChangeWifiMode(ap: com.dct.app.data.communication.WifiApUiState,
direct: com.dct.app.data.communication.WifiDirectUiState, mode: com.dct.app.data.communication.WifiConnectionMode,
ui: MainUiState, socket: Boolean): Boolean = isWifiCommunicationIdle(ui, socket) &&
if (mode == com.dct.app.data.communication.WifiConnectionMode.TCP) !ap.busy && !ap.networkReady
else direct.link == null && direct.phase !in setOf(com.dct.app.data.communication.WifiDirectPhase.CONNECTING,
com.dct.app.data.communication.WifiDirectPhase.CANCELLING, com.dct.app.data.communication.WifiDirectPhase.DISCONNECTING)
fun setWifiConnectionMode(mode: com.dct.app.data.communication.WifiConnectionMode): Boolean {
if (mode == _wifiConnectionMode.value) return true
if (!canChangeWifiMode(_wifiApState.value, _wifiDirectState.value, _wifiConnectionMode.value,
_uiState.value, wifiSocketManager.isConnected.value)) return false
stopWifiDiscovery()
_wifiConnectionMode.value = mode
lastP2pKey = _wifiDirectState.value.link?.key
configureWifiRoutes()
return true
}
private fun configureWifiRoutes() {
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP) {
wifiSocketManager.setWifiDirectRouteProvider(null)
wifiSocketManager.setWifiApRouteProvider(registeredWifiApManager?.let { manager -> { manager.currentRoute() } })
wifiSocketManager.setRecoveryNetworkProvider(registeredWifiApManager?.let { manager -> {
packetRepository.streamRecovery.value == com.dct.app.domain.repository.StreamRecoveryState.RECOVERING &&
manager.recoverLastConnection()
} })
} else {
wifiSocketManager.setWifiApRouteProvider(null)
wifiSocketManager.setRecoveryNetworkProvider(null)
wifiSocketManager.setWifiDirectRouteProvider(registeredWifiDirectManager?.let { manager -> { manager.currentP2pRoute() } })
}
}
/** Only the ribbon Connect action may start TCP and the DCT handshake. */
fun connectCommunication() {
if (!isWifiCommunicationIdle(_uiState.value, wifiSocketManager.isConnected.value)) return
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT) {
val direct = _wifiDirectState.value
if (!isDirectCommunicationReady(direct)) return
val link = direct.link ?: return
connectWifi(link.displayName, link.info.groupOwnerAddress.hostAddress!!,
port = _wifiDirectPort.value, isGroupOwner = link.info.isGroupOwner)
return
}
val manager = registeredWifiApManager ?: return
if (!canStartWifiCommunication(manager.uiState.value, _wifiApEndpoint.value,
_uiState.value, wifiSocketManager.isConnected.value)) return
if (!isApCommunicationReady(manager.uiState.value, _wifiApEndpoint.value)) return
// Adopting a Settings-connected network is synchronous when IPv4 is ready.
// onReady itself deliberately never starts protocol traffic.
if (!manager.uiState.value.networkReady) manager.connectCurrent()
@ -1748,6 +1847,13 @@ class MainViewModel(
connectWifi(ap.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port)
}
fun setWifiDirectPort(port: Int): Boolean {
if (port !in 1..65535 || !isWifiCommunicationIdle(_uiState.value, wifiSocketManager.isConnected.value)) return false
wifiEndpointPreferences?.saveDirectPort(port)
_wifiDirectPort.value = port
return true
}
fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean {
if (!endpoint.isValid || _uiState.value.isCommunicationConnecting || _uiState.value.isMeasuring ||
_uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING,
@ -1758,7 +1864,7 @@ class MainViewModel(
return true
}
/** ESP32 AP is the production entry. P2P implementation below is retained, not initialized. */
/** Observe AP state without starting TCP or the device protocol. */
fun initializeWifiAp(context: Context) {
storageContext = context.applicationContext
if (registeredWifiApManager != null) return
@ -1767,18 +1873,27 @@ class MainViewModel(
com.dct.app.data.communication.WifiConnectionPreferences.FILE_NAME, Context.MODE_PRIVATE))
_wifiApEndpoint.value = com.dct.app.data.communication.WifiApEndpoint.fromInput(wifiEndpointPreferences!!.load())
?: com.dct.app.data.communication.WifiApEndpoint()
_wifiDirectPort.value = wifiEndpointPreferences!!.loadDirectPort()
scope.launch(dispatchers.io) {
runCatching { com.dct.app.data.logger.MeasurementSafExports.exportReady(context.applicationContext) }
.onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) }
}
val manager = com.dct.app.data.communication.WifiApManager(context.applicationContext,
onReady = { /* Wi-Fi/IP readiness alone must not start TCP or the DCT protocol. */ },
onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") },
onConnecting = { closeWifiTransportAfterGroupLoss(null) },
onLost = lost@{
if (_wifiConnectionMode.value != com.dct.app.data.communication.WifiConnectionMode.TCP) return@lost
if (packetRepository.connectionState.value == DeviceConnectionState.MEASURING) {
// Preserve charts/recorders while the measurement owner restores its AP/TCP route.
wifiSocketManager.pauseForNetworkRecovery()
} else closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.")
},
onConnecting = {
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP)
closeWifiTransportAfterGroupLoss(null)
},
endpointProvider = { _wifiApEndpoint.value })
registeredWifiApManager = manager
wifiSocketManager.setWifiDirectRouteProvider(null)
wifiSocketManager.setWifiApRouteProvider(manager::currentRoute)
configureWifiRoutes()
apObserver = scope.launch { manager.uiState.collect { _wifiApState.value = it } }
manager.start()
}
@ -1786,10 +1901,12 @@ class MainViewModel(
fun refreshWifiAp() { registeredWifiApManager?.refresh() }
fun scanWifiAp() { registeredWifiApManager?.scan() }
fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) {
if (registeredWifiApManager?.uiState?.value?.canScan == true)
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP &&
registeredWifiApManager?.uiState?.value?.canScan == true)
registeredWifiApManager?.connect(device, password)
}
fun connectCurrentWifiAp() {
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.TCP)
registeredWifiApManager?.connectCurrent()
}
fun cancelWifiApConnection() {
@ -1797,19 +1914,17 @@ class MainViewModel(
closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.")
}
private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState())
val wifiDirectState: StateFlow<com.dct.app.data.communication.WifiDirectUiState> = _wifiDirectState.asStateFlow()
private var p2pObserver: Job? = null
private var lastP2pKey: String? = null
/** Application context only; one manager and one receiver for this retained ViewModel. */
fun initializeWifiDirect(context: Context) {
storageContext = context.applicationContext
if (registeredWifiDirectManager != null) return
scope.launch(dispatchers.io) {
runCatching { com.dct.app.data.logger.MeasurementSafExports.exportReady(context.applicationContext) }
.onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) }
}
if (registeredWifiDirectManager != null) return
registerWifiDirectManager(com.dct.app.data.communication.WifiDirectManager(context.applicationContext))
registeredWifiDirectManager?.start()
}
@ -1819,37 +1934,36 @@ class MainViewModel(
fun stopWifiDiscovery() { registeredWifiDirectManager?.stopPeerDiscovery() }
fun connectWifiDirect(device: android.net.wifi.p2p.WifiP2pDevice) {
val manager = registeredWifiDirectManager ?: return
if (manager.uiState.value.canConnect && ensureBackgroundExecution()) manager.connect(device)
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT &&
isWifiCommunicationIdle(_uiState.value, wifiSocketManager.isConnected.value) &&
manager.uiState.value.canConnect && ensureBackgroundExecution()) manager.connect(device)
}
fun cancelWifiDirectConnection() { registeredWifiDirectManager?.cancelConnect() }
fun removeWifiDirectGroup() { registeredWifiDirectManager?.disconnect() }
fun removeWifiDirectGroup() {
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT)
disconnectCommunication(releaseWifiAp = true)
}
fun retryWifiDirectTcp() {
val state = wifiDirectState.value
val link = state.link ?: return
if (state.synchronizedWithOs && state.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED)
link.info.groupOwnerAddress?.hostAddress?.let { connectWifi(link.displayName, it, isGroupOwner = link.info.isGroupOwner) }
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT) connectCommunication()
}
fun registerWifiDirectManager(manager: com.dct.app.data.communication.WifiDirectManager) {
if (registeredWifiDirectManager === manager) return
p2pObserver?.cancel()
registeredWifiDirectManager = manager
// A future chip migration can restore the P2P initialization/UI entry.
wifiSocketManager.setWifiApRouteProvider(null)
wifiSocketManager.setWifiDirectRouteProvider { manager.currentP2pRoute() }
configureWifiRoutes()
p2pObserver = scope.launch {
manager.uiState.collect { state ->
_wifiDirectState.value = state
if (!state.synchronizedWithOs) return@collect
val link = state.link
if (link != null && state.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTED &&
link.info.groupOwnerAddress != null && link.key != lastP2pKey) {
lastP2pKey = link.key
connectWifi(link.displayName, link.info.groupOwnerAddress.hostAddress!!, isGroupOwner = link.info.isGroupOwner)
} else if (link == null && lastP2pKey != null) {
lastP2pKey = null
closeWifiTransportAfterGroupLoss()
if (_wifiConnectionMode.value == com.dct.app.data.communication.WifiConnectionMode.DIRECT &&
lastP2pKey != null && link?.key != lastP2pKey &&
(_uiState.value.isConnected || _uiState.value.isCommunicationConnecting ||
_uiState.value.isMeasuring || wifiSocketManager.isConnected.value)) {
closeWifiTransportAfterGroupLoss("Wi-Fi Direct 그룹 연결이 해제되거나 변경되었습니다.")
}
lastP2pKey = link?.key
}
}
}
@ -1880,15 +1994,9 @@ class MainViewModel(
*/
@Synchronized
fun disconnectCommunication(context: Context? = null, releaseWifiAp: Boolean = false): Job? {
if (releaseWifiAp && registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect()
registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager ->
when {
manager.uiState.value.link != null -> manager.disconnect()
manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect()
else -> closeWifiTransportAfterGroupLoss()
}
return null
}
val mode = _wifiConnectionMode.value
if (releaseWifiAp && mode == com.dct.app.data.communication.WifiConnectionMode.TCP &&
registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect()
val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately.
cancelSessionControls()
pendingWifiTarget.set(null)
@ -1906,7 +2014,14 @@ class MainViewModel(
finishMeasurementSession()
wifiSocketManager.disconnectSuspend()
// Keep the AP lease until STOP and log finalization have completed.
if (releaseWifiAp) registeredWifiApManager?.disconnect()
if (releaseWifiAp) {
if (mode == com.dct.app.data.communication.WifiConnectionMode.DIRECT) {
val direct = registeredWifiDirectManager
if (direct?.uiState?.value?.link != null) direct.disconnect()
else if (direct?.uiState?.value?.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING)
direct.cancelConnect()
} else registeredWifiApManager?.disconnect()
}
_uiState.update {
it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE,
connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.")
@ -3241,6 +3356,9 @@ class MainViewModel(
private val activeCsvLoggers = java.util.concurrent.CopyOnWriteArrayList<com.dct.app.data.logger.CsvMeasurementLogger>()
private val activeMf4Loggers = java.util.concurrent.CopyOnWriteArrayList<com.dct.app.data.logger.Mf4MeasurementLogger>()
// Protected by recordingSampleLock. CAN files use the PC's last-page boundary, independently of other sources.
private var activeCanCsvLogger: com.dct.app.data.logger.CsvMeasurementLogger? = null
private var activeCanMf4Logger: com.dct.app.data.logger.Mf4MeasurementLogger? = null
// Protected by recordingSampleLock; seeds a newly started file from the PC-like live raw state.
private val latestMf4RawValues = HashMap<String, Number>()
private var latestMf4MdbSource: MdbDatabase? = null
@ -3463,6 +3581,10 @@ class MainViewModel(
/** START ACK 성공 후에만 차트와 파일 로거를 활성화한다. */
private fun beginMeasurementSession(frequencyHz: Int) {
// Publish the new session and clear old samples as one boundary with packet processing.
synchronized(telemetryResetLock) {
resetMeasuredValues(resetPacketCount = false)
val startedAtElapsedMillis = measurementClock()
operationalLogger?.action("Started measuring")
// 같은 연결에서 계측을 다시 시작할 수 있다. 이전 세션에서 기록한
// 단절 마커의 generation 가드를 새 세션 기준으로 되돌려, 이번 세션
@ -3487,7 +3609,9 @@ class MainViewModel(
renderer.start(scope)
}
_uiState.update {
it.copy(isMeasuring = true, measurementFrequencyHz = frequencyHz, statusMessage = "실시간 계측 중")
it.copy(isMeasuring = true, measurementStartedAtElapsedMillis = startedAtElapsedMillis,
measurementFrequencyHz = frequencyHz, statusMessage = "실시간 계측 중")
}
}
if (_currentProject.value?.autostartMeasuringRecording == true) {
startMeasurementRecording()
@ -3533,6 +3657,8 @@ class MainViewModel(
val currentProj = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject()
activeCsvLoggers.clear()
activeMf4Loggers.clear()
activeCanCsvLogger = null
activeCanMf4Logger = null
val outDir = if (currentProj.measuredFolderUri != null) {
val appContext = storageContext
if (appContext == null) {
@ -3631,14 +3757,20 @@ class MainViewModel(
if (currentProj.writeCsv) {
com.dct.app.data.logger.CsvMeasurementLogger(outDir, canSignals, fnCsv, canColumns,
withMilliseconds = csvWithMilliseconds).also {
if (it.start() != null) activeCsvLoggers.add(it)
if (it.start() != null) {
activeCsvLoggers.add(it)
activeCanCsvLogger = it
}
else setStatusMessage("CSV 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)")
}
}
if (currentProj.writeMf4) {
com.dct.app.data.logger.Mf4MeasurementLogger(outDir, canSignals, fnMf4,
canDefinitions.map { com.dct.app.data.logger.PcMf4Channel.dbc(it) }).also {
if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) activeMf4Loggers.add(it)
if (it.start(recordingStartMillis, latestMf4RawValues, _liveTelemetryValues.value) != null) {
activeMf4Loggers.add(it)
activeCanMf4Logger = it
}
else setStatusMessage("MF4 기록 시작 실패: ${outDir.absolutePath} (신호 계측은 계속됩니다)")
}
}
@ -3801,6 +3933,8 @@ class MainViewModel(
activeCsvLoggers.clear()
for (logger in activeMf4Loggers) logger.stop()
activeMf4Loggers.clear()
activeCanCsvLogger = null
activeCanMf4Logger = null
val stage = activeRecordingStage ?: return@synchronized
activeRecordingStage = null
val appContext = storageContext ?: return@synchronized
@ -3942,6 +4076,7 @@ class MainViewModel(
val payload = packet.payload
val telemetryMap = mutableMapOf<String, Double>()
var isLastCanPage = false
// Preserve raw values during measurement, even if recording starts midway through this packet.
val rawRecordingValues = if (isMeasuring) mutableMapOf<String, Number>() else null
@ -4027,6 +4162,9 @@ class MainViewModel(
}
}
is com.dct.app.data.protocol.DctStreamPayloadParser.CanStreamPayload.Can -> {
// PC ProcessCANDBCData records only when curPage == TotPage.
// Keep decoding and plotting every page; only CAN file writes wait for this boundary.
isLastCanPage = stream.currentPage == stream.totalPages
a3CanPackets++
val definitions = _canSignals.value
if (preparedCanSource !== definitions) {
@ -4132,7 +4270,8 @@ class MainViewModel(
// Only newly received signals create samples. Other packet families must not
// repeat cached values in every graph and every measurement file.
maybeLogA3GraphDiagnostic(telemetryMap.size)
if (telemetryMap.isEmpty()) return
// An empty/NC/unmatched final page still commits the values received on earlier pages.
if (telemetryMap.isEmpty() && !isLastCanPage) return
val updatedSignals = telemetryMap.keys.toMutableSet()
updateFormulaTelemetry(telemetryMap, updatedSignals)
@ -4168,15 +4307,22 @@ class MainViewModel(
(activeCsvLoggers.isNotEmpty() || activeMf4Loggers.isNotEmpty())) {
var wroteSample = false
for (logger in activeCsvLoggers) {
if (logger.signalHeaders.none { it.first in updatedSignals }) continue
if (logger === activeCanCsvLogger) {
if (!isLastCanPage) continue
} else if (logger.signalHeaders.none { it.first in updatedSignals }) continue
val sampleValues = logger.signalHeaders.map { (sigName, _) -> telemetryMap[sigName] }
logger.logSample(elapsedSec, sampleValues)
wroteSample = true
}
for (logger in activeMf4Loggers) {
if (logger.signalHeaders.none { it.first in updatedSignals }) continue
val isCanLogger = logger === activeCanMf4Logger
if (isCanLogger) {
if (!isLastCanPage) continue
} else if (logger.signalHeaders.none { it.first in updatedSignals }) continue
val sampleValues = logger.rawValueKeys.mapIndexed { index, key ->
if (logger.physicalInputColumns[index]) telemetryMap[key] else rawRecordingValues?.get(key)
if (logger.physicalInputColumns[index]) telemetryMap[key]
else if (isCanLogger) latestMf4RawValues[key]
else rawRecordingValues?.get(key)
}
logger.logRawSampleAtMillis(packet.receivedAtMillis.takeIf { it > 0 }
?: (elapsedSec * 1000).toLong(), sampleValues)
@ -4244,6 +4390,8 @@ class MainViewModel(
}
private fun finishMeasurementSession() {
// Clear the playback label before file compression/finalization can delay cleanup.
_uiState.update { it.copy(measurementStartedAtElapsedMillis = null) }
if (_uiState.value.isMeasuring) operationalLogger?.action("Stopped measuring")
// STOP ACK/연결 단절 모두 마지막 샘플과 다음 계측 세션 사이를
// 명시적으로 끊는다. NaN 마커는 렌더러에서 path를 종료하므로

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

@ -69,7 +69,7 @@ import com.dct.app.presentation.main.MainViewModel
import com.dct.app.presentation.main.ProjectLoadState
import com.dct.app.presentation.mdi.MdiWindow
import com.dct.app.presentation.mdi.MdiWindowType
import com.dct.app.presentation.dialog.WifiApConnectionDialog
import com.dct.app.presentation.dialog.WifiConnectionDialog
import com.dct.app.presentation.dialog.OpenProjectDialog
import com.dct.app.presentation.dialog.SelectSpecificFileDialog
import com.dct.app.presentation.monitor.components.WorkspaceRibbonBar
@ -136,6 +136,8 @@ fun DiagnosticDataMonitorScreen(
var showFileMenu by remember { mutableStateOf(false) }
var showWifiDialog by remember { mutableStateOf(false) }
var showMaintenanceDialog by remember { mutableStateOf(false) }
var showDeveloperOptionsDialog by remember { mutableStateOf(false) }
val receiveAutoRecoveryEnabled by viewModel.receiveAutoRecoveryEnabled.collectAsState()
var showExitConfirmDialog by remember { mutableStateOf(false) }
val programExitInProgress = (context as? com.dct.app.presentation.main.MainActivity)?.programExitRequested == true
var newProjectState by remember { mutableStateOf(com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context)) }
@ -430,10 +432,10 @@ fun DiagnosticDataMonitorScreen(
}
}
// ESP32 AP mode. Keep P2P code for a future chip, but do not initialize its receiver/route.
// Both managers observe OS state; only explicit Connect starts the selected TCP route.
LaunchedEffect(Unit) {
// viewModel.initializeWifiDirect(context)
viewModel.initializeWifiAp(context)
viewModel.initializeWifiDirect(context)
}
@ -518,10 +520,19 @@ fun DiagnosticDataMonitorScreen(
showOverwriteConfirmDialog -> showOverwriteConfirmDialog = false
showPermissionRationaleDialog -> showPermissionRationaleDialog = false
showMaintenanceDialog -> showMaintenanceDialog = false
showDeveloperOptionsDialog -> showDeveloperOptionsDialog = false
else -> showExitConfirmDialog = true
}
}
if (showDeveloperOptionsDialog) {
com.dct.app.presentation.monitor.components.DeveloperOptionsDialog(
receiveAutoRecoveryEnabled = receiveAutoRecoveryEnabled,
onReceiveAutoRecoveryChange = viewModel::setReceiveAutoRecoveryEnabled,
onDismiss = { showDeveloperOptionsDialog = false }
)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier.fillMaxSize()
@ -547,6 +558,9 @@ fun DiagnosticDataMonitorScreen(
) {
Text(
text = "DCT",
modifier = Modifier.clickable(onClickLabel = "개발자 옵션") {
if (viewModel.onDctTitleClick()) showDeveloperOptionsDialog = true
},
fontSize = if (isPortrait) 18.sp else 20.sp,
fontWeight = FontWeight.Bold,
color = ColorOnSurface
@ -1131,6 +1145,7 @@ fun DiagnosticDataMonitorScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
com.dct.app.presentation.monitor.components.MeasurementElapsedTime(mainUiState, ColorSecondary)
Text("Packets: ${mainUiState.packetCount}", fontSize = 11.sp, color = ColorSecondary)
}
}
@ -1349,9 +1364,8 @@ fun DiagnosticDataMonitorScreen(
)
}
// Wi-Fi Direct UI retained but inactive: WifiDirectDeviceListDialog(viewModel, onDismiss).
if (showWifiDialog) {
WifiApConnectionDialog(
WifiConnectionDialog(
viewModel = viewModel,
onDismiss = { showWifiDialog = false }
)

51
Android/app/src/main/java/com/dct/app/presentation/monitor/components/DeveloperOptionsDialog.kt

@ -0,0 +1,51 @@
package com.dct.app.presentation.monitor.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
fun DeveloperOptionsDialog(
receiveAutoRecoveryEnabled: Boolean,
onReceiveAutoRecoveryChange: (Boolean) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("개발자 옵션") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("수신 자동복구")
Text(if (receiveAutoRecoveryEnabled) "켜짐" else "꺼짐",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = receiveAutoRecoveryEnabled,
onCheckedChange = onReceiveAutoRecoveryChange,
modifier = Modifier.testTag("ReceiveAutoRecoverySwitch")
.semantics { contentDescription = "수신 자동복구" }
)
}
Text("수신이 중단되면 연결과 계측 설정을 복구하고 신호 재생을 다시 시작합니다.",
style = MaterialTheme.typography.bodySmall)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("닫기") } }
)
}

43
Android/app/src/main/java/com/dct/app/presentation/monitor/components/MeasurementElapsedTime.kt

@ -0,0 +1,43 @@
package com.dct.app.presentation.monitor.components
import android.os.SystemClock
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.dct.app.presentation.main.MainUiState
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/** Ticks only this label; monotonic time includes screen-off time without changing packet/graph state. */
@Composable
internal fun MeasurementElapsedTime(ui: MainUiState, color: Color = Color(0xFF535F73),
nowMillis: () -> Long = SystemClock::elapsedRealtime) {
val startedAt = ui.measurementStartedAtElapsedMillis
val lifecycleOwner = LocalLifecycleOwner.current
val readNow by rememberUpdatedState(nowMillis)
var currentMillis by remember(startedAt, ui.isMeasuring) { mutableLongStateOf(nowMillis()) }
LaunchedEffect(lifecycleOwner, startedAt, ui.isMeasuring) {
if (startedAt == null || !ui.isMeasuring) return@LaunchedEffect
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive) {
currentMillis = readNow()
delay(1_000L)
}
}
}
// Share the existing non-wrapping HH:mm:ss duration formatter; inputs here use a monotonic clock.
val elapsed = if (startedAt == null || !ui.isMeasuring) "00:00:00"
else formatRecordingElapsedTime(startedAt, currentMillis)
Text("재생 $elapsed", fontSize=11.sp, fontFamily=FontFamily.Monospace, color=color,
maxLines=1, softWrap=false, modifier=Modifier.testTag("MeasurementElapsedTime")
.semantics { contentDescription="신호 재생 경과 시간 $elapsed" })
}

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

@ -89,21 +89,25 @@ internal fun WifiConnectionRibbonButtons(
onWifiClick: () -> Unit,
onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit,
onWifiDisconnectClick: () -> Unit
onWifiDisconnectClick: () -> Unit,
direct: com.dct.app.data.communication.WifiDirectUiState? = null
) {
val connected = ui.isConnected && ui.communicationType != CommunicationType.NONE
val wifiConnected = connected && ui.communicationType == CommunicationType.WIFI
RibbonButton(
icon = Icons.Default.WifiOff,
tooltip = "Wi-Fi 연결 해제",
enabled = ap.networkReady || ap.busy,
enabled = if (direct != null) direct.link != null || direct.busy else ap.networkReady || ap.busy,
onClick = onWifiDisconnectClick
)
RibbonButton(
icon = Icons.Default.Wifi,
tooltip = if (ap.networkReady) "Wi-Fi AP 연결됨 (${ap.selectedSsid})" else "Wi-Fi AP 연결",
tooltip = if (direct != null) {
if (direct.link != null) "Wi-Fi Direct 연결됨 (${direct.link.displayName})" else "Wi-Fi Direct 연결"
} else if (ap.networkReady) "Wi-Fi AP 연결됨 (${ap.selectedSsid})" else "Wi-Fi AP 연결",
enabled = !connected || wifiConnected,
tint = if (ap.networkReady || ap.connectedNetworks.isNotEmpty()) Color(0xFF2563EB) else ColorSecondary,
tint = if (if (direct != null) direct.link != null else ap.networkReady || ap.connectedNetworks.isNotEmpty())
Color(0xFF2563EB) else ColorSecondary,
onClick = onWifiClick
)
RibbonButton(
@ -345,6 +349,8 @@ fun WorkspaceRibbonBar(
// Group 2: 통신 연결
val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE
val apState by viewModel.wifiApState.collectAsState()
val directState by viewModel.wifiDirectState.collectAsState()
val wifiMode by viewModel.wifiConnectionMode.collectAsState()
val canConnectCommunication by viewModel.canConnectCommunication.collectAsState()
val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI
@ -352,7 +358,8 @@ fun WorkspaceRibbonBar(
ui = mainUiState, ap = apState, canConnect = canConnectCommunication,
onWifiClick = onWifiClick, onConnectClick = viewModel::connectCommunication,
onDisconnectClick = onDisconnectClick,
onWifiDisconnectClick = onWifiDisconnectClick
onWifiDisconnectClick = onWifiDisconnectClick,
direct = directState.takeIf { wifiMode == com.dct.app.data.communication.WifiConnectionMode.DIRECT }
)
VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant)
@ -361,7 +368,8 @@ fun WorkspaceRibbonBar(
val canStartMeasure = isCommConnected && !isMeasuring
val canStopMeasure = isCommConnected && isMeasuring
Text("신호 재생", fontSize = 12.sp, color = ColorTextSecondary)
Text(if (mainUiState.isStreamRecovering) "수신 복구 중" else "신호 재생", fontSize = 12.sp,
color = if (mainUiState.isStreamRecovering) Color(0xFFD97706) else ColorTextSecondary)
RibbonButton(
icon = Icons.Default.PlayArrow,
tooltip = if (isCommConnected) "신호 재생 시작 (계측 시작)" else "신호 재생: 통신 연결 필요",

212
Android/app/src/test/java/com/dct/app/tests/CanPageRecordingTest.kt

@ -0,0 +1,212 @@
package com.dct.app.tests
import com.dct.app.core.theme.PcColorRef
import com.dct.app.data.logger.CsvMeasurementLogger
import com.dct.app.data.logger.Mf4MeasurementLogger
import com.dct.app.domain.model.chart.AxisPosition
import com.dct.app.domain.model.formula.FormulaDefinition
import com.dct.app.domain.model.mdb.CanSignalKey
import com.dct.app.domain.model.mdb.MdbAttrType
import com.dct.app.domain.model.mdb.SignalDefinition
import com.dct.app.domain.model.project.DpcProject
import com.dct.app.domain.model.project.DpcSensorModuleConfig
import com.dct.app.domain.model.protocol.DctPacket
import com.dct.app.presentation.main.MainUiState
import com.dct.app.presentation.main.MainViewModel
import com.dct.app.presentation.main.MeasurementRecordingState
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import java.util.zip.Inflater
@Suppress("UNCHECKED_CAST")
class CanPageRecordingTest {
@TempDir lateinit var directory: File
private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true }
private val a = SignalDefinition(0, 1, "CanA", MdbAttrType.ATTR_0_UINT8, 2.0, -10.0, "V", 0L, 0.0, 255.0,
messageId = 0x101, startBit = 0, bitLength = 8)
private val b = a.copy(idx = 2, name = "CanB", attr = MdbAttrType.ATTR_3_INT16, messageId = 0x102,
bitLength = 16, cvsA = .1, cvsB = -40.0)
private inner class Fixture(sensorAndFormula: Boolean = false) : AutoCloseable {
val owner = MainViewModelTest().also { it.setUp() }
val vm = field(owner, "viewModel").get(owner) as MainViewModel
val process = MainViewModel::class.java.getDeclaredMethod("processIncomingPacket", DctPacket::class.java,
Double::class.javaPrimitiveType).also { it.isAccessible = true }
val csv get() = (field(vm, "activeCsvLoggers").get(vm) as List<CsvMeasurementLogger>).single {
it.outputFile!!.name.endsWith("_dbc.csv") }
val mf4 get() = (field(vm, "activeMf4Loggers").get(vm) as List<Mf4MeasurementLogger>).single {
it.outputFile!!.name.endsWith("_dbc.mf4") }
var start = 0L
var sequence = 0L
init {
vm.setCurrentProject(DpcProject(projectFileName = "PAGES.dpc", canDbcFileName = "fixture.dbc",
measuredFilesFolder = directory.resolve(UUID.randomUUID().toString()).absolutePath,
writeCsv = true, writeMf4 = true, csvWithMilliseconds = true,
sensorModules = listOf(DpcSensorModuleConfig(0, isUsed = sensorAndFormula))))
(field(vm, "_canSignals").get(vm) as MutableStateFlow<List<SignalDefinition>>).value = listOf(a, b)
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<MainUiState>
ui.value = ui.value.copy(isConnected = true, isMeasuring = true)
if (sensorAndFormula) vm.addFormula(FormulaDefinition(name = "F", expression = "CanA*2"))
vm.chartRenderer.bindSignal(CanSignalKey.stable(a), a.name, AxisPosition.LEFT_0, PcColorRef.DEFAULT_DASHBOARD)
vm.chartRenderer.bindSignal(CanSignalKey.stable(b), b.name, AxisPosition.LEFT_0, PcColorRef.DEFAULT_DASHBOARD)
startRecording()
}
fun startRecording() {
vm.startMeasurementRecording()
assertEquals(MeasurementRecordingState.RECORDING, vm.uiState.value.recordingState)
start = vm.uiState.value.recordingStartedAtMillis!!
}
fun can(page: Int, total: Int, offset: Long, vararg frames: Pair<Int, ByteArray>, notReceived: Boolean = false) {
val payload = byteArrayOf(2, page.toByte(), total.toByte(), frames.size.toByte()) + frames.fold(byteArrayOf()) { out, (id, data) ->
out + byteArrayOf((id ushr 8).toByte(), id.toByte(), (data.size or if (notReceived) 128 else 0).toByte()) + data
}
receive(DctPacket(0xA3.toByte(), payload, receivedAtMillis = start + offset, ingressSequence = ++sequence))
}
fun receive(packet: DctPacket) = process.invoke(vm, packet, packet.receivedAtMillis / 1000.0)
fun stop() = vm.stopMeasurementRecording()
override fun close() {
stop()
vm.scope.cancel()
vm.chartRenderer.stop()
owner.tearDown()
}
}
private fun csvRows(file: File) = file.readLines().drop(7).filter { it.isNotBlank() }
.map { line -> line.split(',').map(String::trim) }
private fun binary(file: File) = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN)
private fun records(file: File): ByteBuffer {
val data = binary(file)
val recordSize = data.getInt(232 + 96)
val hl = data.getLong(168 + 40).toInt()
val dl = data.getLong(hl + 24).toInt()
if (dl == 0) return ByteBuffer.allocate(0)
val dz = data.getLong(dl + 32).toInt()
val rawSize = data.getLong(dz + 32).toInt()
val compressedSize = data.getLong(dz + 40).toInt()
val transposed = ByteArray(rawSize)
val inflater = Inflater()
try {
inflater.setInput(data.array(), dz + 48, compressedSize)
assertEquals(rawSize, inflater.inflate(transposed))
assertTrue(inflater.finished())
} finally { inflater.end() }
val count = rawSize / recordSize
val raw = ByteArray(rawSize)
for (column in 0 until recordSize) for (row in 0 until count) {
raw[row * recordSize + column] = transposed[column * count + row]
}
return ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)
}
@Test fun fourPagesUpdateGraphsImmediatelyButWriteOneFullSnapshotAtTheLastPage() {
Fixture().use { f ->
val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!!
f.can(1, 4, 100, 0x101 to byteArrayOf(12))
f.can(2, 4, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1)) // Raw 500.
f.can(3, 4, 300, 0x101 to byteArrayOf(20))
assertEquals(0L, f.vm.uiState.value.recordingSampleCount)
assertEquals(2, f.vm.chartRenderer.seriesListFlow.value.first { it.signalName == "CanA" }.buffer.size)
assertEquals(30.0, f.vm.liveTelemetryValues.value["CanA"])
f.can(4, 4, 400, 0x102 to byteArrayOf(0x58, 2)) // Raw 600.
assertEquals(1L, f.vm.uiState.value.recordingSampleCount)
f.stop()
val rows = csvRows(csv)
assertEquals(1, rows.size)
assertEquals("0", rows.single()[1])
assertEquals(30.0, rows.single()[2].toDouble())
assertEquals(20.0, rows.single()[3].toDouble())
assertEquals(1L, binary(mf4).getLong(232 + 80))
val raw = records(mf4)
assertEquals(.4, raw.double, 0.000001)
assertEquals(20, raw.get().toInt() and 255)
assertEquals(600, raw.short.toInt())
assertFalse(raw.hasRemaining())
}
}
@ParameterizedTest @ValueSource(ints = [0, 1, 2])
fun finalPageWithNoNewDecodedSignalsStillWritesEarlierPageValues(lastKind: Int) {
Fixture().use { f ->
val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!!
f.can(1, 2, 100, 0x101 to byteArrayOf(12))
assertEquals(0L, f.vm.uiState.value.recordingSampleCount)
when (lastKind) {
0 -> f.can(2, 2, 200)
1 -> f.can(2, 2, 200, 0x777 to byteArrayOf(1))
else -> f.can(2, 2, 200, 0x102 to byteArrayOf(0, 0), notReceived = true)
}
f.stop()
assertEquals(14.0, csvRows(csv).single()[2].toDouble())
assertEquals("No value", csvRows(csv).single()[3])
val raw = records(mf4)
assertEquals(.2, raw.double, 0.000001)
assertEquals(12, raw.get().toInt() and 255)
assertEquals(0, raw.short.toInt())
assertEquals(1L, f.vm.uiState.value.recordingSampleCount)
}
}
@ParameterizedTest @ValueSource(ints = [0, 1])
fun equalSinglePageNumbersRecordEveryPacket(page: Int) {
Fixture().use { f ->
val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!!
f.can(page, page, 100, 0x101 to byteArrayOf(12))
f.can(page, page, 200, 0x101 to byteArrayOf(20))
f.stop()
assertEquals(2, csvRows(csv).size)
assertEquals(2L, binary(mf4).getLong(232 + 80))
}
}
@Test fun interleavedSensorAndFormulaRecordingDoesNotWriteAnExtraCanRow() {
Fixture(sensorAndFormula = true).use { f ->
val loggers = field(f.vm, "activeCsvLoggers").get(f.vm) as List<CsvMeasurementLogger>
val csv = f.csv.outputFile!!; val mf4 = f.mf4.outputFile!!
val formula = loggers.single { it.outputFile!!.name.endsWith("_formula.csv") }.outputFile!!
val sensor = loggers.single { it.outputFile!!.name.endsWith("_sm.csv") }.outputFile!!
f.can(1, 2, 100, 0x101 to byteArrayOf(12))
f.receive(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 25, 0, 0), receivedAtMillis = f.start + 150))
f.can(2, 2, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1))
f.stop()
assertEquals(1, csvRows(csv).size)
assertEquals(1L, binary(mf4).getLong(232 + 80))
assertEquals(1, csvRows(formula).size)
assertEquals(28.0, csvRows(formula).single()[2].toDouble())
assertEquals(1, csvRows(sensor).size)
assertEquals(25.0, csvRows(sensor).single()[2].toDouble())
assertEquals(3L, f.vm.uiState.value.recordingSampleCount)
}
}
@Test fun stoppingBeforeTheLastPageAndRestartingKeepsFilesAndCountersSeparate() {
Fixture().use { f ->
val firstCsv = f.csv.outputFile!!; val firstMf4 = f.mf4.outputFile!!
f.can(1, 2, 100, 0x101 to byteArrayOf(12))
f.stop()
assertTrue(csvRows(firstCsv).isEmpty())
assertEquals(0L, binary(firstMf4).getLong(232 + 80))
f.startRecording()
val secondCsv = f.csv.outputFile!!; val secondMf4 = f.mf4.outputFile!!
f.can(2, 2, 200, 0x102 to byteArrayOf(0xF4.toByte(), 1))
f.stop()
assertEquals(1, csvRows(secondCsv).size)
assertEquals(14.0, csvRows(secondCsv).single()[2].toDouble())
assertEquals(1L, binary(secondMf4).getLong(232 + 80))
val raw = records(secondMf4)
raw.double
assertEquals(12, raw.get().toInt() and 255)
assertEquals(500, raw.short.toInt())
assertEquals(1L, f.vm.uiState.value.recordingSampleCount)
}
}
}

46
Android/app/src/test/java/com/dct/app/tests/ChartNavigationTest.kt

@ -70,6 +70,7 @@ class ChartNavigationTest {
@Test fun forwardPanAfterZoomAlsoUsesCurrentLatestData() {
val r = renderer()
r.zoomTimeWindow(2f, .5)
r.panTimeWindow(-10.0) // Browse history before testing return to newer live data.
r.sample(2000.0)
r.panTimeWindow(10000.0)
assertTrue(r.snapshot().timeMaxSec >= 2000.0)
@ -95,7 +96,7 @@ class ChartNavigationTest {
@Test fun zoomOutReachingLatestEdgeResumesWithoutAnotherDrag() {
val r = renderer()
r.panTimeWindow(-50.0)
r.panTimeWindow(-350.0) // Move the right edge past the initial 300-second future margin.
r.zoomTimeWindow(.5f, .5)
assertEquals(1000.0, r.renderSnapshotFlow.value.timeMaxSec)
assertTrue(r.renderSnapshotFlow.value.isXAxisAuto)
@ -156,16 +157,55 @@ class ChartNavigationTest {
assertEquals(.05, r.snapshot().span(), 1e-6)
}
@Test fun forwardDragWhileFollowingPreservesFixedPresetAndInitialFutureMargin() {
@Test fun forwardDragWhileFollowingMovesBeyondInitialFutureMarginAndPinsTheRange() {
val r = renderer()
r.clearAllData()
r.sample(2000.0)
val before = r.snapshot()
assertTrue(before.timeMaxSec > 2000.0)
r.panTimeWindow(10.0)
assertEquals(before.timeMaxSec, r.snapshot().timeMaxSec)
assertEquals(before.timeMaxSec + 10.0, r.snapshot().timeMaxSec)
assertEquals(before.span(), r.snapshot().span())
assertEquals(TimeWindowPreset.MANUAL, r.snapshot().xAxisPreset)
assertFalse(r.snapshot().isXAxisAuto)
r.panTimeWindow(10.0)
assertEquals(before.timeMaxSec + 20.0, r.snapshot().timeMaxSec)
r.sample(2100.0)
assertEquals(before.timeMaxSec + 20.0, r.snapshot().timeMaxSec)
r.panTimeWindow(-5.0)
assertEquals(before.timeMaxSec + 15.0, r.snapshot().timeMaxSec)
r.resetPan()
assertEquals(TimeWindowPreset.MIN_1, r.snapshot().xAxisPreset)
assertTrue(r.snapshot().isXAxisAuto)
assertEquals(before.span(), r.snapshot().span())
}
@Test fun forwardDragCanContinueBeyondLatestAfterReturningFromHistory() {
val r = renderer()
r.panTimeWindow(-320.0) // Initial range is 1000..1300; actual history ends at 980.
r.panTimeWindow(20.0)
assertTrue(r.snapshot().isXAxisAuto)
r.panTimeWindow(10.0)
assertEquals(1010.0, r.snapshot().timeMaxSec)
assertFalse(r.snapshot().isXAxisAuto)
r.panTimeWindow(10.0)
assertEquals(1020.0, r.snapshot().timeMaxSec)
r.panTimeWindow(-30.0)
assertEquals(990.0, r.snapshot().timeMaxSec)
r.panTimeWindow(10.0)
assertEquals(1000.0, r.snapshot().timeMaxSec)
assertTrue(r.snapshot().isXAxisAuto)
}
@Test fun zoomInFutureRangeKeepsTheAnchorAndDoesNotSnapBackToLatest() {
val r = renderer()
val initialRight = r.snapshot().timeMaxSec
r.panTimeWindow(600.0)
r.zoomTimeWindow(2f, 1.0)
assertEquals(initialRight + 600.0, r.snapshot().timeMaxSec)
assertEquals(150.0, r.snapshot().span())
assertFalse(r.snapshot().isXAxisAuto)
assertTrue(r.snapshot().seriesList.single().points.isEmpty())
}
@Test fun emptyOrExplicitlyFrozenViewportDoesNotResumeWithoutReachingDataByGesture() {

24
Android/app/src/test/java/com/dct/app/tests/ChartStoppedNavigationTest.kt

@ -67,6 +67,30 @@ class ChartStoppedNavigationTest {
assertFalse(r.renderSnapshotFlow.value.isXAxisAuto)
}
@Test fun stoppedChartPublishesFutureBlankRangeAndCanReturnWithoutNewSamples() = runTest {
val r = renderer()
val job = r.start(backgroundScope)
r.resume()
runCurrent()
r.stop()
r.panTimeWindow(60.0)
runCurrent()
val future = r.renderSnapshotFlow.value
assertEquals(1060.0, future.timeMaxSec, 1e-8)
assertEquals(1030.0, future.timeMinSec, 1e-8)
assertFalse(future.isXAxisAuto)
assertTrue(future.isPaused)
assertTrue(future.seriesList.single().points.isEmpty())
r.panTimeWindow(-60.0)
assertRight(r, 1000.0)
r.resetPan()
assertRight(r, 1000.0)
assertTrue(r.renderSnapshotFlow.value.isXAxisAuto)
assertTrue(job.isCancelled)
assertEquals(900.0, r.seriesListFlow.value.single().buffer.getOldestTimestamp())
assertEquals(1000.0, r.seriesListFlow.value.single().buffer.getLatestTimestamp())
}
@Test fun cancelledJobAndNeverStartedRendererAlsoPublishNavigation() = runTest {
val r = renderer()
r.panTimeWindow(-10.0)

54
Android/app/src/test/java/com/dct/app/tests/DeveloperOptionsTest.kt

@ -0,0 +1,54 @@
package com.dct.app.tests
import android.content.SharedPreferences
import com.dct.app.data.preferences.DeveloperOptionsPreferences
import com.dct.app.presentation.main.DeveloperOptionsAccess
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class DeveloperOptionsTest {
@Test fun fiveConsecutiveTapsUnlockThenOneTapReopensUntilANewSession() {
val access = DeveloperOptionsAccess()
repeat(4) { assertFalse(access.onTitleTap(it * 150L)) }
assertTrue(access.onTitleTap(600))
assertTrue(access.onTitleTap(100_000))
assertFalse(DeveloperOptionsAccess().onTitleTap(100_150))
}
@Test fun pausesAndClockDiscontinuityResetTheTapSequence() {
val access = DeveloperOptionsAccess()
repeat(4) { assertFalse(access.onTitleTap(it * 100L)) }
assertFalse(access.onTitleTap(1_301))
assertFalse(access.onTitleTap(1_000))
repeat(3) { assertFalse(access.onTitleTap(1_100L + it * 100)) }
assertTrue(access.onTitleTap(1_400))
}
@Test fun closingTheUiRelocksEvenIfTheBackgroundSessionIsRetained() {
val access = DeveloperOptionsAccess()
repeat(5) { access.onTitleTap(it * 100L) }
assertTrue(access.onTitleTap(500))
access.lock()
repeat(4) { assertFalse(access.onTitleTap(600L + it * 100)) }
assertTrue(access.onTitleTap(1_000))
}
@Test fun preferenceSurvivesNewStoreAndLockedAccessWithoutBeingReset() {
val values = mutableMapOf<String, Boolean>()
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getBoolean(any(), any()) } answers { values[firstArg()] ?: secondArg() }
every { prefs.edit() } returns editor
every { editor.putBoolean(any(), any()) } answers { values[firstArg()] = secondArg(); editor }
every { editor.apply() } returns Unit
assertFalse(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled())
DeveloperOptionsPreferences(prefs).setReceiveAutoRecoveryEnabled(false)
assertFalse(DeveloperOptionsAccess().onTitleTap(0))
assertFalse(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled())
DeveloperOptionsPreferences(prefs).setReceiveAutoRecoveryEnabled(true)
assertTrue(DeveloperOptionsPreferences(prefs).receiveAutoRecoveryEnabled())
assertEquals(setOf("receive_auto_recovery"), values.keys)
}
}

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

@ -35,6 +35,7 @@ class FormulaTelemetryRegressionTest {
private lateinit var vm: MainViewModel
private lateinit var packets: MutableSharedFlow<DctPacket>
private lateinit var repository: PacketRepository
private var telemetrySequence = 0L
private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true }
@BeforeEach fun setUp() {
@ -48,12 +49,13 @@ class FormulaTelemetryRegressionTest {
@AfterEach fun tearDown() = fixture.tearDown()
private fun telemetry(value: Float) {
private fun telemetry(value: Float, receivedAtMillis: Long = 0L) {
// Real transport assigns an ingress sequence, preserving distinct same-millisecond packets.
assertTrue(packets.tryEmit(DctPacket(0x02, ByteArray(18).also {
val buffer = ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN)
buffer.position(2)
buffer.putFloat(value).putFloat(2f).putFloat(3f).putFloat(4f)
})))
}, ingressSequence = ++telemetrySequence, receivedAtMillis = receivedAtMillis)))
}
private fun openMdbWithoutRawConsumers() {
@ -84,6 +86,95 @@ class FormulaTelemetryRegressionTest {
assertEquals(3.0, vm.liveTelemetryValues.value["B"])
}
@Test fun measurementRestartClearsAllPagesAndLiveCachesThenUsesOnlyNewSamples() {
vm.startMeasure()
vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2", unit = "V"))
val binding = com.dct.app.domain.model.project.DpcSignalBindingState(
"formula_F", "F", AxisPosition.LEFT_0, 0x123456L, "V")
vm.restoreProjectWorkspace(com.dct.app.domain.model.project.DpcWorkspaceState("page_1", listOf(
com.dct.app.domain.model.project.DpcPageState("page_1", "One", listOf(
com.dct.app.domain.model.project.DpcWindowState("graph_one", "GRAPH", "Graph", signals = listOf(binding)))),
com.dct.app.domain.model.project.DpcPageState("page_2", "Two", listOf(
com.dct.app.domain.model.project.DpcWindowState("multi_two", "MULTI_AXIS_GRAPH", "Multi", signals = listOf(binding)))))))
vm.chartRenderer.bindSignal("formula_F", "F", AxisPosition.LEFT_0, 0x123456L)
val renderers = listOf(vm.chartRenderer, vm.getChartRenderer("graph_one"), vm.getChartRenderer("multi_two"))
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<MainUiState>
ui.value = ui.value.copy(dashboardTiles = listOf(
com.dct.app.domain.model.dashboard.TileWidgetItem("Incar_Snr", "Incar_Snr")))
telemetry(2f)
vm.stopMeasure()
val raw = field(vm, "latestMf4RawValues").get(vm) as MutableMap<String, Number>
raw.putAll(mapOf("sig_1_1" to 42, "can_1" to 55, "a2l_1" to 66))
val beforeCount = vm.uiState.value.packetCount
val project = vm.currentProject.value
val formulas = vm.formulas.value
val pages = vm.pages.value.map { it.id to it.title }
val bindings = renderers.map { it.exportWorkspaceState() }
renderers.forEach { assertTrue(it.seriesListFlow.value.single().buffer.size > 0) }
assertEquals(4.0, vm.liveTelemetryValues.value["F"])
vm.startMeasure()
assertTrue(vm.liveTelemetryValues.value.isEmpty())
assertTrue(raw.isEmpty())
assertEquals(beforeCount, vm.uiState.value.packetCount)
assertTrue(vm.uiState.value.dashboardTiles.all { it.value == 0.0 && it.historyBuffer.size == 0 })
assertEquals(project, vm.currentProject.value)
assertEquals(formulas, vm.formulas.value)
assertEquals(pages, vm.pages.value.map { it.id to it.title })
renderers.forEachIndexed { index, renderer ->
assertEquals(bindings[index], renderer.exportWorkspaceState())
assertEquals(0, renderer.seriesListFlow.value.single().buffer.size)
}
telemetry(7f)
assertEquals(14.0, vm.liveTelemetryValues.value["F"])
renderers.forEach {
assertEquals(1, it.seriesListFlow.value.single().buffer.size)
assertEquals(14.0, it.seriesListFlow.value.single().buffer.getLatestValue())
}
assertEquals(7.0, vm.uiState.value.dashboardTiles.single().value)
}
@Test fun pendingAndFailedStartRetainOldValuesUntilStartSucceeds() {
vm.startMeasure()
vm.chartRenderer.bindSignal("s", "Incar_Snr", AxisPosition.LEFT_0, 0x123456L)
telemetry(2f)
vm.stopMeasure()
val before = vm.chartRenderer.seriesListFlow.value.single().buffer.size
val response = kotlinx.coroutines.CompletableDeferred<DctResult<Unit>>()
coEvery { repository.startStreaming(any()) } coAnswers { response.await() }
vm.startMeasure()
assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"])
assertEquals(before, vm.chartRenderer.seriesListFlow.value.single().buffer.size)
response.complete(DctResult.Failure(DctError.ProtocolError.ConnectionFailed("START failed")))
assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"])
assertEquals(before, vm.chartRenderer.seriesListFlow.value.single().buffer.size)
coEvery { repository.startStreaming(any()) } returns DctResult.Success(Unit)
vm.startMeasure()
assertTrue(vm.liveTelemetryValues.value.isEmpty())
assertEquals(0, vm.chartRenderer.seriesListFlow.value.single().buffer.size)
}
@Test fun automaticRecoveryRetainsValuesGraphsAndRawSnapshot() {
vm.startMeasure()
vm.chartRenderer.bindSignal("s", "Incar_Snr", AxisPosition.LEFT_0, 0x123456L)
// Separate the sample from the recovery gap, which may replace a same-millisecond point.
telemetry(2f, receivedAtMillis = System.currentTimeMillis() - 100L)
val before = vm.chartRenderer.seriesListFlow.value.single().buffer.size
val raw = field(vm, "latestMf4RawValues").get(vm) as MutableMap<String, Number>
raw["can_1"] = 55
val recovery = repository.streamRecovery as MutableStateFlow<com.dct.app.domain.repository.StreamRecoveryState>
recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECOVERING
recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECEIVING
assertTrue(vm.uiState.value.isMeasuring)
assertEquals(2.0, vm.liveTelemetryValues.value["Incar_Snr"])
val buffer = vm.chartRenderer.seriesListFlow.value.single().buffer
assertEquals(before + 1, buffer.size)
assertEquals(2.0, buffer.getValueAt(0))
assertTrue(buffer.getLatestValue()!!.isNaN())
assertEquals(55, raw["can_1"])
}
@Test fun debugResetClearsAllPagesAndValuesWithoutStoppingRecordingOrRemovingConfiguration() {
vm.startMeasure()
vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2", unit = "V"))

89
Android/app/src/test/java/com/dct/app/tests/LongRunMeasurementReviewTest.kt

@ -0,0 +1,89 @@
package com.dct.app.tests
import com.dct.app.data.logger.Mf4MeasurementLogger
import com.dct.app.data.logger.PcMf4Channel
import com.dct.app.data.ringbuffer.TimeSeriesRingBuffer
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.Inflater
/** Accelerated component checks; these do not simulate radio, Doze, heat or 24 hours of wall time. */
class LongRunMeasurementReviewTest {
@TempDir lateinit var directory: File
private val samples = 24 * 60 * 60 * 30
@Test fun twentyFourHoursAtThirtyHertzKeepsRingCapacityAndNewestSamples() {
val ring = TimeSeriesRingBuffer()
repeat(samples) { index -> ring.addSample(index / 30.0, index.toDouble()) }
assertEquals(100_000, ring.capacity)
assertEquals(ring.capacity, ring.size)
assertEquals((samples - ring.capacity) / 30.0, ring.getOldestTimestamp()!!, 1e-6)
assertEquals((samples - 1) / 30.0, ring.getLatestTimestamp()!!, 1e-6)
assertEquals((samples - 1).toDouble(), ring.getLatestValue())
assertEquals((samples - ring.capacity).toDouble(), ring.getOldestValue())
}
@Test fun compressedMf4RetainsCountTimesAndValuesAcrossTwentyFourLogicalHours() {
val channel = PcMf4Channel("Counter", dataType = 0, bitCount = 16)
val logger = Mf4MeasurementLogger(directory, listOf("Counter" to ""), "24h.mf4", listOf(channel))
// 23:59:59 KST: the simulated stream crosses midnight almost immediately.
val origin = java.time.Instant.parse("2026-10-28T14:59:59Z").toEpochMilli()
val file = logger.start(origin)!!
try {
repeat(samples) { index ->
logger.logRawSampleAtMillis(origin + index * 1000L / 30L, listOf(index and 0xFFFF))
}
} finally { logger.stop() }
val bytes = file.readBytes()
val data = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
assertEquals(samples.toLong(), data.getLong(232 + 80))
assertEquals(10, data.getInt(232 + 96)) // Time float64 + original uint16.
assertEquals(0, data.getShort(60).toInt()) // Finalized only after links/counts are patched.
val hl = data.getLong(168 + 40).toInt()
assertEquals("##HL", String(bytes, hl, 4))
var dl = data.getLong(hl + 24).toInt()
var total = 0
var chunks = 0
var previousTime = -1.0
while (dl != 0) {
assertTrue(++chunks < 1000, "Invalid/cyclic DL chain")
val dz = data.getLong(dl + 32).toInt()
assertEquals("##DZ", String(bytes, dz, 4))
val rawSize = data.getLong(dz + 32).toInt()
val zipSize = data.getLong(dz + 40).toInt()
val transposed = ByteArray(rawSize)
val inflater = Inflater()
try {
inflater.setInput(bytes, dz + 48, zipSize)
var count = 0
while (!inflater.finished() && count < rawSize) {
val n = inflater.inflate(transposed, count, rawSize - count)
assertTrue(n > 0, "Truncated compressed chunk")
count += n
}
assertTrue(inflater.finished())
assertEquals(rawSize, count)
} finally { inflater.end() }
val rows = rawSize / 10
for (row in listOf(0, rows - 1).distinct()) {
val timeBytes = ByteArray(8) { column -> transposed[column * rows + row] }
val time = ByteBuffer.wrap(timeBytes).order(ByteOrder.LITTLE_ENDIAN).double
assertEquals(((total + row) * 1000L / 30L) / 1000.0, time, 1e-6)
assertTrue(time >= previousTime)
previousTime = time
val raw = (transposed[8 * rows + row].toInt() and 255) or
((transposed[9 * rows + row].toInt() and 255) shl 8)
assertEquals((total + row) and 0xFFFF, raw)
}
total += rows
dl = data.getLong(dl + 24).toInt()
}
assertEquals(samples, total)
assertEquals(260, chunks)
assertEquals(86_399.966, previousTime, 1e-6)
}
}

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

@ -461,6 +461,7 @@ class MainViewModelTest {
private lateinit var mdbRepository: MdbRepository
private lateinit var packetRepository: PacketRepository
private lateinit var connectionState: MutableStateFlow<DeviceConnectionState>
private lateinit var recoveryState: MutableStateFlow<com.dct.app.domain.repository.StreamRecoveryState>
private lateinit var repositoryConnected: MutableStateFlow<Boolean>
private lateinit var repositoryStreaming: MutableStateFlow<Boolean>
private lateinit var chartRenderer: ChartBatchRenderer
@ -473,6 +474,7 @@ class MainViewModelTest {
private lateinit var viewModel: MainViewModel
private lateinit var sampleDb: MdbDatabase
private var backgroundExecutionAllowed = true
private var measurementClockMillis = 0L
private fun installWifiAp(
initial: com.dct.app.data.communication.WifiApUiState
@ -885,10 +887,12 @@ class MainViewModelTest {
val packetFlow = MutableSharedFlow<DctPacket>(extraBufferCapacity = 8)
connectionState = MutableStateFlow(DeviceConnectionState.DISCONNECTED)
recoveryState = MutableStateFlow(com.dct.app.domain.repository.StreamRecoveryState.IDLE)
repositoryConnected = MutableStateFlow(false)
repositoryStreaming = MutableStateFlow(false)
every { packetRepository.packetStream } returns packetFlow
every { packetRepository.connectionState } returns connectionState
every { packetRepository.streamRecovery } returns recoveryState
every { packetRepository.isConnected } returns repositoryConnected
every { packetRepository.isStreaming } returns repositoryStreaming
coEvery { packetRepository.configureMeasurement(any()) } returns DctResult.Success(Unit)
@ -923,7 +927,8 @@ class MainViewModelTest {
every { isConnected } returns MutableStateFlow(false)
}, // Lifecycle tests must not launch real asynchronous TCP attempts.
packetRepository = packetRepository,
ensureBackgroundExecution = { backgroundExecutionAllowed }
ensureBackgroundExecution = { backgroundExecutionAllowed },
measurementClock = { measurementClockMillis }
)
// Rendering cadence is not under test; keep virtual time from advancing forever.
chartRenderer.stop()

114
Android/app/src/test/java/com/dct/app/tests/MeasurementElapsedTimeTest.kt

@ -0,0 +1,114 @@
package com.dct.app.tests
import com.dct.app.core.common.DctResult
import com.dct.app.core.error.DctError
import com.dct.app.domain.repository.DeviceConnectionState
import com.dct.app.domain.repository.PacketRepository
import com.dct.app.domain.repository.StreamRecoveryState
import com.dct.app.presentation.main.MainViewModel
import io.mockk.coEvery
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
@Suppress("UNCHECKED_CAST")
class MeasurementElapsedTimeTest {
private lateinit var fixture: MainViewModelTest
private lateinit var vm: MainViewModel
private lateinit var repository: PacketRepository
private lateinit var connection: MutableStateFlow<DeviceConnectionState>
private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true }
private fun clock(time: Long) = field(fixture, "measurementClockMillis").setLong(fixture, time)
@BeforeEach fun setUp() {
fixture = MainViewModelTest().also { it.setUp() }
vm = field(fixture, "viewModel").get(fixture) as MainViewModel
repository = field(fixture, "packetRepository").get(fixture) as PacketRepository
connection = field(fixture, "connectionState").get(fixture) as MutableStateFlow<DeviceConnectionState>
connection.value = DeviceConnectionState.READY
}
@AfterEach fun cleanUp() {
vm.scope.cancel()
fixture.tearDown()
}
@Test fun timerStartsOnlyAfterSuccessfulStartNotWhileWaitingForResponse() {
val response = CompletableDeferred<DctResult<Unit>>()
coEvery { repository.startStreaming(any()) } coAnswers { response.await() }
clock(1_000)
vm.startMeasure()
assertNull(vm.uiState.value.measurementStartedAtElapsedMillis)
clock(50_000)
response.complete(DctResult.Success(Unit))
assertTrue(vm.uiState.value.isMeasuring)
assertEquals(50_000L, vm.uiState.value.measurementStartedAtElapsedMillis)
}
@Test fun failedStartDoesNotCreateTimer() {
coEvery { repository.startStreaming(any()) } returns DctResult.Failure(DctError.ProtocolError.Disconnected)
clock(5_000)
vm.startMeasure()
assertFalse(vm.uiState.value.isMeasuring)
assertNull(vm.uiState.value.measurementStartedAtElapsedMillis)
}
@Test fun stopClearsTimeAndSuccessfulRestartStartsFromNewTime() {
clock(1_000)
vm.startMeasure()
clock(61_000)
vm.stopMeasure()
assertNull(vm.uiState.value.measurementStartedAtElapsedMillis)
clock(99_000)
vm.stopMeasure()
assertNull(vm.uiState.value.measurementStartedAtElapsedMillis)
clock(120_000)
vm.startMeasure()
assertEquals(120_000L, vm.uiState.value.measurementStartedAtElapsedMillis)
}
@Test fun pendingOrFailedStopKeepsTimerUntilMeasurementActuallyEnds() {
clock(1_000)
vm.startMeasure()
val response = CompletableDeferred<DctResult<Unit>>()
coEvery { repository.stopStreaming() } coAnswers { response.await() }
vm.stopMeasure()
assertTrue(vm.uiState.value.isMeasuring)
assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis)
response.complete(DctResult.Failure(DctError.ProtocolError.ConnectionFailed("STOP failed")))
assertTrue(vm.uiState.value.isMeasuring)
assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis)
coEvery { repository.stopStreaming() } returns DctResult.Success(Unit)
vm.stopMeasure()
assertFalse(vm.uiState.value.isMeasuring)
assertNull(vm.uiState.value.measurementStartedAtElapsedMillis)
}
@Test fun automaticRecoveryAndDebugValueResetKeepOriginalMeasurementStart() {
clock(1_000)
vm.startMeasure()
clock(300_000)
val recovery = repository.streamRecovery as MutableStateFlow<StreamRecoveryState>
recovery.value = StreamRecoveryState.RECOVERING
vm.resetDebugValues()
assertTrue(vm.uiState.value.isMeasuring)
assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis)
recovery.value = StreamRecoveryState.RECEIVING
assertEquals(1_000L, vm.uiState.value.measurementStartedAtElapsedMillis)
}
@Test fun disconnectAndNewProjectClearTimer() {
clock(1_000)
vm.startMeasure()
clock(11_000)
connection.value = DeviceConnectionState.DISCONNECTED
assertFalse(vm.uiState.value.isMeasuring)
assertNull(vm.uiState.value.measurementStartedAtElapsedMillis)
vm.resetWorkspaceForNewProject()
assertNull(vm.uiState.value.measurementStartedAtElapsedMillis)
}
}

9
Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt

@ -9,6 +9,15 @@ import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class MeasurementExecutionStateTest {
@Test fun recoveryKeepsRecordingAndItsStartTimeWhileShowingReceptionIsUnavailable() {
val notification = state(MainUiState(isMeasuring = true, isStreamRecovering = true,
recordingState = MeasurementRecordingState.RECORDING, recordingStartedAtMillis = 1234))
assertTrue(notification.active)
assertTrue(notification.measuring)
assertTrue(notification.recording)
assertEquals(1234L, notification.startedAtMillis)
assertTrue(notification.title.contains("수신 복구 중"))
}
@Test fun apNegotiationAndTcpWaitKeepServiceButScanningDoesNot() {
fun ap(phase: com.dct.app.data.communication.WifiApPhase, present: Boolean = false) =
MeasurementExecutionState.from(MainUiState(), DeviceConnectionState.DISCONNECTED, false,

28
Android/app/src/test/java/com/dct/app/tests/MeasurementRestartRegressionTest.kt

@ -54,6 +54,34 @@ class MeasurementRestartRegressionTest {
fixture.tearDown()
}
@Test fun receptionRecoveryPreservesMeasurementRecordingAndExistingLoggerUntilDataReturns() {
vm.startMeasure()
val logger = CsvMeasurementLogger(directory.toFile(), listOf("Incar_Snr" to ""))
loggers.add(logger)
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<com.dct.app.presentation.main.MainUiState>
ui.value = ui.value.copy(recordingState = MeasurementRecordingState.RECORDING,
recordingStartedAtMillis = 1234, recordingSampleCount = 10)
val recovery = repository.streamRecovery as MutableStateFlow<com.dct.app.domain.repository.StreamRecoveryState>
val before = ui.value.packetCount
recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECOVERING
assertTrue(ui.value.isMeasuring)
assertTrue(ui.value.isStreamRecovering)
assertEquals(MeasurementRecordingState.RECORDING, ui.value.recordingState)
assertEquals(1234L, ui.value.recordingStartedAtMillis)
assertEquals(10L, ui.value.recordingSampleCount)
assertSame(logger, loggers.single())
recovery.value = com.dct.app.domain.repository.StreamRecoveryState.RECEIVING
packets.tryEmit(DctPacket(0x02, ByteArray(18).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putFloat(10f).putFloat(2f).putFloat(3f).putFloat(4f)
}))
assertFalse(ui.value.isStreamRecovering)
assertEquals(before + 1, ui.value.packetCount)
assertEquals(11L, ui.value.recordingSampleCount)
assertEquals(1234L, ui.value.recordingStartedAtMillis)
assertSame(logger, loggers.single())
vm.stopMeasure()
}
@Test fun clearingLoggerListDuringPacketProcessingDoesNotKillSubscription() {
vm.startMeasure()
val subscribers = packets.subscriptionCount.value

396
Android/app/src/test/java/com/dct/app/tests/MeasurementStreamRecoveryTest.kt

@ -0,0 +1,396 @@
package com.dct.app.tests
import com.dct.app.core.common.DctResult
import com.dct.app.data.communication.WifiApEndpoint
import com.dct.app.data.communication.WifiSocketManager
import com.dct.app.data.repository.PacketRepositoryImpl
import com.dct.app.domain.model.protocol.DctMeasurementConfiguration
import com.dct.app.domain.model.protocol.DctPacket
import com.dct.app.domain.repository.DeviceConnectionState
import com.dct.app.domain.repository.StreamRecoveryState
import io.mockk.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@OptIn(ExperimentalCoroutinesApi::class)
class MeasurementStreamRecoveryTest {
private class Fixture(val scope: TestScope, timeout: Long = 50, autoRecovery: Boolean = true) {
val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 32)
val connected = MutableStateFlow(true)
var session = 7L
var owner = 42L
var nextSession = 8L
val sent = mutableListOf<DctPacket>()
val times = mutableListOf<Long>()
val acks = mutableListOf<DctPacket>()
val reconnects = mutableListOf<Pair<Long, Long>>()
val events = mutableListOf<String>()
var silent: Int? = null
var reject: Int? = null
var reconnectWorks = true
var emptyStop = false
var endpoint: WifiApEndpoint? = WifiApEndpoint("192.168.4.1", 5000)
var holdCommand: Int? = null
val gate = CompletableDeferred<Unit>()
val dispatcher = UnconfinedTestDispatcher(scope.testScheduler)
init {
every { wifi.isConnected } returns connected
every { wifi.currentSessionId } answers { session }
every { wifi.recoveryGeneration } answers { owner }
every { wifi.recoveryEndpoint } answers { endpoint }
every { wifi.packetStream } returns packets
every { wifi.offerCommand(capture(acks)) } returns true
every { wifi.diagnosticEvent(any(), any()) } answers { events += secondArg<String>() }
coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false; session = 0; owner++ }
coEvery { wifi.reconnectForRecovery(any(), any(), any()) } coAnswers {
reconnects += firstArg<Long>() to secondArg<Long>()
if (firstArg<Long>() != owner || !reconnectWorks) false else {
connected.value = false
yield()
session = nextSession++
connected.value = true
true
}
}
coEvery { wifi.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>()
sent += request
times += scope.testScheduler.currentTime
val command = request.command.toInt() and 255
if (holdCommand == command) gate.await()
if (silent != command && command !in listOf(0x0F, 0x17)) {
val data = when (command) {
1 -> byteArrayOf(1, 65)
3 -> ByteArray(6)
9 -> byteArrayOf(request.payload[0], 1)
0x08 -> byteArrayOf(0, 1)
0x20 -> byteArrayOf(1, 1)
5 -> byteArrayOf()
0x21 -> if (emptyStop) byteArrayOf() else byteArrayOf(1)
else -> byteArrayOf(1)
}
if (reject == command) data[0] = 2
packets.emit(DctPacket((command + 0x80).toByte(), data, transportSessionId = request.transportSessionId))
}
true
}
}
val repo = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher,
responseTimeoutMs = timeout, heartbeatIntervalMs = 40, heartbeatTimeoutMs = 20,
heartbeatDispatcher = dispatcher, recoveryDispatcher = dispatcher,
monotonicTimeMillis = { scope.testScheduler.currentTime }, streamSilenceTimeoutMs = 300,
recoveryDataTimeoutMs = 200, scope = scope.backgroundScope,
initialReceiveAutoRecoveryEnabled = autoRecovery)
suspend fun open(measure: Boolean = true) {
assertTrue(repo.connect("") is DctResult.Success)
assertTrue(repo.configureMeasurement(DctMeasurementConfiguration(projectName = "SavedProject", vehicleCode = "Car")) is DctResult.Success)
if (measure) assertTrue(repo.startStreaming() is DctResult.Success)
sent.clear(); times.clear()
}
suspend fun stream(valid: Boolean = true, from: Long = session) = packets.emit(
DctPacket(0xA3.toByte(), if (valid) byteArrayOf(1, 0xA0.toByte(), 9) else byteArrayOf(), transportSessionId = from))
suspend fun enable() = packets.emit(DctPacket(0x80.toByte(), byteArrayOf(1), transportSessionId = session))
fun controls() = sent.map { it.command.toInt() and 255 }.filter { it != 5 }
suspend fun close() { repo.disconnect() }
}
@Test fun repositoryDefaultLeavesAutoRecoveryOff() = runTest {
assertFalse(PacketRepositoryImpl(scope = backgroundScope).receiveAutoRecoveryEnabled.value)
}
@Test fun disabledRecoveryIgnoresSilenceHeartbeatTimeoutAndUnexpectedEnableButStillAcksData() = runTest {
val f = Fixture(this, autoRecovery = false); f.open()
assertFalse(f.repo.receiveAutoRecoveryEnabled.value)
f.silent = 5
f.enable()
advanceTimeBy(20_000); runCurrent()
assertTrue(f.controls().isEmpty())
assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value)
assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value)
f.stream(); runCurrent()
assertEquals(1, f.acks.size)
assertTrue(f.repo.isStreaming.value)
assertTrue(f.reconnects.isEmpty())
f.close()
}
@Test fun disabledRecoveryDoesNotReconnectOnTcpLoss() = runTest {
val f = Fixture(this); f.open()
f.repo.setReceiveAutoRecoveryEnabled(false)
f.session = 0; f.connected.value = false
advanceTimeBy(20_000); runCurrent()
assertTrue(f.reconnects.isEmpty())
assertTrue(f.controls().isEmpty())
assertEquals(DeviceConnectionState.ERROR, f.repo.connectionState.value)
assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value)
f.close()
}
@Test fun disablingDuringRecoveryCancelsPendingSetupAndAllLaterStarts() = runTest {
val f = Fixture(this); f.open(); f.holdCommand = 3
advanceTimeBy(301); runCurrent()
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
f.repo.setReceiveAutoRecoveryEnabled(false)
val count = f.controls().size
f.gate.complete(Unit)
advanceTimeBy(20_000); runCurrent()
assertEquals(count, f.controls().size)
assertFalse(f.controls().contains(0x20))
assertTrue(f.reconnects.isEmpty())
assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value)
f.close()
}
@Test fun enablingDuringStalledMeasurementRestoresRecoveryWithoutManualRestart() = runTest {
val f = Fixture(this, autoRecovery = false); f.open()
advanceTimeBy(1_000); runCurrent()
f.repo.setReceiveAutoRecoveryEnabled(true)
advanceTimeBy(421); runCurrent()
assertEquals(1, f.controls().count { it == 0x20 })
f.stream(); advanceTimeBy(25); runCurrent()
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
f.close()
}
@Test fun disablingAfterStartKeepsNaturallyResumedTelemetryVisibleWithoutRetrying() = runTest {
val f = Fixture(this); f.open()
advanceTimeBy(621); runCurrent()
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
assertFalse(f.repo.isStreaming.value)
f.repo.setReceiveAutoRecoveryEnabled(false)
val count = f.controls().size
f.stream(); runCurrent()
assertTrue(f.repo.isStreaming.value)
assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value)
advanceTimeBy(2_000); runCurrent()
assertEquals(count, f.controls().size)
assertEquals(1, f.acks.size)
f.close()
}
@Test fun togglingRecoveryDuringHealthyMeasurementDoesNotRestartPlayback() = runTest {
val f = Fixture(this); f.open()
f.repo.setReceiveAutoRecoveryEnabled(false)
f.stream()
f.repo.setReceiveAutoRecoveryEnabled(true)
repeat(8) { advanceTimeBy(150); f.stream(); runCurrent() }
assertTrue(f.controls().isEmpty())
assertEquals(9, f.acks.size)
f.close()
}
@Test fun continuousValidDataNeverTriggersRecovery() = runTest {
val f = Fixture(this); f.open()
repeat(20) { advanceTimeBy(150); f.stream(); runCurrent() }
assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value)
assertTrue(f.controls().isEmpty())
assertEquals(20, f.acks.size)
f.close()
}
@Test fun heartbeatAndStartAckCannotCompleteRecoveryButRealDataCan() = runTest {
val f = Fixture(this); f.open()
advanceTimeBy(299); runCurrent()
assertTrue(f.controls().isEmpty())
advanceTimeBy(322); runCurrent() // 300 silence + 200 version gap + 120 DID setup.
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value)
assertFalse(f.repo.isStreaming.value)
val controls = f.controls().size
assertTrue(f.repo.sendCommand(com.dct.app.data.protocol.DctProtocolRequests.hardwareId()) is DctResult.Failure)
assertEquals(controls, f.controls().size, "User commands must not interleave recovery setup")
assertTrue(f.sent.count { it.command == 5.toByte() } > 1)
assertTrue(f.controls().containsAll(listOf(0x21, 0, 1, 3, 4, 6, 8, 0x40, 0x11, 0x17, 9, 0x12, 0x20)))
val db = f.sent.single { it.command == 8.toByte() }
assertEquals("SavedProject", String(db.payload.copyOfRange(2, db.payload.size)))
advanceTimeBy(50); runCurrent()
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
f.stream(); advanceTimeBy(25); runCurrent()
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
assertTrue(f.repo.isStreaming.value)
verify { f.wifi.diagnosticEvent(7, match { it.startsWith("STREAM_RECOVERY_COMPLETE") }) }
f.close()
}
@Test fun duplicateEnableDuringHealthyStreamingDoesNotResetTheDevice() = runTest {
val f = Fixture(this); f.open()
f.enable(); f.enable(); f.enable()
repeat(8) { advanceTimeBy(125); f.stream() }
runCurrent()
assertTrue(f.controls().isEmpty())
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
assertEquals(8, f.acks.size)
f.close()
}
@Test fun unsolicitedEnableWithNoDataRestoresSettingsAndStartsOnlyOnce() = runTest {
val f = Fixture(this); f.open()
f.enable(); f.enable()
advanceTimeBy(1321); runCurrent()
assertEquals(1, f.controls().count { it == 0x20 })
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
f.stream(); advanceTimeBy(25); runCurrent()
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
f.close()
}
@ParameterizedTest @ValueSource(ints = [1, 9, 0x20])
fun missingSetupResponseRetriesOnANewSessionWithoutEndingMeasurement(command: Int) = runTest {
val f = Fixture(this); f.open(); f.silent = command
var budget = 0L
while (f.events.none { it.startsWith("STREAM_RECOVERY_FAILED") } && budget < 20_000) {
advanceTimeBy(25); runCurrent(); budget += 25
}
assertTrue(budget < 20_000)
assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value)
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
f.silent = null
budget = 0
while (f.sent.none { it.command == 0x20.toByte() && it.transportSessionId == 8L } && budget < 5_000) {
advanceTimeBy(25); runCurrent(); budget += 25
}
assertTrue(budget < 5_000)
assertEquals(listOf(42L to 7L), f.reconnects)
f.stream(from = 7) // Late bytes from the failed socket cannot complete recovery.
runCurrent()
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
f.stream(); advanceTimeBy(25); runCurrent()
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
assertTrue(f.sent.filter { it.transportSessionId != 7L }.all { it.transportSessionId == 8L })
f.close()
}
@Test fun startAckWithNoDataRetriesInsteadOfClaimingSuccess() = runTest {
val f = Fixture(this); f.open()
advanceTimeBy(2825); runCurrent()
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
coVerify(exactly = 1) { f.wifi.reconnectForRecovery(42, 7, any()) }
advanceTimeBy(320); runCurrent()
f.stream(); advanceTimeBy(25); runCurrent()
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
f.close()
}
@Test fun eofImmediatelyRecoversWhileKeepingMeasurementIntent() = runTest {
val f = Fixture(this); f.open()
f.session = 0; f.connected.value = false; runCurrent()
assertEquals(DeviceConnectionState.MEASURING, f.repo.connectionState.value)
advanceTimeBy(321); runCurrent()
coVerify(exactly = 1) { f.wifi.reconnectForRecovery(42, 0, any()) }
f.stream(); advanceTimeBy(25); runCurrent()
assertTrue(f.repo.isStreaming.value)
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
f.close()
}
@Test fun malformedDataDoesNotRenewTheSilenceBudget() = runTest {
val f = Fixture(this); f.open()
repeat(3) { advanceTimeBy(100); f.stream(valid = false) }
runCurrent()
assertEquals(StreamRecoveryState.RECOVERING, f.repo.streamRecovery.value)
assertTrue(f.acks.all { it.payload.single() == 0.toByte() })
f.close()
}
@Test fun explicitStopDuringRecoveryCancelsAllLaterStartsAndRetries() = runTest {
val f = Fixture(this); f.open()
advanceTimeBy(301); runCurrent()
assertTrue(f.repo.stopStreaming() is DctResult.Success)
val commands = f.controls().size
advanceTimeBy(20_000); runCurrent()
assertEquals(commands, f.controls().size)
assertFalse(f.controls().contains(0x20))
assertEquals(DeviceConnectionState.READY, f.repo.connectionState.value)
assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value)
f.close()
}
@Test fun explicitStopAlsoWorksWhileTcpIsUnavailable() = runTest {
val f = Fixture(this); f.open(); f.reconnectWorks = false
f.session = 0; f.connected.value = false; runCurrent()
assertTrue(f.repo.stopStreaming() is DctResult.Success)
advanceTimeBy(20_000); runCurrent()
assertEquals(DeviceConnectionState.DISCONNECTED, f.repo.connectionState.value)
assertEquals(StreamRecoveryState.IDLE, f.repo.streamRecovery.value)
assertFalse(f.repo.isStreaming.value)
coVerify(exactly = 1) { f.wifi.reconnectForRecovery(any(), any(), any()) }
f.close()
}
@Test fun disconnectDuringRecoveryNeverReopensTheTransport() = runTest {
val f = Fixture(this); f.open()
advanceTimeBy(301); runCurrent()
f.close()
val commands = f.sent.size
advanceTimeBy(20_000); runCurrent()
assertEquals(commands, f.sent.size)
assertEquals(DeviceConnectionState.DISCONNECTED, f.repo.connectionState.value)
coVerify(exactly = 0) { f.wifi.reconnectForRecovery(any(), any(), any()) }
}
@Test fun retryBackoffDoesNotSpinWhenDeviceIsUnavailable() = runTest {
val f = Fixture(this); f.open(); f.reconnectWorks = false
f.session = 0; f.connected.value = false; runCurrent()
advanceTimeBy(1999); runCurrent()
coVerify(exactly = 1) { f.wifi.reconnectForRecovery(any(), any(), any()) }
advanceTimeBy(1); runCurrent()
coVerify(exactly = 2) { f.wifi.reconnectForRecovery(any(), any(), any()) }
advanceTimeBy(3999); runCurrent()
coVerify(exactly = 2) { f.wifi.reconnectForRecovery(any(), any(), any()) }
advanceTimeBy(1); runCurrent()
coVerify(exactly = 3) { f.wifi.reconnectForRecovery(any(), any(), any()) }
f.close()
}
@Test fun readinessAloneDoesNotStartMeasurementAutomatically() = runTest {
val f = Fixture(this); f.open(measure = false)
advanceTimeBy(20_000); runCurrent()
assertTrue(f.controls().isEmpty())
f.enable(); advanceTimeBy(250); runCurrent()
assertEquals(listOf(1, 9, 9), f.controls())
assertEquals(DeviceConnectionState.READY, f.repo.connectionState.value)
f.close()
}
@Test fun lostStartAckDoesNotResetAlreadyResumedFreshTelemetry() = runTest {
val f = Fixture(this); f.open(); f.silent = 0x20
advanceTimeBy(621); runCurrent()
repeat(160) { advanceTimeBy(100); f.stream(); runCurrent() }
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
assertTrue(f.repo.isStreaming.value)
assertTrue(f.reconnects.isEmpty())
assertEquals(1, f.controls().count { it == 0x20 })
f.close()
}
@Test fun legacyEmptyStopAckIsAcceptedDuringRecovery() = runTest {
val f = Fixture(this); f.open(); f.emptyStop = true
advanceTimeBy(621); runCurrent()
assertEquals(1, f.controls().count { it == 0x20 })
f.stream(); advanceTimeBy(25); runCurrent()
assertEquals(StreamRecoveryState.RECEIVING, f.repo.streamRecovery.value)
assertTrue(f.reconnects.isEmpty())
f.close()
}
@Test fun manualStopCancelsARecoveryCommandWaitingForAnotherRequest() = runTest {
val f = Fixture(this, timeout = 2000); f.open(); f.holdCommand = 3
val blocking = async { f.repo.sendCommand(com.dct.app.data.protocol.DctProtocolRequests.hardwareId()) }
runCurrent(); advanceTimeBy(301); runCurrent()
val stop = async { f.repo.stopStreaming() }
runCurrent(); f.gate.complete(Unit)
assertTrue(blocking.await() is DctResult.Success)
assertTrue(stop.await() is DctResult.Success)
assertEquals(listOf(3, 0x21), f.controls())
advanceTimeBy(2000); runCurrent()
assertFalse(f.controls().contains(0x20))
f.close()
}
}

39
Android/app/src/test/java/com/dct/app/tests/PacketPipelineReliabilityTest.kt

@ -14,6 +14,45 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class PacketPipelineReliabilityTest {
@Test fun consecutiveAcksAndControlWaitFiveMillisecondsAfterThePreviousWrite() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val entered = CountDownLatch(1)
val releaseFirst = CountDownLatch(1)
val finished = CountDownLatch(4)
val commands = mutableListOf<Int>()
val startedAt = mutableListOf<Long>()
val completedAt = mutableListOf<Long>()
val writer = OrderedPacketWriter(scope, write = { bytes ->
commands += bytes[0].toInt()
startedAt += System.nanoTime()
if (bytes[0] == 0x24.toByte()) {
entered.countDown()
check(releaseFirst.await(2, TimeUnit.SECONDS))
}
// A slow write must not consume the following frame's quiet interval.
Thread.sleep(10)
completedAt += System.nanoTime()
finished.countDown()
}, closeTransport = { releaseFirst.countDown() })
try {
assertTrue(writer.offer(byteArrayOf(0x24)))
assertTrue(entered.await(2, TimeUnit.SECONDS))
assertTrue(writer.offer(byteArrayOf(0x23)))
assertTrue(writer.offer(byteArrayOf(0x50)))
val control = async(start = CoroutineStart.UNDISPATCHED) { writer.send(byteArrayOf(0x05)) }
releaseFirst.countDown()
assertTrue(finished.await(3, TimeUnit.SECONDS))
assertTrue(control.await())
assertEquals(listOf(0x24, 0x05, 0x23, 0x50), commands,
"Control priority and ACK ordering must survive pacing")
for (index in 1 until startedAt.size) {
val quietNanos = startedAt[index] - completedAt[index - 1]
assertTrue(quietNanos >= 5_000_000L,
"Frame $index began after only ${quietNanos / 1_000_000.0}ms of quiet")
}
} finally { writer.close(); scope.cancel() }
}
@Test fun cancelledCallerDoesNotCloseSharedWriter() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val entered = CountDownLatch(1)

239
Android/app/src/test/java/com/dct/app/tests/PacketRepositoryEnableRefreshTest.kt

@ -0,0 +1,239 @@
package com.dct.app.tests
import com.dct.app.core.common.DctResult
import com.dct.app.data.communication.WifiSocketManager
import com.dct.app.data.protocol.DctProtocolRequests
import com.dct.app.data.repository.PacketRepositoryImpl
import com.dct.app.domain.model.protocol.DctPacket
import com.dct.app.domain.repository.DeviceConnectionState
import io.mockk.*
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
class PacketRepositoryEnableRefreshTest {
private class Fixture(val testScope: TestScope, timeoutMs: Long = 50) {
val wifi = mockk<WifiSocketManager>(relaxed = true)
val packets = MutableSharedFlow<DctPacket>(extraBufferCapacity = 16)
val connected = MutableStateFlow(true)
var sessionId = 7L
val sent = mutableListOf<DctPacket>()
val writtenAt = mutableListOf<Pair<DctPacket, Long>>()
val acks = mutableListOf<DctPacket>()
var versionDelay = 0L
var silent: Byte? = null
var sendFailure: Byte? = null
var versionPayload = byteArrayOf(1, 65)
var environmentRejected = false
init {
every { wifi.isConnected } returns connected
every { wifi.currentSessionId } answers { sessionId }
every { wifi.packetStream } returns packets
every { wifi.offerCommand(capture(acks)) } returns true
every { wifi.invalidateConnectionIfSession(any()) } answers {
if (firstArg<Long>() == sessionId && connected.value) { connected.value = false; true } else false
}
coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false }
coEvery { wifi.sendCommand(any()) } coAnswers {
val request = firstArg<DctPacket>()
sent += request
if (request.command == sendFailure) return@coAnswers false
if (request.command == 1.toByte()) kotlinx.coroutines.delay(versionDelay)
writtenAt += request to testScope.testScheduler.currentTime
if (request.command != silent && request.command != 0x0F.toByte()) {
val payload = when (request.command.toInt()) {
1 -> versionPayload
3 -> ByteArray(6)
9 -> byteArrayOf(request.payload[0], if (environmentRejected) 2 else 1)
0x20 -> byteArrayOf(1, 1)
else -> byteArrayOf(1)
}
packets.emit(DctPacket((request.command + 0x80).toByte(), payload,
transportSessionId = request.transportSessionId))
}
true
}
}
val repository = PacketRepositoryImpl(wifiSocketManager = wifi,
ioDispatcher = UnconfinedTestDispatcher(testScope.testScheduler), responseTimeoutMs = timeoutMs,
enableHeartbeat = false, monotonicTimeMillis = { testScope.testScheduler.currentTime }, scope = testScope.backgroundScope)
suspend fun open(measuring: Boolean = false) {
assertTrue(repository.connect("") is DctResult.Success)
if (measuring) assertTrue(repository.startStreaming(30) is DctResult.Success)
sent.clear(); writtenAt.clear()
}
suspend fun enable(payload: ByteArray = byteArrayOf(1), session: Long = sessionId) {
packets.emit(DctPacket(0x80.toByte(), payload, transportSessionId = session))
}
}
@Test
fun readyEnableRefreshesVersionAndReadOnlyEnvironmentWithoutInterruptingAcks() = runTest {
val f = Fixture(this)
f.open()
f.versionDelay = 25
f.enable()
runCurrent()
// Keep receiving/ACKing while VERSION's actual write is still pending.
f.packets.emit(DctPacket(0xA3.toByte(), byteArrayOf(1, 0xA0.toByte(), 9), transportSessionId = 7))
f.enable(); f.enable()
runCurrent()
assertEquals(1, f.acks.size)
assertEquals(0x23.toByte(), f.acks.single().command)
assertEquals(listOf(1), f.sent.map { it.command.toInt() })
advanceTimeBy(224); runCurrent()
assertEquals(1, f.sent.size)
advanceTimeBy(1); runCurrent()
assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() })
assertEquals(listOf(0, 3), f.sent.filter { it.command == 9.toByte() }.map { it.payload.single().toInt() })
assertEquals(200L, f.writtenAt[1].second - f.writtenAt[0].second)
assertTrue(f.sent.all { it.transportSessionId == 7L })
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
assertTrue(f.connected.value)
verify(exactly = 1) { f.wifi.diagnosticEvent(7L, match { it.startsWith("ENABLE_REFRESH_COMPLETE") }) }
coVerify(exactly = 0) { f.wifi.disconnectSuspend() }
f.enable()
advanceTimeBy(225); runCurrent()
assertEquals(listOf(1, 9, 9, 1, 9, 9), f.sent.map { it.command.toInt() },
"A later ENABLE must be able to refresh again after completion")
}
@Test fun matchingInitialEnableReplyDoesNotStartAnotherRefresh() = runTest {
val f = Fixture(this)
assertTrue(f.repository.connect("") is DctResult.Success)
advanceTimeBy(500); runCurrent()
assertEquals(1, f.sent.count { it.command == 1.toByte() })
assertEquals(2, f.sent.count { it.command == 9.toByte() })
verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_") }) }
}
@Test fun versionReplyAfter200msDoesNotAddAnother200msBeforeEnvironment() = runTest {
val f = Fixture(this, timeoutMs = 1000)
f.open()
coEvery { f.wifi.sendCommand(match { it.command == 1.toByte() }) } coAnswers {
val request = firstArg<DctPacket>()
f.sent += request
f.writtenAt += request to testScheduler.currentTime
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
kotlinx.coroutines.delay(250)
f.packets.emit(DctPacket(0x81.toByte(), byteArrayOf(1, 65), transportSessionId = 7))
}
true
}
f.enable()
advanceTimeBy(249); runCurrent()
assertEquals(listOf(1), f.sent.map { it.command.toInt() })
advanceTimeBy(1); runCurrent()
assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() })
assertEquals(250L, f.writtenAt[1].second - f.writtenAt[0].second)
}
@ParameterizedTest
@ValueSource(strings = ["empty", "nack", "extra", "stale"])
fun invalidOrOldSessionEnableDoesNotQuery(failure: String) = runTest {
val f = Fixture(this)
f.open()
when (failure) {
"empty" -> f.enable(byteArrayOf())
"nack" -> f.enable(byteArrayOf(2))
"extra" -> f.enable(byteArrayOf(1, 1))
else -> f.enable(session = 6)
}
advanceTimeBy(500); runCurrent()
assertTrue(f.sent.isEmpty())
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
}
@ParameterizedTest
@ValueSource(strings = ["versionTimeout", "versionMalformed", "versionSendFailed", "environmentTimeout", "environmentRejected"])
fun failedReadyRefreshReportsWithoutStartingMeasurementOrClosingTcp(failure: String) = runTest {
val f = Fixture(this)
f.open()
when (failure) {
"versionTimeout" -> f.silent = 1
"versionMalformed" -> f.versionPayload = byteArrayOf(3, 65)
"versionSendFailed" -> f.sendFailure = 1
"environmentTimeout" -> f.silent = 9
else -> f.environmentRejected = true
}
f.enable()
advanceTimeBy(500); runCurrent()
assertEquals(if (failure.startsWith("version")) listOf(1) else listOf(1, 9),
f.sent.map { it.command.toInt() })
assertTrue(f.connected.value)
assertFalse(f.repository.isStreaming.value)
assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value)
verify(exactly = 1) { f.wifi.diagnosticEvent(7L, match { it.startsWith("ENABLE_REFRESH_FAILED") }) }
coVerify(exactly = 0) { f.wifi.disconnectSuspend() }
}
@Test fun disconnectCancelsRefreshDuringTheVersionEnvironmentGap() = runTest {
val f = Fixture(this)
f.open()
f.enable(); runCurrent()
assertEquals(listOf(1), f.sent.map { it.command.toInt() })
assertTrue(f.repository.disconnect() is DctResult.Success)
advanceTimeBy(500); runCurrent()
assertEquals(listOf(1, 15), f.sent.map { it.command.toInt() })
assertEquals(DeviceConnectionState.DISCONNECTED, f.repository.connectionState.value)
verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_COMPLETE") }) }
}
@Test fun transportLossCancelsOldRefreshAndOldRepliesCannotStartWorkOnNewSession() = runTest {
val f = Fixture(this)
f.open()
f.enable(); runCurrent()
f.connected.value = false; runCurrent()
f.sessionId = 8
f.connected.value = true
f.open()
f.enable(session = 7)
advanceTimeBy(500); runCurrent()
assertTrue(f.sent.isEmpty())
f.enable(session = 8)
advanceTimeBy(200); runCurrent()
assertEquals(listOf(1, 9, 9), f.sent.map { it.command.toInt() })
assertTrue(f.sent.all { it.transportSessionId == 8L })
}
@Test fun enableDuringExplicitDisconnectCannotStartAnotherRefresh() = runTest {
val f = Fixture(this)
f.open(measuring = true)
f.silent = 0x21
val disconnect = async { f.repository.disconnect() }
runCurrent()
f.enable()
advanceTimeBy(50); runCurrent()
assertTrue(disconnect.await() is DctResult.Success)
assertEquals(listOf(0x21, 0x0F), f.sent.map { it.command.toInt() })
verify(exactly = 0) { f.wifi.diagnosticEvent(any(), match { it.startsWith("ENABLE_REFRESH_START") }) }
}
@Test fun commandWaitingForMutexCannotBeRetaggedToAnotherSession() = runTest {
val f = Fixture(this)
f.open()
f.silent = 3
val blocking = async { f.repository.sendCommand(DctProtocolRequests.hardwareId()) }
runCurrent()
val waiting = async { f.repository.sendCommand(DctProtocolRequests.version().copy(transportSessionId = 7)) }
runCurrent()
f.sessionId = 8
advanceTimeBy(50); runCurrent()
assertTrue(blocking.await() is DctResult.Failure)
assertTrue(waiting.await() is DctResult.Failure)
assertEquals(listOf(3), f.sent.map { it.command.toInt() })
assertTrue(f.repository.sendCommand(DctProtocolRequests.version().copy(transportSessionId = 7)) is DctResult.Failure)
assertEquals(listOf(3), f.sent.map { it.command.toInt() })
}
}

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

@ -23,6 +23,23 @@ import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
class PacketRepositoryResponseTest {
@Test fun collectorExceptionAndActualResubscriptionAreObservableWithoutChangingRetry() = runTest {
val f = Fixture(UnconfinedTestDispatcher(testScheduler))
every { f.wifi.offerCommand(any()) } throws IllegalStateException("injected ACK failure")
f.packets.emit(DctPacket(DctCommand.SEND_CAN_FRAME.code, byteArrayOf(1, 0xA0.toByte(), 1), ingressSequence = 42))
runCurrent()
verify { f.wifi.diagnosticEvent(any(), match { it.contains("COLLECTOR_ERROR") && it.contains("ingress=42") && it.contains("injected ACK failure") }) }
assertEquals(0, f.packets.subscriptionCount.value)
advanceTimeBy(99); runCurrent()
assertEquals(0, f.packets.subscriptionCount.value)
every { f.wifi.offerCommand(any()) } returns true
advanceTimeBy(1); runCurrent()
assertEquals(1, f.packets.subscriptionCount.value)
verify { f.wifi.diagnosticEvent(any(), match { it.contains("COLLECTOR_RESUBSCRIBED") }) }
f.packets.emit(DctPacket(DctCommand.SEND_CAN_FRAME.code, byteArrayOf(1, 0xA0.toByte(), 2)))
runCurrent()
verify(exactly = 2) { f.wifi.offerCommand(any()) }
}
@ParameterizedTest
@CsvSource("true,false", "false,true")
fun xcpCalibrationFollowsConfiguredEndianAcrossProjectChanges(firstMsb: Boolean, secondMsb: Boolean) = runTest {

8
Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt

@ -137,7 +137,7 @@ class PcOperationalLoggerTest {
logger.connected(1, "PC"); logger.frame(1, "Recv", codec.encode(packet))
logger.frame(1, "Send", codec.encode(packet))
logger.close(); logger.awaitClosed()
assertEquals(2, errors.size) // Action creation and first raw creation; no repeated raw attempts.
assertEquals(2, errors.size) // CSV and first TXT; no diagnostic file writer.
assertEquals("preserve", notDirectory.readText())
}
@ -160,6 +160,10 @@ class PcOperationalLoggerTest {
val frame = codec.encode(packet)
val corrupt = frame.copyOf().apply { this[lastIndex - 1] = 0 }
peer.getOutputStream().write(corrupt + frame.copyOfRange(0, 3))
withTimeout(5000) {
while (socketManager.metrics.tcpBytesRead.get() < corrupt.size + 3L) delay(10)
}
assertFalse(received.isCompleted, "A partial/corrupt frame must not be published")
peer.getOutputStream().write(frame.copyOfRange(3, frame.size))
assertEquals(packet, withTimeout(5000) { received.await() })
assertTrue(socketManager.sendCommand(packet))
@ -179,5 +183,7 @@ class PcOperationalLoggerTest {
val csv = files("csv").single().readText()
assertTrue(csv.contains("Connected 127.0.0.1"))
assertTrue(csv.contains("Disconnected"))
assertTrue(files("rxdiag").isEmpty(), "TCP sessions must no longer create .rxdiag files")
assertTrue(folder.listFiles()!!.all { it.extension in setOf("txt", "csv") })
}
}

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

@ -22,6 +22,38 @@ class SlidingRingBufferParserTest {
codec = DctPacketCodec()
}
@Test fun diagnosticsAccountForFragmentsNoiseAndChecksumWithoutChangingParsing() {
val frame = codec.encode(DctPacket.create(DctCommand.SEND_CAN_FRAME, byteArrayOf(1, 2, 3)))
val corrupt = frame.copyOf().apply { this[lastIndex - 1] = (this[lastIndex - 1].toInt() xor 1).toByte() }
val prefix = byteArrayOf(9, 8) + corrupt + frame.copyOfRange(0, 4)
assertThat(parser.parseStream(prefix)).isEmpty()
val pending = parser.diagnostics()
assertThat(pending.pendingBytes).isEqualTo(4)
assertThat(pending.expectedBytes).isEqualTo(frame.size)
assertThat(pending.discarded[SlidingRingBufferParser.DiscardReason.CHECKSUM]).isEqualTo(1L)
assertThat(pending.inputBytes).isEqualTo(pending.frameBytes + pending.discardedBytes + pending.pendingBytes)
assertThat(parser.parseStream(frame.copyOfRange(4, frame.size))).hasSize(1)
val complete = parser.diagnostics()
assertThat(complete.frameBytes).isEqualTo(frame.size.toLong())
assertThat(complete.frames).isEqualTo(1L)
assertThat(complete.pendingBytes).isEqualTo(0)
assertThat(complete.inputBytes).isEqualTo(complete.frameBytes + complete.discardedBytes)
parser.reset()
assertThat(parser.diagnostics().inputBytes).isEqualTo(0L)
assertThat(parser.diagnostics().discardedBytes).isEqualTo(0L)
}
@Test fun diagnosticSnapshotDoesNotConsumePartialHeader() {
val frame = codec.encode(DctPacket.create(DctCommand.CMD_ENABLE))
parser.parseStream(frame.copyOfRange(0, 2))
repeat(10) {
assertThat(parser.diagnostics().pendingBytes).isEqualTo(2)
assertThat(parser.diagnostics().expectedBytes).isNull()
}
assertThat(parser.parseStream(frame.copyOfRange(2, frame.size))).hasSize(1)
assertThat(parser.diagnostics().discardedBytes).isEqualTo(0L)
}
@Test
@DisplayName("단일 완전한 패킷 입력 시 즉시 파싱 성공")
fun testParseSingleCompletePacket() {

54
Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt

@ -51,6 +51,60 @@ class WifiApManagerTest {
manager = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34)
}
@AfterEach fun teardown() { manager.close(); unmockkAll() }
@Test fun recoveryRestoresOnlyTheSelectedApWithoutEndingMeasurementOrDuplicatingRequests() {
var switching = 0
val owner = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34, onConnecting = { switching++ })
try {
owner.connect(device, "12345678")
val first = callbacks.last(); val network = mockk<Network>()
first.onLinkPropertiesChanged(network, properties("192.168.4.20"))
first.onLost(network)
assertEquals(1, lost)
assertFalse(owner.recoverLastConnection()) // IP callback has not arrived yet.
assertEquals(2, callbacks.size)
assertFalse(owner.recoverLastConnection()) // CONNECTING is already in progress.
assertEquals(2, callbacks.size)
assertEquals(1, switching, "An automatic retry must not invoke manual session teardown")
first.onLinkPropertiesChanged(network, properties("192.168.4.20"))
assertFalse(owner.uiState.value.networkReady)
callbacks.last().onLinkPropertiesChanged(mockk(), properties("192.168.4.21"))
assertEquals(2, ready)
assertTrue(owner.recoverLastConnection())
owner.disconnect()
assertFalse(owner.recoverLastConnection())
assertEquals(2, callbacks.size, "Explicit Disconnect clears AP retry intent")
} finally { owner.close() }
}
@Test fun settingsNetworkRecoveryNeverAdoptsAnUnrelatedWifi() {
fun network(name: String): Network {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities>()
val info = mockk<WifiInfo>()
every { info.ssid } returns "\"$name\""
every { capabilities.transportInfo } returns info
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false
every { connectivity.getNetworkCapabilities(network) } returns capabilities
every { connectivity.getLinkProperties(network) } returns properties("192.168.4.20")
return network
}
val original = network("DCT")
every { connectivity.allNetworks } returns arrayOf(original)
manager.connectCurrent()
callbacks.last().onLost(original)
val unrelated = network("Office")
every { connectivity.allNetworks } returns arrayOf(unrelated)
assertFalse(manager.recoverLastConnection())
assertEquals(1, ready)
every { connectivity.getNetworkCapabilities(original) } returns connectivity.getNetworkCapabilities(unrelated)
every { connectivity.allNetworks } returns arrayOf(original)
assertFalse(manager.recoverLastConnection(), "A known SSID must match even when Network identity is reused")
every { connectivity.allNetworks } returns arrayOf(network("DCT"))
assertTrue(manager.recoverLastConnection())
assertEquals(2, ready)
}
private fun properties(address: String): LinkProperties {
val link = mockk<LinkAddress>(); every { link.address } returns InetAddress.getByName(address)
val properties = mockk<LinkProperties>(); every { properties.linkAddresses } returns listOf(link)

36
Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt

@ -22,12 +22,19 @@ class WifiConnectionPreferencesTest {
private fun preferences(): SharedPreferences {
val values = mutableMapOf<String, String?>()
val pending = mutableMapOf<String, String?>()
val integers = mutableMapOf<String, Int>()
val pendingIntegers = mutableMapOf<String, Int>()
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getString(any(), any()) } answers { values[firstArg()] ?: secondArg() }
every { prefs.getInt(any(), any()) } answers { integers[firstArg()] ?: secondArg() }
every { prefs.edit() } returns editor
every { editor.putString(any(), any()) } answers { pending[firstArg()] = secondArg(); editor }
every { editor.apply() } answers { values.putAll(pending); pending.clear() }
every { editor.putInt(any(), any()) } answers { pendingIntegers[firstArg()] = secondArg(); editor }
every { editor.apply() } answers {
values.putAll(pending); pending.clear()
integers.putAll(pendingIntegers); pendingIntegers.clear()
}
return prefs
}
@ -39,6 +46,33 @@ class WifiConnectionPreferencesTest {
assertNull(input.portNumber)
}
@Test fun directPortDefaultsTo5000AndPersistsIndependentlyOfApSettings() {
val prefs=preferences()
val store=WifiConnectionPreferences(prefs)
assertEquals(5000,store.loadDirectPort())
val ap=WifiConnectionInput("192.168.200.9","5000")
store.save(ap)
store.saveDirectPort(6000)
assertEquals(6000,WifiConnectionPreferences(prefs).loadDirectPort())
assertEquals(ap,WifiConnectionPreferences(prefs).load())
store.save(WifiConnectionInput())
assertEquals(6000,store.loadDirectPort())
for (port in listOf(1,65535)) {
store.saveDirectPort(port)
assertEquals(port,WifiConnectionPreferences(prefs).loadDirectPort())
}
for (port in listOf(0,-1,65536)) assertThrows(IllegalArgumentException::class.java) { store.saveDirectPort(port) }
assertEquals(65535,store.loadDirectPort())
}
@Test fun invalidStoredDirectPortUses5000() {
val prefs=preferences()
for (port in listOf(0,-1,65536)) {
every { prefs.getInt("direct_port",any()) } returns port
assertEquals(5000,WifiConnectionPreferences(prefs).loadDirectPort())
}
}
@Test fun lastDraftSurvivesNewStoreAndDoesNotRequireSuccessfulConnection() {
val prefs = preferences()
val input = WifiConnectionInput("192.168.0.", "")

206
Android/app/src/test/java/com/dct/app/tests/WifiDualModeTest.kt

@ -0,0 +1,206 @@
package com.dct.app.tests
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pInfo
import com.dct.app.core.common.DctResult
import com.dct.app.data.communication.*
import com.dct.app.domain.repository.DeviceConnectionState
import com.dct.app.domain.repository.PacketRepository
import com.dct.app.presentation.main.*
import io.mockk.*
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import java.net.InetAddress
@Suppress("UNCHECKED_CAST")
class WifiDualModeTest {
private fun field(target: Any, name: String) = target.javaClass.getDeclaredField(name).also { it.isAccessible = true }
private inner class Fixture : AutoCloseable {
val owner = MainViewModelTest().also { it.setUp() }
val vm = field(owner, "viewModel").get(owner) as MainViewModel
val repo = field(owner, "packetRepository").get(owner) as PacketRepository
val ui = field(vm, "_uiState").get(vm) as MutableStateFlow<MainUiState>
val apState = field(vm, "_wifiApState").get(vm) as MutableStateFlow<WifiApUiState>
val directState = MutableStateFlow(WifiDirectUiState(phase=WifiDirectPhase.IDLE,
enabled=true, permissionGranted=true, synchronizedWithOs=true))
val direct = mockk<WifiDirectManager>(relaxed=true)
val ap = mockk<WifiApManager>(relaxed=true)
val socket = vm.wifiSocketManager
init {
every { direct.uiState } returns directState
every { ap.uiState } returns apState
field(vm, "registeredWifiApManager").set(vm, ap)
vm.registerWifiDirectManager(direct)
coEvery { repo.connect(any()) } answers {
(field(owner,"repositoryConnected").get(owner) as MutableStateFlow<Boolean>).value=true
(field(owner,"connectionState").get(owner) as MutableStateFlow<DeviceConnectionState>).value=DeviceConnectionState.READY
DctResult.Success(Unit)
}
}
fun connectedDirect(ownerRole: Boolean = false): WifiDirectUiState {
val peer = WifiP2pDevice().apply { deviceName="DCT peer"; deviceAddress="aa:bb:cc:dd:ee:ff" }
val group = mockk<WifiP2pGroup> {
every { getOwner() } returns peer
every { getClientList() } returns listOf(peer)
every { getInterface() } returns "p2p-test"
}
return directState.value.copy(phase=WifiDirectPhase.CONNECTED,
link=WifiDirectLink(WifiP2pInfo().apply {
groupFormed=true; isGroupOwner=ownerRole; groupOwnerAddress=InetAddress.getByName("192.168.77.1")
}, group))
}
fun apReady() = WifiApUiState(phase=WifiApPhase.CONNECTED, enabled=true, permissionGranted=true,
localAddress="192.168.4.2", selectedSsid="DCT")
override fun close() { vm.scope.cancel(); owner.tearDown() }
}
@ParameterizedTest @ValueSource(booleans=[false,true])
fun directGroupFormationNeverStartsProtocolUntilExplicitConnect(ownerRole: Boolean) {
Fixture().use { f ->
assertEquals(WifiConnectionMode.TCP, f.vm.wifiConnectionMode.value)
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT))
f.directState.value = f.connectedDirect(ownerRole)
assertTrue(f.vm.canConnectCommunication.value)
verify(exactly=0) { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) }
coVerify(exactly=0) { f.repo.connect(any()) }
var ready: ((Boolean,String)->Unit)? = null
every { f.socket.startDualMode(any(), any(), any(), any(), any(), any()) } answers { ready=arg(3) }
f.vm.connectCommunication()
f.vm.connectCommunication()
verify(exactly=1) { f.socket.startDualMode(ownerRole, "192.168.77.1", 5000, any(), any(), any()) }
coVerify(exactly=0) { f.repo.connect(any()) }
ready!!(ownerRole, "192.168.77.1")
coVerify(exactly=1) { f.repo.connect("192.168.77.1") }
coVerify(exactly=1) { f.repo.configureMeasurement(any()) }
}
}
@Test fun modeChangesSelectOnlyTheirOwnRouteAndKeepApEndpoint() {
Fixture().use { f ->
assertTrue(f.vm.setWifiApEndpoint(WifiApEndpoint("192.168.200.9",5000)))
clearMocks(f.socket, answers=false)
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT))
verify { f.socket.setWifiApRouteProvider(null) }
verify { f.socket.setRecoveryNetworkProvider(null) }
verify { f.socket.setWifiDirectRouteProvider(isNull(inverse=true)) }
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP))
verify { f.socket.setWifiDirectRouteProvider(null) }
verify { f.socket.setWifiApRouteProvider(isNull(inverse=true)) }
assertEquals(WifiApEndpoint("192.168.200.9",5000), f.vm.wifiApEndpoint.value)
}
}
@ParameterizedTest @ValueSource(booleans=[false,true])
fun configuredDirectPortIsUsedByBothRolesAndLockedDuringCommunication(ownerRole: Boolean) {
Fixture().use { f ->
assertEquals(5000,f.vm.wifiDirectPort.value)
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT))
f.directState.value=f.connectedDirect(ownerRole)
for (port in listOf(0,65536,-1)) assertFalse(f.vm.setWifiDirectPort(port))
assertTrue(f.vm.setWifiDirectPort(6000))
assertEquals(WifiApEndpoint(),f.vm.wifiApEndpoint.value)
f.vm.connectCommunication()
verify(exactly=1) { f.socket.startDualMode(ownerRole,"192.168.77.1",6000,any(),any(),any()) }
assertFalse(f.vm.canEditWifiDirectPort.value)
assertFalse(f.vm.setWifiDirectPort(5000))
f.vm.disconnectCommunication()
assertTrue(f.vm.setWifiDirectPort(5001))
assertEquals(5001,f.vm.wifiDirectPort.value)
}
}
@Test fun connectedMeasuringAndRecordingCannotChangeDirectPort() {
Fixture().use { f ->
for (ui in listOf(MainUiState(isConnected=true),MainUiState(isMeasuring=true),
MainUiState(recordingState=MeasurementRecordingState.RECORDING))) {
f.ui.value=ui
assertFalse(f.vm.setWifiDirectPort(6000))
assertEquals(5000,f.vm.wifiDirectPort.value)
}
}
}
@Test fun disconnectKeepsTheDirectGroupAndWifiReleaseRemovesOnlyThatGroup() {
Fixture().use { f ->
f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)
f.directState.value=f.connectedDirect()
f.ui.value=f.ui.value.copy(isConnected=true, communicationType=CommunicationType.WIFI)
f.vm.disconnectCommunication()
verify(exactly=0) { f.direct.disconnect() }
verify(exactly=0) { f.ap.disconnect() }
coVerify(exactly=1) { f.repo.disconnect() }
assertTrue(f.vm.canConnectCommunication.value)
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP))
f.vm.disconnectCommunication(releaseWifiAp=true)
verify(exactly=1) { f.direct.disconnect() }
verify(exactly=0) { f.ap.disconnect() }
f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE)
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP))
}
}
@Test fun inactiveDirectGroupLossNeverClosesApCommunication() {
Fixture().use { f ->
f.apState.value=f.apReady()
f.directState.value=f.connectedDirect()
f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI)
f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE)
verify(exactly=0) { f.socket.invalidateConnection() }
coVerify(exactly=0) { f.repo.disconnect() }
assertTrue(f.vm.uiState.value.isConnected)
}
}
@Test fun activeDirectGroupLossClosesItsTransportWithoutStartingAnother() {
Fixture().use { f ->
f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT)
f.directState.value=f.connectedDirect()
f.ui.value=f.ui.value.copy(isConnected=true,communicationType=CommunicationType.WIFI)
f.directState.value=f.directState.value.copy(link=null,phase=WifiDirectPhase.IDLE)
verify(exactly=1) { f.socket.invalidateConnection() }
coVerify(exactly=1) { f.repo.disconnect() }
verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) }
assertFalse(f.vm.uiState.value.isConnected)
}
}
@Test fun apModeAndDirectModeUseIndependentReadiness() {
Fixture().use { f ->
f.apState.value=f.apReady()
assertTrue(f.vm.canConnectCommunication.value)
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT))
f.apState.value=WifiApUiState()
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT))
assertFalse(f.vm.canConnectCommunication.value)
f.apState.value=f.apReady()
assertFalse(f.vm.canConnectCommunication.value)
f.directState.value=f.connectedDirect()
assertTrue(f.vm.canConnectCommunication.value)
f.directState.value=f.directState.value.copy(permissionGranted=false)
assertFalse(f.vm.canConnectCommunication.value)
f.vm.connectCommunication()
verify(exactly=0) { f.socket.startDualMode(any(),any(),any(),any(),any(),any()) }
}
}
@Test fun measurementRecordingAndNegotiationBlockModeChanges() {
Fixture().use { f ->
for (state in listOf(MainUiState(isMeasuring=true), MainUiState(isCommunicationConnecting=true),
MainUiState(recordingState=MeasurementRecordingState.RECORDING),MainUiState(isConnected=true))) {
f.ui.value=state
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT))
}
f.ui.value=MainUiState()
assertTrue(f.vm.setWifiConnectionMode(WifiConnectionMode.DIRECT))
f.directState.value=f.directState.value.copy(phase=WifiDirectPhase.CONNECTING)
assertFalse(f.vm.setWifiConnectionMode(WifiConnectionMode.TCP))
f.vm.disconnectCommunication(releaseWifiAp=true)
verify(exactly=1) { f.direct.cancelConnect() }
}
}
}

53
Android/app/src/test/java/com/dct/app/tests/WifiSocketRecoveryTest.kt

@ -0,0 +1,53 @@
package com.dct.app.tests
import com.dct.app.data.communication.WifiSocketManager
import com.dct.app.data.protocol.DctPacketCodec
import com.dct.app.data.protocol.DctProtocolRequests
import kotlinx.coroutines.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.net.ServerSocket
import java.net.Socket
class WifiSocketRecoveryTest {
@Test fun recoveryReopensTheSameEndpointAndOldOwnerCannotReplaceANewManualSession() = runBlocking {
val manager = WifiSocketManager()
ServerSocket(0).use { server ->
server.soTimeout = 3000
val peers = mutableListOf<Socket>()
suspend fun connect() {
val connected = CompletableDeferred<Unit>()
manager.connect("127.0.0.1", server.localPort,
onSuccess = { connected.complete(Unit) },
onFailure = { connected.completeExceptionally(IllegalStateException(it)) })
peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 }
withTimeout(5000) { connected.await() }
}
try {
connect()
val owner = manager.recoveryGeneration
val session = manager.currentSessionId
val endpoint = manager.recoveryEndpoint!!
manager.pauseForNetworkRecovery()
assertFalse(manager.isConnected.value)
assertEquals(owner, manager.recoveryGeneration)
assertEquals(endpoint, manager.recoveryEndpoint)
val recovery = async { manager.reconnectForRecovery(owner, 0, endpoint) }
peers += withContext(Dispatchers.IO) { server.accept() }.apply { soTimeout = 3000 }
assertTrue(withTimeout(5000) { recovery.await() })
assertTrue(manager.currentSessionId > session)
assertEquals(owner, manager.recoveryGeneration)
val frame = DctPacketCodec().encode(DctProtocolRequests.possibleToConnect())
assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect()))
assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) })
connect() // A manual connection takes ownership, even at the same address.
val newest = manager.currentSessionId
assertFalse(manager.reconnectForRecovery(owner, newest, endpoint))
assertEquals(newest, manager.currentSessionId)
assertTrue(manager.isConnected.value)
assertTrue(manager.sendCommand(DctProtocolRequests.possibleToConnect()))
assertArrayEquals(frame, withContext(Dispatchers.IO) { peers.last().getInputStream().readNBytes(frame.size) })
} finally { manager.close(); peers.forEach { it.close() } }
}
}
}

28
DCT_test/DCT_test/App.xaml.cs

@ -13,5 +13,33 @@ namespace DCT_test
/// </summary>
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
if (e.Args.Length > 0 && e.Args[0] == "--allow-network")
{
StartupUri = null;
ShutdownMode = ShutdownMode.OnExplicitShutdown;
try
{
int port;
if (e.Args.Length != 4 || !int.TryParse(e.Args[1], out port))
throw new ArgumentException("잘못된 통신 허용 요청입니다.");
var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
using (identity)
if (!new System.Security.Principal.WindowsPrincipal(identity)
.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator))
throw new UnauthorizedAccessException("관리자 승인이 필요합니다.");
NetworkSupport.AllowFirewall(new NetworkFirewallSettings(NetworkSupport.ExecutablePath, port, e.Args[2], e.Args[3]));
Shutdown(0);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "DCT 통신 허용 실패", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(1);
}
return;
}
base.OnStartup(e);
}
}
}

15
DCT_test/DCT_test/ClientSession.cs

@ -166,6 +166,21 @@ namespace DCT_test
if (!value) ClearPendingTelemetryWrites();
}
/// <summary>Protocol Connect/Disconnect is independent of the TCP/Wi-Fi lease.</summary>
public void ResetProtocolState()
{
lock (SendLock)
{
++ControlRevision; // A late START write must not reactivate the previous run.
SetStreaming(false);
ResetTelemetryAcks();
HandshakeMask = 0;
ConfigurationMask = 0;
XcpUseMode = 0;
XcpConfigurationMask = 0;
}
}
/// <summary>
/// Enqueues a telemetry frame without waiting for socket I/O. The
/// queue is bounded and drops the oldest telemetry frame on overflow;

28
DCT_test/DCT_test/DCT_test.csproj

@ -15,6 +15,21 @@
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
<IsWebBootstrapper>false</IsWebBootstrapper>
<PublishUrl>게시\</PublishUrl>
<Install>true</Install>
<InstallFrom>Disk</InstallFrom>
<UpdateEnabled>false</UpdateEnabled>
<UpdateMode>Foreground</UpdateMode>
<UpdateInterval>7</UpdateInterval>
<UpdateIntervalUnits>Days</UpdateIntervalUnits>
<UpdatePeriodically>false</UpdatePeriodically>
<UpdateRequired>false</UpdateRequired>
<MapFileExtensions>true</MapFileExtensions>
<ApplicationRevision>0</ApplicationRevision>
<ApplicationVersion>1.0.0.%2a</ApplicationVersion>
<UseApplicationTrust>false</UseApplicationTrust>
<BootstrapperEnabled>true</BootstrapperEnabled>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
@ -73,6 +88,7 @@
<Compile Include="ClientSession.cs" />
<Compile Include="DctTcpServer.cs" />
<Compile Include="WifiDirectHost.cs" />
<Compile Include="NetworkSupport.cs" />
<Compile Include="MainWindow.xaml.cs">
<DependentUpon>MainWindow.xaml</DependentUpon>
<SubType>Code</SubType>
@ -107,5 +123,17 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<BootstrapperPackage Include=".NETFramework,Version=v4.7.2">
<Visible>False</Visible>
<ProductName>Microsoft .NET Framework 4.7.2%28x86 및 x64%29</ProductName>
<Install>true</Install>
</BootstrapperPackage>
<BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1">
<Visible>False</Visible>
<ProductName>.NET Framework 3.5 SP1</ProductName>
<Install>false</Install>
</BootstrapperPackage>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

17
DCT_test/DCT_test/DctTcpServer.cs

@ -449,6 +449,7 @@ namespace DCT_test
switch (packet.CommandId)
{
case 0x00: // CMD_ENABLE
ResetProtocolSession(session);
session.HandshakeMask |= 0x01;
SendResponse(session, DctPacket.Create(0x80, new byte[] { 0x01 }, packet.Sequence), "RES_ENABLE (0x80, ACK: 0x01) [HANDSHAKE]");
break;
@ -474,6 +475,11 @@ namespace DCT_test
SendResponse(session, DctPacket.Create(0x84, new byte[] { 0x01 }, packet.Sequence), "RES_SYNC_TIME (0x84, ACK: 0x01) [HANDSHAKE]");
break;
case 0x05: // CMD_POSSIBLE_TO_CONNECT: idle probe, not Connect/START.
SendResponse(session, DctPacket.Create(0x85, new byte[0], packet.Sequence),
"RES_POSSIBLE_TO_CONNECT (0x85) [ALIVE]");
break;
case 0x06: // CMD_CARTYPE
string carType = "Unknown";
if (packet.Payload.Length > 1)
@ -524,7 +530,10 @@ namespace DCT_test
break;
case 0x0F: // CMD_DISABLE
SendResponse(session, DctPacket.Create(0x8F, new byte[] { 0x01 }, packet.Sequence), "RES_DISABLE (0x8F, 0x01) [HANDSHAKE]");
// PC and current Android define no 8F response. End protocol
// measurement/readiness, leaving the TCP connection usable.
ResetProtocolSession(session);
Log($"[INFO] 통신 해제 완료 (TCP 유지): {session.IpPort}");
break;
case 0x10: // CMD_MDB_INFO
@ -710,6 +719,12 @@ namespace DCT_test
}
}
private void ResetProtocolSession(ClientSession session)
{
session.ResetProtocolState();
if (!_clients.Values.Any(client => client.Streaming)) OnStopPlaybackRequested?.Invoke();
}
private void QueueMeasurementResponse(ClientSession session, DctPacket packet, Action onSent)
{
if (!session.QueueControl(packet.Encode(), sent =>

25
DCT_test/DCT_test/MainWindow.xaml

@ -43,6 +43,11 @@
<TextBlock x:Name="TextServerStatus" Text="[중지] 서버 중지됨 (Stopped)" FontWeight="Bold"
Foreground="#DC2626" VerticalAlignment="Center" FontSize="13"/>
</WrapPanel>
<WrapPanel Margin="0,8,0,0">
<Button x:Name="BtnNetworkDiagnostics" Content="연결 진단" Click="BtnNetworkDiagnostics_Click" Padding="10,4" Margin="0,0,8,0"/>
<Button x:Name="BtnAllowNetwork" Content="통신 허용 (관리자)" Click="BtnAllowNetwork_Click" Padding="10,4"/>
<TextBlock x:Name="TextNetworkHelp" Text="연결되지 않으면 진단에서 AP·IP·수신 상태를 확인하세요." FontWeight="Normal" VerticalAlignment="Center" Margin="10,0,0,0" TextWrapping="Wrap"/>
</WrapPanel>
<TextBlock x:Name="ExternalApGuide" Visibility="Collapsed" Margin="0,8,0,0" TextWrapping="Wrap" FontWeight="Normal"
Text="PC와 태블릿을 같은 공유기에 연결하세요. 위 목록에서 공유기에 연결된 PC IP를 선택하고 사용할 포트를 입력한 뒤 서버를 시작하세요. 앱에도 같은 IP/포트를 입력하고 PC 방화벽에서 해당 TCP 포트를 허용하세요. 공유기의 장치 간 통신을 허용하세요."/>
<StackPanel x:Name="ApControls" Visibility="Collapsed" Margin="0,8,0,0">
@ -72,7 +77,7 @@
Background="#F8FAFC" Margin="0,0,0,6"
AllowDrop="True" Drop="DropZone_Drop" DragEnter="DropZone_DragEnter">
<TextBlock x:Name="TextDropTitle"
Text="여기에 재생할 로그 파일(.csv, .txt, .dpc)을 끌어다 놓으세요 (Drag &amp; Drop)"
Text="여기에 재생할 DCT 수신 로그(.txt)를 끌어다 놓으세요 (Drag &amp; Drop)"
FontWeight="SemiBold" FontSize="11.5" Foreground="#64748B"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Border>
@ -113,7 +118,18 @@
<Grid Grid.Row="3">
<Grid.RowDefinitions>
<RowDefinition Height="110"/>
<RowDefinition Height="*"/>
<RowDefinition>
<RowDefinition.Style>
<Style TargetType="RowDefinition">
<Setter Property="Height" Value="*"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsExpanded, ElementName=PacketLogExpander}" Value="False">
<Setter Property="Height" Value="Auto"/>
</DataTrigger>
</Style.Triggers>
</Style>
</RowDefinition.Style>
</RowDefinition>
</Grid.RowDefinitions>
<GroupBox Grid.Row="0" Header=" 연결된 클라이언트 (Connected Clients) "
@ -132,7 +148,8 @@
</GroupBox>
<!-- Real-time Packet Console -->
<GroupBox Grid.Row="1" Header=" 실시간 통신 패킷 로그 (Real-time Packet Console) "
<Expander x:Name="PacketLogExpander" Grid.Row="1" IsExpanded="False"
Header=" 실시간 통신 패킷 로그 (Real-time Packet Console) "
FontWeight="Bold" Foreground="#1E293B" Padding="4"
Background="White" BorderBrush="#CBD5E1">
<Grid>
@ -160,7 +177,7 @@
IsReadOnly="True" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
TextWrapping="NoWrap"/>
</Grid>
</GroupBox>
</Expander>
</Grid>
</Grid>
</Window>

97
DCT_test/DCT_test/MainWindow.xaml.cs

@ -48,7 +48,7 @@ namespace DCT_test
};
_statusTimer.Start();
RefreshIpAddresses();
ComboNetworkMode.SelectedIndex = 0;
ComboNetworkMode.SelectedIndex = 1; // Current Android uses AP/LAN IP:port; Direct stays optional.
// 기본 로그 파일 탐색/로드
InitializeDefaultLogFile();
@ -257,15 +257,24 @@ namespace DCT_test
private void ComboNetworkMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (TextPort == null || ApControls == null) return;
TextPort.Text = ComboNetworkMode.SelectedIndex == 2 ? DctTcpServer.ApPort.ToString() : "6000";
TextPort.Text = ComboNetworkMode.SelectedIndex == 2 ? DctTcpServer.ApPort.ToString() : "5000";
TextPort.IsEnabled = ComboNetworkMode.SelectedIndex != 2;
ComboIp.IsEnabled = ComboNetworkMode.SelectedIndex == 1;
BtnCopyIp.Content = ComboNetworkMode.SelectedIndex == 1 ? "IP 복사"
: ComboNetworkMode.SelectedIndex == 2 ? "IP 고정" : "P2P 자동";
: ComboNetworkMode.SelectedIndex == 2 ? "IP 고정" : "PC 이름";
BtnCopyIp.IsEnabled = ComboNetworkMode.SelectedIndex == 1;
ApControls.Visibility = ComboNetworkMode.SelectedIndex == 2 ? Visibility.Visible : Visibility.Collapsed;
ExternalApGuide.Visibility = ComboNetworkMode.SelectedIndex == 1 ? Visibility.Visible : Visibility.Collapsed;
if (ComboNetworkMode.SelectedIndex == 2)
ComboIp.ToolTip = ComboNetworkMode.SelectedIndex == 0
? "태블릿 Wi-Fi Direct 목록에서 선택할 PC 이름입니다. IP는 연결 시 자동 설정됩니다."
: "태블릿에서 접속할 PC IP 주소입니다.";
if (ComboNetworkMode.SelectedIndex == 0)
{
ComboIp.Items.Clear();
ComboIp.Items.Add(WifiDirectHost.GetDisplayName());
ComboIp.SelectedIndex = 0;
}
else if (ComboNetworkMode.SelectedIndex == 2)
{
ComboIp.Items.Clear();
ComboIp.Items.Add(DctTcpServer.ApAddress);
@ -280,6 +289,86 @@ namespace DCT_test
catch (Exception ex) { AppendLog("[ERROR] 핫스팟 설정 열기 실패: " + ex.Message); }
}
private async void BtnNetworkDiagnostics_Click(object sender, RoutedEventArgs e)
{
int port;
if (!int.TryParse(TextPort.Text.Trim(), out port) || port < 1 || port > 65535)
{
TextNetworkHelp.Text = "올바른 포트(1~65535)를 입력하세요.";
return;
}
string mode = ComboNetworkMode.SelectedIndex == 0 ? "direct" : "ap";
string address = ComboIp.SelectedItem?.ToString() ?? "";
bool running = _server.IsRunning;
var direct = _wifiDirectHost?.GetListeningAddresses() ?? new string[0];
var clients = _server.ActiveClients.Select(client => client.IpPort).ToArray();
BtnNetworkDiagnostics.IsEnabled = false;
TextNetworkHelp.Text = "연결 정보를 확인하는 중입니다...";
try
{
string report = await Task.Run(() => NetworkSupport.BuildReport(mode, port, address, running, direct, clients));
var text = new TextBox { Text = report, IsReadOnly = true, AcceptsReturn = true,
FontFamily = new System.Windows.Media.FontFamily("Consolas"), FontSize = 13,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Auto };
var copy = new Button { Content = "진단 결과 복사", Padding = new Thickness(12, 6, 12, 6), Margin = new Thickness(0, 0, 0, 8) };
copy.Click += (copySender, args) =>
{
try { Clipboard.SetText(text.Text); copy.Content = "복사 완료"; }
catch (Exception ex) { MessageBox.Show(ex.Message, "진단 결과 복사 실패"); }
};
var panel = new DockPanel { Margin = new Thickness(12) };
DockPanel.SetDock(copy, Dock.Top);
panel.Children.Add(copy);
panel.Children.Add(text);
TextNetworkHelp.Text = "진단 완료. IP·AP와 수신 상태를 확인하세요.";
new Window { Title = "DCT 연결 진단", Owner = this, Width = 900, Height = 650,
MinWidth = 600, MinHeight = 350, Content = panel, WindowStartupLocation = WindowStartupLocation.CenterOwner }.ShowDialog();
}
catch (Exception ex) { TextNetworkHelp.Text = "진단 실패: " + ex.Message; }
finally { BtnNetworkDiagnostics.IsEnabled = true; }
}
private async void BtnAllowNetwork_Click(object sender, RoutedEventArgs e)
{
if (!_server.IsRunning)
{
TextNetworkHelp.Text = "서버 시작 후 통신 허용을 누르세요.";
return;
}
int port = _server.Port;
bool direct = ComboNetworkMode.SelectedIndex == 0;
var addresses = direct ? _wifiDirectHost?.GetListeningAddresses() ?? new string[0]
: new[] { ComboIp.SelectedItem?.ToString() ?? "" };
if (addresses.Length == 0)
{
TextNetworkHelp.Text = "태블릿에서 이 PC에 Direct 연결 후 다시 누르세요.";
return;
}
BtnAllowNetwork.IsEnabled = false;
BtnToggleServer.IsEnabled = false;
try
{
var settings = new NetworkFirewallSettings(NetworkSupport.ExecutablePath, port, direct ? "direct" : "ap", string.Join(",", addresses));
var info = new System.Diagnostics.ProcessStartInfo(settings.ProgramPath, settings.HelperArguments)
{ UseShellExecute = true, Verb = "runas", WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden };
TextNetworkHelp.Text = "Windows 관리자 승인을 기다리는 중입니다...";
using (var process = System.Diagnostics.Process.Start(info))
{
await Task.Run(() => process.WaitForExit());
TextNetworkHelp.Text = process.ExitCode == 0
? "통신 허용 적용 완료. 태블릿에서 다시 연결하세요. 계속 실패하면 연결 진단을 확인하세요."
: "통신 허용 실패. 관리자 알림과 연결 진단을 확인하세요.";
}
AppendLog("[NETWORK] " + TextNetworkHelp.Text);
}
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
TextNetworkHelp.Text = "관리자 승인을 취소했습니다. 통신 허용은 적용되지 않았습니다.";
}
catch (Exception ex) { TextNetworkHelp.Text = "통신 허용 실패: " + ex.Message; }
finally { BtnAllowNetwork.IsEnabled = true; BtnToggleServer.IsEnabled = true; }
}
private void BtnConfigureAp_Click(object sender, RoutedEventArgs e)
{
string script = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configure-HotspotAp.ps1");

243
DCT_test/DCT_test/NetworkSupport.cs

@ -0,0 +1,243 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace DCT_test
{
public sealed class NetworkFirewallSettings
{
public string ProgramPath { get; private set; }
public string LocalAddresses { get; private set; }
public int Port { get; private set; }
public string Mode { get; private set; }
public string RuleName { get; private set; }
public NetworkFirewallSettings(string programPath, int port, string mode, string addresses)
{
if (port < 1 || port > 65535) throw new ArgumentException("포트는 1~65535여야 합니다.");
if (mode != "direct" && mode != "ap") throw new ArgumentException("잘못된 연결 모드입니다.");
if (string.IsNullOrWhiteSpace(programPath) || !Path.IsPathRooted(programPath) ||
!string.Equals(Path.GetExtension(programPath), ".exe", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("실행 파일의 전체 경로가 필요합니다.");
var parsed = (addresses ?? "").Split(',').Select(value =>
{
IPAddress address;
if (!IPAddress.TryParse(value, out address) || address.AddressFamily != AddressFamily.InterNetwork)
throw new ArgumentException("허용할 PC IPv4 주소를 먼저 확인하세요.");
var bytes = address.GetAddressBytes();
if (bytes[0] == 0 || bytes[0] == 127 || bytes[0] >= 224 ||
!string.Equals(value, address.ToString(), StringComparison.Ordinal))
throw new ArgumentException("명시적인 PC IPv4 주소만 허용할 수 있습니다.");
return address.ToString();
}).Distinct().OrderBy(value => value, StringComparer.Ordinal).ToArray();
ProgramPath = Path.GetFullPath(programPath);
LocalAddresses = string.Join(",", parsed);
Port = port;
Mode = mode;
using (var hash = SHA256.Create())
{
string key = BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(ProgramPath.ToUpperInvariant())))
.Replace("-", "").Substring(0, 16);
RuleName = "DCT-Test-TCP-" + key + "-" + mode + "-" + port.ToString(CultureInfo.InvariantCulture);
}
}
public string HelperArguments => "--allow-network " + Port.ToString(CultureInfo.InvariantCulture) + " " + Mode + " " + LocalAddresses;
}
public static class NetworkSupport
{
public const string FirewallGroup = "DCT Test Network Access";
public static string ExecutablePath => Process.GetCurrentProcess().MainModule.FileName;
// Shared with the verifier: no COM or OS mutations occur in this method.
public static void ConfigureRule(object target, NetworkFirewallSettings settings)
{
dynamic rule = target;
rule.Name = settings.RuleName;
rule.Description = "DCT 테스트 서버: 현재 실행 파일, 지정 PC IPv4와 TCP 포트, 로컬 서브넷만 허용";
rule.Grouping = FirewallGroup;
rule.ApplicationName = settings.ProgramPath;
rule.Protocol = 6; // NET_FW_IP_PROTOCOL_TCP; must precede LocalPorts.
rule.LocalPorts = settings.Port.ToString(CultureInfo.InvariantCulture);
rule.LocalAddresses = settings.LocalAddresses;
rule.RemoteAddresses = "LocalSubnet";
rule.Direction = 1; // inbound
rule.Action = 1; // allow
rule.Profiles = 7; // domain/private/public; IP and application remain scoped.
rule.EdgeTraversal = false;
rule.Enabled = true;
}
public static void AllowFirewall(NetworkFirewallSettings settings)
{
dynamic policy = null, rule = null;
try
{
policy = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FwPolicy2", true));
if ((int)policy.LocalPolicyModifyState != 0)
throw new InvalidOperationException("현재 방화벽 정책에서 로컬 규칙을 적용할 수 없습니다. 관리자 정책을 확인하세요.");
foreach (dynamic existing in policy.Rules)
{
if ((string)existing.Name == settings.RuleName)
{
if (!string.Equals((string)existing.ApplicationName, settings.ProgramPath, StringComparison.OrdinalIgnoreCase) ||
(string)existing.Grouping != FirewallGroup || (int)existing.Action != 1 ||
(int)existing.Direction != 1 || (int)existing.Protocol != 6)
{
Marshal.FinalReleaseComObject((object)existing);
throw new InvalidOperationException("같은 이름의 다른 방화벽 규칙이 있습니다. 연결 진단을 확인하세요.");
}
rule = existing;
break;
}
Marshal.FinalReleaseComObject((object)existing);
}
bool isNew = rule == null;
if (isNew) rule = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FWRule", true));
ConfigureRule((object)rule, settings);
// Same executable/mode/port updates its own rule after DHCP changes.
// Explicit block rules and all other applications' rules remain untouched.
if (isNew) policy.Rules.Add(rule);
}
finally
{
if (rule != null) Marshal.FinalReleaseComObject((object)rule);
if (policy != null) Marshal.FinalReleaseComObject((object)policy);
}
}
public static string BuildReport(string mode, int port, string selectedAddress,
bool serverRunning, string[] directAddresses, string[] clients)
{
var report = new StringBuilder();
report.AppendLine("DCT 연결 진단 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
report.AppendLine("PC 이름: " + Environment.MachineName);
report.AppendLine("실행 파일: " + ExecutablePath);
report.AppendLine("모드: " + mode + " / 포트: " + port);
report.AppendLine("이 프로그램 서버: " + (serverRunning ? "실행 중" : "중지 — 서버 시작을 먼저 누르세요."));
report.AppendLine("연결된 TCP 클라이언트: " + (clients.Length == 0 ? "없음" : string.Join(", ", clients)));
if (mode == "direct")
report.AppendLine("Direct 데이터 리스너: " + (directAddresses.Length == 0
? "아직 없음 — 서버 시작 후 태블릿에서 이 PC에 Direct 연결하세요."
: string.Join(", ", directAddresses.Select(ip => ip + ":" + port))));
else report.AppendLine("앱에 입력할 주소: " + selectedAddress + ":" + port);
report.AppendLine();
report.AppendLine("[현재 어댑터 IPv4]");
try
{
bool selectedFound = false;
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
{
foreach (var address in adapter.GetIPProperties().UnicastAddresses
.Where(value => value.Address.AddressFamily == AddressFamily.InterNetwork))
{
report.AppendLine(adapter.Name + " / " + adapter.OperationalStatus + " / " + address.Address +
" / 마스크 " + address.IPv4Mask + " / " + adapter.Description);
if (adapter.OperationalStatus == OperationalStatus.Up && address.Address.ToString() == selectedAddress)
selectedFound = true;
}
}
if (mode != "direct" && !selectedFound)
report.AppendLine("주의: 선택 IP가 현재 활성 어댑터에 없습니다. 서버를 중지하고 현재 Wi-Fi IP를 다시 선택하세요.");
}
catch (Exception ex) { report.AppendLine("IP 조회 실패: " + ex.Message); }
report.AppendLine();
report.AppendLine("[Windows TCP 수신 포트]");
try
{
var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners().Where(item => item.Port == port).ToArray();
report.AppendLine(listeners.Length == 0 ? "조회된 수신 포트 없음" : string.Join(", ", listeners.Select(item => item.ToString())));
report.AppendLine("전체 프로세스의 조회 결과입니다. Direct의 별도 네트워크 구획 리스너는 위 Direct 데이터 리스너 표시도 확인하세요.");
}
catch (Exception ex) { report.AppendLine("수신 포트 조회 실패: " + ex.Message); }
report.AppendLine();
AppendFirewallReport(report, port);
report.AppendLine();
report.AppendLine("[현재 Wi-Fi AP]");
report.AppendLine(ReadWifiStatus());
report.AppendLine("[연결이 계속 실패할 때]");
report.AppendLine("• 태블릿과 PC의 AP/서브넷 및 입력 IP·포트를 맞추세요. Direct에서는 위 Direct 주소를 사용합니다.");
report.AppendLine("• 서버 실행 후 '통신 허용 (관리자)'을 누르고 태블릿에서 다시 연결하세요.");
report.AppendLine("• 허용 규칙이 있어도 명시적 차단·관리자 정책·보안 프로그램은 별도 확인이 필요합니다.");
report.AppendLine("• 상대 IP의 MAC 주소도 확인되지 않는 ARP 실패는 포트 허용만으로 해결되지 않습니다. AP 격리/게스트망/다른 어댑터 연결을 확인하세요.");
report.AppendLine("로컬 수신/허용 규칙은 상대 태블릿의 실제 접속 성공을 보장하지 않습니다.");
return report.ToString();
}
private static void AppendFirewallReport(StringBuilder report, int port)
{
report.AppendLine("[Windows 방화벽]");
dynamic policy = null;
try
{
policy = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FwPolicy2", true));
int profiles = policy.CurrentProfileTypes;
report.AppendLine("활성 프로필: " + profiles + " (1=도메인, 2=개인, 4=공용)");
report.AppendLine("로컬 정책 수정 상태: " + policy.LocalPolicyModifyState + " (0=적용 가능)");
int ownRules = 0;
foreach (dynamic rule in policy.Rules)
{
try
{
if (!string.Equals((string)rule.ApplicationName, ExecutablePath, StringComparison.OrdinalIgnoreCase)) continue;
if (!(bool)rule.Enabled || (int)rule.Direction != 1) continue;
if ((int)rule.Action == 0 && ((int)rule.Profiles & profiles) != 0)
report.AppendLine("주의: 이 실행 파일의 명시적 수신 차단 규칙: " + rule.Name);
if ((string)rule.Grouping == FirewallGroup && (int)rule.Protocol == 6 &&
(string)rule.LocalPorts == port.ToString(CultureInfo.InvariantCulture) && (int)rule.Action == 1)
{
ownRules++;
report.AppendLine("프로그램 허용 규칙: " + rule.Name + " / IP " + rule.LocalAddresses + " / 원격 " + rule.RemoteAddresses);
}
}
finally { if (Marshal.IsComObject(rule)) Marshal.FinalReleaseComObject((object)rule); }
}
if (ownRules == 0) report.AppendLine("이 실행 파일/포트의 프로그램 전용 허용 규칙 없음 (다른 허용 정책의 유무와는 별개)");
}
catch (Exception ex) { report.AppendLine("방화벽 조회 실패: " + ex.Message); }
finally { if (policy != null) Marshal.FinalReleaseComObject((object)policy); }
}
private static string ReadWifiStatus()
{
try
{
var info = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "netsh.exe"), "wlan show interfaces")
{
UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true
};
using (var process = Process.Start(info))
using (var bytes = new MemoryStream())
{
var output = process.StandardOutput.BaseStream.CopyToAsync(bytes);
if (!process.WaitForExit(5000))
{
process.Kill();
return "Wi-Fi AP 조회 시간 초과";
}
if (!output.Wait(1000)) return "Wi-Fi AP 조회 출력 시간 초과";
return "종료 코드 " + process.ExitCode + Environment.NewLine + DecodeConsoleOutput(bytes.ToArray());
}
}
catch (Exception ex) { return "Wi-Fi AP 조회 실패: " + ex.Message; }
}
public static string DecodeConsoleOutput(byte[] bytes)
{
try { return new UTF8Encoding(false, true).GetString(bytes); }
catch (DecoderFallbackException)
{
return Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.OEMCodePage).GetString(bytes);
}
}
}
}

25
DCT_test/DCT_test/WifiDirectHost.cs

@ -43,6 +43,31 @@ namespace DCT_test
public WifiDirectHost(int tcpPort) { _tcpPort = tcpPort; }
public string[] GetListeningAddresses()
{
#if WIFI_DIRECT_WINRT
lock (_endpointLock) return System.Linq.Enumerable.ToArray(_boundAddresses);
#else
return new string[0];
#endif
}
public static string GetDisplayName()
{
#if WIFI_DIRECT_WINRT
try
{
var name = new Windows.Security.ExchangeActiveSyncProvisioning.EasClientDeviceInformation().FriendlyName;
if (!string.IsNullOrWhiteSpace(name)) return name;
}
catch (Exception ex)
{
System.Diagnostics.Trace.TraceWarning("Wi-Fi Direct PC 이름 조회 실패: " + ex.Message);
}
#endif
return Environment.MachineName;
}
public bool Start()
{
if (IsRunning) return true;

6
DCT_test/Verify-ApMode.ps1

@ -1,4 +1,4 @@
param([string]$BuildFolder = 'ApMode')
param([string]$BuildFolder = 'Release')
if ($PSVersionTable.PSEdition -ne 'Desktop') {
& "$env:SystemRoot/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile -STA -ExecutionPolicy Bypass -File $PSCommandPath -BuildFolder $BuildFolder
@ -15,7 +15,9 @@ try {
$port = $apWindow.FindName('TextPort')
$controls = $apWindow.FindName('ApControls')
$externalGuide = $apWindow.FindName('ExternalApGuide')
Assert-Ap ($mode.SelectedIndex -eq 0 -and $port.Text -eq '6000' -and $port.IsEnabled) 'Direct default must remain editable 6000'
Assert-Ap ($mode.SelectedIndex -eq 1 -and $port.Text -eq '6000' -and $port.IsEnabled) 'Current app default must be external AP with editable port'
$mode.SelectedIndex = 0
Assert-Ap ($port.Text -eq '6000' -and $port.IsEnabled -and $controls.Visibility -eq 'Collapsed') 'Legacy Direct must remain selectable with editable 6000'
$mode.SelectedIndex = 1
$ips = $apWindow.FindName('ComboIp')
Assert-Ap ($port.Text -eq '6000' -and $port.IsEnabled -and $ips.IsEnabled -and $controls.Visibility -eq 'Collapsed' -and $externalGuide.Visibility -eq 'Visible') 'External AP current IP and editable port, no hotspot controls'

66
DCT_test/Verify-NetworkSupport.ps1

@ -0,0 +1,66 @@
param([string]$BuildFolder = 'NetworkTools')
if ($PSVersionTable.PSEdition -ne 'Desktop') {
& "$env:SystemRoot/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile -STA -ExecutionPolicy Bypass -File $PSCommandPath -BuildFolder $BuildFolder
exit $LASTEXITCODE
}
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName PresentationFramework
$networkAssembly = [Reflection.Assembly]::LoadFrom((Join-Path $PSScriptRoot "DCT_test/bin/$BuildFolder/DCT_test.exe"))
function Assert-Network($condition, $message) { if (-not $condition) { throw $message } }
Add-Type @'
public class FirewallRuleProbe {
public string Name {get;set;}
public string Description {get;set;}
public string Grouping {get;set;}
public string ApplicationName {get;set;}
public int Protocol {get;set;}
public string LocalPorts {get;set;}
public string LocalAddresses {get;set;}
public string RemoteAddresses {get;set;}
public int Direction {get;set;}
public int Action {get;set;}
public int Profiles {get;set;}
public bool EdgeTraversal {get;set;}
public bool Enabled {get;set;}
}
'@
$exe = $networkAssembly.Location
$settings = [DCT_test.NetworkFirewallSettings]::new($exe, 5000, 'ap', '192.168.200.5,192.168.200.5')
$rule = New-Object FirewallRuleProbe
[DCT_test.NetworkSupport]::ConfigureRule($rule, $settings)
Assert-Network ($rule.ApplicationName -eq $exe -and $rule.Protocol -eq 6 -and $rule.LocalPorts -eq '5000') 'Rule must restrict executable and TCP port'
Assert-Network ($rule.LocalAddresses -eq '192.168.200.5' -and $rule.RemoteAddresses -eq 'LocalSubnet') 'Rule must restrict local IPv4 and remote subnet'
Assert-Network ($rule.Direction -eq 1 -and $rule.Action -eq 1 -and $rule.Enabled -and $rule.Profiles -eq 7 -and -not $rule.EdgeTraversal) 'Inbound allow policy contract'
Assert-Network ($settings.HelperArguments -eq '--allow-network 5000 ap 192.168.200.5') 'Validated UAC helper arguments'
$changedIp = [DCT_test.NetworkFirewallSettings]::new($exe,5000,'ap','192.168.200.6')
$direct = [DCT_test.NetworkFirewallSettings]::new($exe,5000,'direct','192.168.137.1')
Assert-Network ($changedIp.RuleName -eq $settings.RuleName -and $direct.RuleName -ne $settings.RuleName) 'DHCP updates same rule; Direct/AP remain separate'
foreach ($badAddress in @('*','0.0.0.0','127.0.0.1','224.0.0.1','::1','192.168.200.0/24','192.168.200.5;whoami','192.168.1','')) {
$rejected = $false
try { [void][DCT_test.NetworkFirewallSettings]::new($exe,5000,'ap',$badAddress) } catch { $rejected = $true }
Assert-Network $rejected ('Unsafe address accepted: ' + $badAddress)
}
foreach ($badPort in @(0,65536)) {
$rejected = $false
try { [void][DCT_test.NetworkFirewallSettings]::new($exe,$badPort,'ap','192.168.200.5') } catch { $rejected = $true }
Assert-Network $rejected 'Invalid port accepted'
}
$report = [DCT_test.NetworkSupport]::BuildReport('ap',5000,'203.0.113.111',$false,[string[]]@(),[string[]]@())
Assert-Network ($report.Contains('203.0.113.111:5000') -and $report.Contains('ARP') -and $report.Contains('netsh') -eq $false) 'Report must identify selected endpoint and ARP limitation without requiring terminal commands'
Assert-Network ($report.Contains('Windows TCP') -and $report.Contains('Wi-Fi AP')) 'Report must include listener and AP inventory'
Assert-Network ($report.Contains([DCT_test.NetworkSupport]::ExecutablePath)) 'Report must use actual executable path'
$korean = [string][char]0xC5F0 + [string][char]0xACB0
Assert-Network ([DCT_test.NetworkSupport]::DecodeConsoleOutput([Text.Encoding]::UTF8.GetBytes($korean)) -eq $korean) 'Korean UTF-8 console text must remain readable'
$oem = [Text.Encoding]::GetEncoding([Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage)
if ($oem.CodePage -eq 949) {
Assert-Network ([DCT_test.NetworkSupport]::DecodeConsoleOutput($oem.GetBytes($korean)) -eq $korean) 'Legacy Korean console output must remain readable'
}
Write-Output $report
$window = New-Object DCT_test.MainWindow
try {
Assert-Network ($null -ne $window.FindName('BtnNetworkDiagnostics') -and $null -ne $window.FindName('BtnAllowNetwork')) 'Network action buttons missing'
$window.GetType().GetMethod('BtnAllowNetwork_Click',[Reflection.BindingFlags]'Instance,NonPublic').Invoke($window,@($null,$null)) | Out-Null
Assert-Network ($window.FindName('BtnAllowNetwork').IsEnabled -and $window.FindName('BtnToggleServer').IsEnabled) 'Stopped server must not launch or lock the admin helper'
} finally { $window.Close() }
Write-Output 'PASS: firewall plan scope, validation, helper arguments, DHCP/mode rule identity, real read-only network/firewall/AP report, stopped-server guard (no firewall changes or UAC)'

29
DCT_test/Verify-Regression.ps1

@ -1,6 +1,8 @@
param([string]$BuildFolder = 'Release')
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName PresentationFramework
[void][Reflection.Assembly]::LoadFrom((Join-Path $PSScriptRoot 'DCT_test/bin/Release/DCT_test.exe'))
[void][Reflection.Assembly]::LoadFrom((Join-Path $PSScriptRoot "DCT_test/bin/$BuildFolder/DCT_test.exe"))
function Assert-True($value, $message) { if (!$value) { throw $message } }
$packet = [DCT_test.DctPacket]::Create(0xA3, [byte[]](1, 161, 0x12, 0x34), 0)
$bytes = $packet.Encode()
@ -35,6 +37,8 @@ try {
$window = New-Object DCT_test.MainWindow
$flags = [Reflection.BindingFlags]'Instance,NonPublic'
$windowType = $window.GetType()
Assert-True ($window.FindName('ComboNetworkMode').SelectedIndex -eq 1) 'Current app AP/LAN default missing'
Assert-True ($window.FindName('TextDropTitle').Text.Contains('(.txt)')) 'Drop guide must match supported TXT log input'
[void]$windowType.GetMethod('BtnClearFile_Click', $flags).Invoke($window, [object[]]@($null, $null))
[void]$windowType.GetMethod('StartTelemetryStreaming', $flags).Invoke($window, $null)
Assert-True (-not $windowType.GetField('_isPlaying', $flags).GetValue($window)) 'Empty source started playback'
@ -45,6 +49,29 @@ try {
foreach ($retainedName in @('ComboSpeed', 'CheckLoop', 'GridClients', 'TextLogConsole')) {
Assert-True ($null -ne $window.FindName($retainedName)) ('Missing playback control: ' + $retainedName)
}
$console = $window.FindName('TextLogConsole')
$expander = $window.FindName('PacketLogExpander')
Assert-True ($null -ne $expander -and -not $expander.IsExpanded) 'Packet log must start collapsed'
$append = $windowType.GetMethod('AppendLog', $flags)
[void]$append.Invoke($window, [object[]]@('collapsed-log-marker'))
Assert-True ($console.Text.Contains('collapsed-log-marker')) 'Collapsing must not stop log collection'
for ($i = 0; $i -lt 3; $i++) {
$expander.IsExpanded = $true
$window.Dispatcher.Invoke([Action]{}, [Windows.Threading.DispatcherPriority]::ContextIdle)
# A hidden Window does not arrange its content; measure the root directly.
$window.Content.Measure([Windows.Size]::new(1120,820))
$window.Content.Arrange([Windows.Rect]::new(0,0,1120,820))
$window.Content.UpdateLayout()
Assert-True ($expander.Parent.RowDefinitions[1].Height.IsStar -and $console.ActualHeight -gt 100) 'Expanded log must use available height'
Assert-True ($console.Text.Contains('collapsed-log-marker')) 'Expanding must retain collected logs'
$expander.IsExpanded = $false
$window.Dispatcher.Invoke([Action]{}, [Windows.Threading.DispatcherPriority]::ContextIdle)
$window.Content.UpdateLayout()
Assert-True ($expander.Parent.RowDefinitions[1].Height.IsAuto -and $expander.ActualHeight -lt 80) 'Collapsed log must show only its header'
}
[void]$windowType.GetMethod('BtnClearLog_Click', $flags).Invoke($window, [object[]]@($null, $null))
Assert-True ($console.Text.Length -eq 0) 'Clear log must remain usable'
Write-Output 'PASS: packet log default collapsed, retained collection, expanded/collapsed layout (3 rounds), clear log'
Write-Output 'PASS: log-only WPF controls, empty-source START stays stopped, client list and packet console retained'
}
finally {

177
DCT_test/Verify-Transport.cs

@ -11,6 +11,7 @@ namespace DCT_test
{
public static class TransportRegression
{
private static bool fragmentRequests;
private static void Check(bool value, string message) { if (!value) throw new Exception(message); }
private static DctPacket Receive(NetworkStream stream)
{
@ -27,9 +28,182 @@ namespace DCT_test
private static DctPacket Request(NetworkStream stream, byte command, params byte[] payload)
{
byte[] bytes = DctPacket.Create(command, payload).Encode();
stream.Write(bytes, 0, bytes.Length);
WriteFrame(stream, bytes);
return Receive(stream);
}
// Replay the current Android client's transactions over the real TCP listener.
// No Wi-Fi adapter, firewall, equipment or Android application state is changed.
public static void RunCurrentAppCompatibility()
{
RunCurrentAppCompatibility(null, false);
}
public static void RunCurrentAppCompatibility(Func<IPEndPoint, TcpClient> connect, bool fragmented)
{
fragmentRequests = fragmented;
var server = new DctTcpServer();
int playbackStops = 0;
server.OnStopPlaybackRequested += () => Interlocked.Increment(ref playbackStops);
var start = typeof(DctTcpServer).GetMethod("Start", BindingFlags.Instance | BindingFlags.NonPublic,
null, new[] { typeof(int), typeof(IPAddress), typeof(bool) }, null);
Check((bool)start.Invoke(server, new object[] { 0, IPAddress.Loopback, false }), "App compatibility listener");
try
{
var listener = (TcpListener)typeof(DctTcpServer).GetField("_listener", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(server);
using (var client = ConnectClient((IPEndPoint)listener.LocalEndpoint, connect))
{
var stream = client.GetStream();
stream.ReadTimeout = 1500;
Check(SpinWait.SpinUntil(() => server.ActiveClients.Count == 1, 1000), "TCP attach");
ClientSession session = null;
foreach (var active in server.ActiveClients) session = active;
Check(!session.Streaming && !session.ReadyToMeasure && !stream.DataAvailable,
"Wi-Fi/TCP attach must not enable protocol or playback");
Check(Request(stream, 0x05).CommandId == 0x85, "Idle connection probe 05 -> 85");
Check(!session.Streaming && session.HandshakeMask == 0, "Probe must not connect/start");
ConfigureCurrentApp(stream, 0);
Check(Request(stream, 0x20, 1).Payload[0] == 1, "Current MDB START");
Check(SpinWait.SpinUntil(() => session.Streaming, 1000), "MDB stream active after A0");
int generation = session.TelemetryGeneration;
Check(Request(stream, 0x05).CommandId == 0x85 && session.Streaming &&
generation == session.TelemetryGeneration, "Probe must not reset playback or ACK generation");
var mdbFrame = DctPacket.Create(0xA3, new byte[] { 1, 0xA0, 7 });
server.BroadcastLogPacketAsync(mdbFrame, 0, CancellationToken.None).GetAwaiter().GetResult();
CheckFrame(Receive(stream), mdbFrame, "MDB log frame after probe");
Write(stream, 0x23, 1);
var smFrame = DctPacket.Create(0xA4, new byte[] { 1, 0, 8, 0, 0xFF, 0x7E, 0xA1, 3 });
server.BroadcastLogPacketAsync(smFrame, 3, CancellationToken.None).GetAwaiter().GetResult();
CheckFrame(Receive(stream), smFrame, "Sensor log frame");
Write(stream, 0x24, 1);
Check(Request(stream, 0x21).CommandId == 0xA1 && !session.Streaming && session.ReadyToMeasure,
"STOP keeps protocol configured on same TCP");
Check(Request(stream, 0x05).CommandId == 0x85, "Probe after STOP");
Check(Request(stream, 0x20, 1).Payload[0] == 1, "Restart after STOP");
Check(SpinWait.SpinUntil(() => session.Streaming, 1000), "Restart active");
// Current Android stream watchdog replays handshake/configuration on
// the existing TCP. ENABLE must retire the previous measurement run.
Check(Request(stream, 0x00).CommandId == 0x80, "Recovery ENABLE");
Check(!session.Streaming && !session.ReadyToMeasure && session.HandshakeMask == 1 &&
session.ConfigurationMask == 0 && session.XcpUseMode == 0, "ENABLE clears old session readiness");
Check(Request(stream, 0x20, 1).Payload[0] != 1, "Old settings cannot authorize START");
ConfigureCurrentApp(stream, 0);
Check(Request(stream, 0x20, 1).Payload[0] == 1, "Recovery START after full setup");
Check(SpinWait.SpinUntil(() => session.Streaming, 1000), "Recovery stream active");
Write(stream, 0x0F);
// DISABLE has no 8F response in the PC/current Android protocol.
Check(Request(stream, 0x05).CommandId == 0x85, "DISABLE must not leave a fictional 8F response");
Check(!session.Streaming && !session.ReadyToMeasure && session.HandshakeMask == 0,
"DISABLE ends protocol without closing TCP");
Check(Request(stream, 0x20, 1).Payload[0] != 1, "START after DISABLE needs fresh Connect/setup");
ConfigureCurrentApp(stream, 1);
Check(Request(stream, 0x20, 1).Payload[0] == 1, "Current A2L START without MDB commands");
Check(SpinWait.SpinUntil(() => session.Streaming, 1000), "XCP stream active");
var xcpFrame = DctPacket.Create(0xD0, new byte[] { 0, 1, 2, 0x34, 0x12 });
server.BroadcastLogPacketAsync(xcpFrame, 1, CancellationToken.None).GetAwaiter().GetResult();
CheckFrame(Receive(stream), xcpFrame, "XCP log frame");
Write(stream, 0x50, 1);
Check(Request(stream, 0x05).CommandId == 0x85, "Probe after XCP ACK");
Check(server.TotalCanAcks == 1 && server.TotalSmAcks == 1 && server.TotalXcpAcks == 1, "Current host ACKs consumed");
Check(Request(stream, 0x21).CommandId == 0xA1, "XCP STOP");
ConfigureCurrentApp(stream, 2);
Check(Request(stream, 0x20, 1).Payload[0] == 1, "CAN-only START without MDB/XCP lists");
Check(SpinWait.SpinUntil(() => session.Streaming, 1000), "CAN-only stream active");
var canFrame = DctPacket.Create(0xA3, new byte[] { 2, 1, 1, 1, 0x24, 2, 1, 7 });
server.BroadcastLogPacketAsync(canFrame, 2, CancellationToken.None).GetAwaiter().GetResult();
CheckFrame(Receive(stream), canFrame, "CAN-DBC log frame");
Write(stream, 0x23, 1);
Check(Request(stream, 0x05).CommandId == 0x85 && server.TotalCanAcks == 2, "CAN-only ACK/probe");
int stopsBeforeOtherClient = Volatile.Read(ref playbackStops);
using (var other = ConnectClient((IPEndPoint)listener.LocalEndpoint, connect))
{
other.GetStream().ReadTimeout = 1500;
Check(Request(other.GetStream(), 0x00).CommandId == 0x80, "Second client Connect");
Write(other.GetStream(), 0x0F);
Check(Request(other.GetStream(), 0x05).CommandId == 0x85 && session.Streaming &&
Volatile.Read(ref playbackStops) == stopsBeforeOtherClient,
"Other client's Connect/DISABLE must not stop an active client");
}
Request(stream, 0x21);
}
}
finally { server.Stop(); fragmentRequests = false; }
Console.WriteLine("PASS: current Android TCP attach/05/Connect/MDB/STOP/restart/recovery/DISABLE/A2L/CAN-only/ACK parity");
}
private static void Write(NetworkStream stream, byte command, params byte[] payload)
{
byte[] bytes = DctPacket.Create(command, payload).Encode();
WriteFrame(stream, bytes);
}
private static TcpClient ConnectClient(IPEndPoint endpoint, Func<IPEndPoint, TcpClient> connect)
{
if (connect != null) return connect(endpoint);
var client = new TcpClient();
try { client.Connect(endpoint); return client; }
catch { client.Dispose(); throw; }
}
private static void WriteFrame(NetworkStream stream, byte[] bytes)
{
if (!fragmentRequests) { stream.Write(bytes, 0, bytes.Length); return; }
for (int i = 0; i < bytes.Length; i++)
{
stream.Write(bytes, i, 1);
Thread.Sleep(1);
}
}
private static void CheckFrame(DctPacket actual, DctPacket expected, string message)
{
byte[] received = actual.Encode(), sent = expected.Encode();
Check(received.Length == sent.Length, message + " length");
for (int i = 0; i < sent.Length; i++) Check(received[i] == sent[i], message + " byte " + i);
}
private static void ConfigureCurrentApp(NetworkStream stream, byte mode)
{
Check(Request(stream, 0x00).CommandId == 0x80, "Connect ACK");
Check(Request(stream, 0x01).CommandId == 0x81, "Version response");
Check(Request(stream, 0x09, 0).Payload[0] == 0, "Save interval query");
Check(Request(stream, 0x09, 3).Payload[0] == 3, "Hardware environment query");
Check(Request(stream, 0x09, 6, 0).Payload[0] == 6, "CAN mode ACK");
var sm = Request(stream, 0x12, 1, 0, 0, 0, 0, 0);
Check(sm.CommandId == 0x92 && sm.Payload.Length == 2 && sm.Payload[1] == 0,
"No-sensor response count is zero, not a failure status");
Check(Request(stream, 0x12, 1, 1, 0xFF, 0, 0, 0).Payload[1] == 1, "Enabled SM1 count");
var channels = Request(stream, 0x12, 2, 0);
Check(channels.Payload.Length == 3 && channels.Payload[0] == 2 && channels.Payload[1] == 0 &&
channels.Payload[2] == 8, "SM channel query subtype/index/count");
Request(stream, 0x12, 1, 0, 0, 0, 0, 0);
Check(Request(stream, 0x03).Payload.Length == 6, "Hardware ID length");
Check(Request(stream, 0x04, 12, 0, 0, 7, 0xEA, 10, 27, 0x20).Payload[0] == 1, "Time ACK");
Request(stream, 0x06, 3, 49, 50, 51, 2);
Request(stream, 0x08, 0, 4, 49, 48, 109, 98);
if (mode == 1)
{
Check(Request(stream, 0x40, 1).Payload[0] == 1, "XCP USE");
Check(Request(stream, 0x41, 0, 0x90, 6, 0, 0, 0x93, 6, 0, 0).Payload[0] == 1, "XCP IF_DATA");
Check(Request(stream, 0x42, 0, 1, 2, 0, 0x10, 0, 0x70).Payload[0] == 1, "XCP measurements");
}
else if (mode == 0)
{
Request(stream, 0x10, 1, 0, 0xA0, 0, 1, 0, 1, 1);
Request(stream, 0x13, 1, 0xA0, 1, 1);
}
else Check(Request(stream, 0x40, 2).Payload[0] == 1, "CAN-only XCP disabled");
Request(stream, 0x11, 0, 0, 0);
// Android does not wait for 97. Drain the optional legacy server reply in this fixture.
Request(stream, 0x17, 0x31);
Request(stream, 0x09, 0); Request(stream, 0x09, 3);
Request(stream, 0x09, 6, 0); Request(stream, 0x12, 1, 0, 0, 0, 0, 0);
}
private static void VerifySlowReaderRecovery()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
@ -123,6 +297,7 @@ namespace DCT_test
public static void Run()
{
RunCurrentAppCompatibility();
VerifyApListener();
VerifySlowReaderRecovery();
VerifyQueuedControlSurvivesStall();

9
DCT_test/Verify-Transport.ps1

@ -1,4 +1,4 @@
param([string]$BuildFolder = 'Release')
param([string]$BuildFolder = 'Release', [switch]$ProtocolOnly)
if ($PSVersionTable.PSEdition -ne 'Desktop') {
# DCT_test is a .NET Framework/WPF binary. PowerShell 7 cannot reference
@ -8,7 +8,9 @@ if ($PSVersionTable.PSEdition -ne 'Desktop') {
if (-not (Test-Path -LiteralPath $legacyPowerShell)) {
throw 'Windows PowerShell 5.1 is required to run Verify-Transport.ps1.'
}
& $legacyPowerShell -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath -BuildFolder $BuildFolder
$arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, '-BuildFolder', $BuildFolder)
if ($ProtocolOnly) { $arguments += '-ProtocolOnly' }
& $legacyPowerShell @arguments
exit $LASTEXITCODE
}
@ -22,4 +24,5 @@ $transportLoaded = [Reflection.Assembly]::LoadFrom($transportAssembly)
# framework references used by the helper source.
Add-Type -Path (Join-Path $PSScriptRoot 'Verify-Transport.cs') `
-ReferencedAssemblies @($transportLoaded.FullName, 'System.dll', 'System.Core.dll')
[DCT_test.TransportRegression]::Run()
if ($ProtocolOnly) { [DCT_test.TransportRegression]::RunCurrentAppCompatibility() }
else { [DCT_test.TransportRegression]::Run() }

148
DCT_test/verification/Verify-WifiDirect.cs

@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Windows.Networking;
using Windows.Networking.Sockets;
namespace DCT_test
{
// Uses the production WinRT bridge callback and copy loops on loopback.
// Radio discovery, WPS and a physical tablet are deliberately outside this test.
internal static class VerifyWifiDirect
{
private sealed class Bridge : IDisposable
{
private readonly StreamSocketListener listener = new StreamSocketListener();
private readonly WifiDirectHost host;
private readonly CancellationTokenSource cancellation = new CancellationTokenSource();
private int receivedConnections;
public Bridge(IPEndPoint server)
{
host = new WifiDirectHost(server.Port);
typeof(WifiDirectHost).GetField("_cts", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(host, cancellation);
var callback = typeof(WifiDirectHost).GetMethod("SocketListener_ConnectionReceived",
BindingFlags.Instance | BindingFlags.NonPublic);
listener.Control.NoDelay = true;
listener.ConnectionReceived += (sender, args) =>
{
Interlocked.Increment(ref receivedConnections);
callback.Invoke(host, new object[] { sender, args });
};
listener.BindEndpointAsync(new HostName("127.0.0.1"), "0")
.AsTask().GetAwaiter().GetResult();
}
public TcpClient Connect()
{
var client = new TcpClient { NoDelay = true };
client.Connect(IPAddress.Loopback, int.Parse(listener.Information.LocalPort));
if (!SpinWait.SpinUntil(() => Volatile.Read(ref receivedConnections) > 0, 2000))
{
client.Dispose();
throw new Exception("WinRT listener did not accept the test client");
}
return client;
}
public void Dispose()
{
host.Dispose();
listener.Dispose();
// The async bridge can still observe cancellation while disposing its I/O.
}
}
private static int Main()
{
try
{
TransportRegression.RunCurrentAppCompatibility();
foreach (bool fragmented in new[] { false, true })
{
var bridges = new List<Bridge>();
try
{
TransportRegression.RunCurrentAppCompatibility(endpoint =>
{
var bridge = new Bridge(endpoint);
bridges.Add(bridge);
return bridge.Connect();
}, fragmented);
Console.WriteLine("PASS: production Wi-Fi Direct WinRT bridge protocol parity; fragmented=" + fragmented);
}
finally { foreach (var bridge in bridges) bridge.Dispose(); }
}
VerifyBridgeBytes();
return 0;
}
catch (Exception ex) { Console.Error.WriteLine(ex); return 1; }
}
private static void VerifyBridgeBytes()
{
var server = new TcpListener(IPAddress.Loopback, 0);
server.Start();
try
{
using (var bridge = new Bridge((IPEndPoint)server.LocalEndpoint))
using (var peer = bridge.Connect())
using (var target = server.AcceptTcpClient())
{
var peerStream = peer.GetStream();
var targetStream = target.GetStream();
peerStream.ReadTimeout = targetStream.ReadTimeout = 5000;
peerStream.WriteTimeout = targetStream.WriteTimeout = 5000;
byte[] payload = new byte[2 * 1024 * 1024];
for (int i = 0; i < payload.Length; i++) payload[i] = (byte)(i * 37 + i / 251);
var up = Task.Run(() => peerStream.Write(payload, 0, payload.Length));
CheckBytes(targetStream, payload);
up.GetAwaiter().GetResult();
var down = Task.Run(() => targetStream.Write(payload, 0, payload.Length));
Thread.Sleep(1800); // A temporary paused receiver must keep byte order and the connection.
CheckBytes(peerStream, payload);
down.GetAwaiter().GetResult();
targetStream.WriteByte(0x85);
if (peerStream.ReadByte() != 0x85) throw new Exception("Control byte after receiver pause");
target.Close();
try
{
if (peerStream.ReadByte() != -1) throw new Exception("Bridge must propagate server close");
}
catch (IOException ex)
{
// Disposing a WinRT socket with its upstream read pending can
// surface a TCP reset. Only accept that after our explicit close,
// after every payload byte and the following control were checked.
var socketError = ex.InnerException as SocketException;
if (socketError == null || (socketError.SocketErrorCode != System.Net.Sockets.SocketError.ConnectionReset &&
socketError.SocketErrorCode != System.Net.Sockets.SocketError.ConnectionAborted)) throw;
}
Console.WriteLine("PASS: production WinRT bridge 2MiB each direction, exact bytes, 1.8s reader pause, control after pause, close propagation");
}
}
finally { server.Stop(); }
}
private static void CheckBytes(Stream source, byte[] expected)
{
byte[] buffer = new byte[1777];
int offset = 0;
while (offset < expected.Length)
{
int count = source.Read(buffer, 0, Math.Min(buffer.Length, expected.Length - offset));
if (count == 0) throw new EndOfStreamException("Bridge closed before all bytes arrived");
for (int i = 0; i < count; i++)
if (buffer[i] != expected[offset + i]) throw new Exception("Bridge changed byte " + (offset + i));
offset += count;
}
}
}
}

23
DCT_test/verification/Verify-WifiDirect.csproj

@ -0,0 +1,23 @@
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<OutputPath>bin\</OutputPath>
<AssemblyName>Verify-WifiDirect</AssemblyName>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
<TestBuildFolder Condition="'$(TestBuildFolder)' == ''">DirectName</TestBuildFolder>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.28000.2705" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="DCT_test">
<HintPath>..\DCT_test\bin\$(TestBuildFolder)\DCT_test.exe</HintPath>
</Reference>
<Compile Include="Verify-WifiDirect.cs" />
<Compile Include="..\Verify-Transport.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
Loading…
Cancel
Save