Browse Source

Add AP/LAN connectivity and Wi-Fi endpoint controls

master
seuuung 3 weeks ago
parent
commit
e42e7fd5d4
  1. 2
      Android/DCT Work/Configuration/AUTO_TEST.dpc
  2. 12
      Android/DCT Work/Configuration/Sample.mdb.dpc
  3. 721
      Android/app/DCT Work/Configuration/AUTO_TEST.dpc
  4. 1096
      Android/app/DCT Work/Configuration/Sample.mdb.dpc
  5. 354
      Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt
  6. 8
      Android/app/src/main/AndroidManifest.xml
  7. 7
      Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt
  8. 10
      Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt
  9. 353
      Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt
  10. 90
      Android/app/src/main/java/com/dct/app/data/communication/WifiApState.kt
  11. 40
      Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt
  12. 400
      Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt
  13. 99
      Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt
  14. 11
      Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt
  15. 4
      Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt
  16. 9
      Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt
  17. 46
      Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt
  18. 10
      Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt
  19. 3
      Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt
  20. 287
      Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt
  21. 152
      Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt
  22. 2
      Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt
  23. 11
      Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt
  24. 66
      DCT_test/DCT_test/Configure-HotspotAp.ps1
  25. 3
      DCT_test/DCT_test/DCT_test.csproj
  26. 40
      DCT_test/DCT_test/DctTcpServer.cs
  27. 22
      DCT_test/DCT_test/MainWindow.xaml
  28. 79
      DCT_test/DCT_test/MainWindow.xaml.cs
  29. 26
      DCT_test/README.md
  30. 79
      DCT_test/Verify-ApMode.ps1
  31. 43
      DCT_test/Verify-Transport.cs
  32. 14
      README.md

2
Android/DCT Work/Configuration/AUTO_TEST.dpc

@ -664,7 +664,7 @@
"signals": [],
"alphanumerical_signals": [],
"can_search_query": "",
"created_at": 1788419362700,
"created_at": 1788829282840,
"id": "page_1",
"time_window_preset": "SEC_10",
"title": "Page 1",

12
Android/DCT Work/Configuration/Sample.mdb.dpc

@ -112,7 +112,7 @@
"pages": [{
"scroll_y": 0,
"scroll_x": 0,
"created_at": 1788419362160,
"created_at": 1788829282298,
"time_window_preset": "SEC_10",
"title": "Page 1",
"windows": [
@ -122,7 +122,7 @@
"closable": true,
"maximized": false,
"dashboard_grid_mode": "GRID_3X3",
"created_at": 1788419362280,
"created_at": 1788829282409,
"target_parameter_name": "",
"time_window_preset": "MIN_1",
"type": "SIGNAL_LIST",
@ -137,7 +137,7 @@
"width": 240,
"hidden_signal_ids": [],
"y": 16,
"id": "win_signal_list_35efb6",
"id": "win_signal_list_71fba1",
"highlight_colors": {},
"height": 520
},
@ -147,7 +147,7 @@
"closable": true,
"maximized": false,
"dashboard_grid_mode": "GRID_3X3",
"created_at": 1788419362280,
"created_at": 1788829282409,
"target_parameter_name": "",
"time_window_preset": "MIN_1",
"type": "MDB_SIGNAL_LIST",
@ -162,7 +162,7 @@
"width": 420,
"hidden_signal_ids": [],
"y": 16,
"id": "win_mdb_signal_list_abc4cb",
"id": "win_mdb_signal_list_dae8f1",
"highlight_colors": {},
"height": 520
}
@ -172,7 +172,7 @@
"alphanumerical_signals": [],
"can_search_query": "",
"id": "page_1",
"active_window_id": "win_mdb_signal_list_abc4cb",
"active_window_id": "win_mdb_signal_list_dae8f1",
"time_window_sec": 10
}],
"active_page_id": "page_1"

721
Android/app/DCT Work/Configuration/AUTO_TEST.dpc

@ -8,552 +8,547 @@
"name": "mainWindow",
"right": 1616
}]},
"canmode": 1,
"logfile_path_use_basefolder": 1,
"isave_logfile": 1,
"copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work/Data",
"maker": 2,
"version": "0.0.0.2",
"writemf4": 0,
"android_parameter_descriptions": {},
"sm_property": {"modules": [
"sensormodule_property": {"property": [
{
"channels": [
"sm_index": 0,
"sm_use": 1,
"items": [
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-01 ch",
"index": 0,
"description": ""
"Descrip": "",
"channel_idx": 0
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-02 ch",
"index": 1,
"description": ""
"Descrip": "",
"channel_idx": 1
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-03 ch",
"index": 2,
"description": ""
"Descrip": "",
"channel_idx": 2
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-04 ch",
"index": 3,
"description": ""
"Descrip": "",
"channel_idx": 3
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-05 ch",
"index": 4,
"description": ""
"Descrip": "",
"channel_idx": 4
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-06 ch",
"index": 5,
"description": ""
"Descrip": "",
"channel_idx": 5
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-07 ch",
"index": 6,
"description": ""
"Descrip": "",
"channel_idx": 6
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-08 ch",
"index": 7,
"description": ""
"Descrip": "",
"channel_idx": 7
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-09 ch",
"index": 8,
"description": ""
"Descrip": "",
"channel_idx": 8
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-10 ch",
"index": 9,
"description": ""
"Descrip": "",
"channel_idx": 9
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-11 ch",
"index": 10,
"description": ""
"Descrip": "",
"channel_idx": 10
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-12 ch",
"index": 11,
"description": ""
"Descrip": "",
"channel_idx": 11
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-13 ch",
"index": 12,
"description": ""
"Descrip": "",
"channel_idx": 12
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-14 ch",
"index": 13,
"description": ""
"Descrip": "",
"channel_idx": 13
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-15 ch",
"index": 14,
"description": ""
"Descrip": "",
"channel_idx": 14
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "01-16 ch",
"index": 15,
"description": ""
"Descrip": "",
"channel_idx": 15
}
],
"index": 0,
"is_used": 1
]
},
{
"channels": [
"sm_index": 1,
"sm_use": 0,
"items": [
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-01 ch",
"index": 0,
"description": ""
"Descrip": "",
"channel_idx": 0
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-02 ch",
"index": 1,
"description": ""
"Descrip": "",
"channel_idx": 1
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-03 ch",
"index": 2,
"description": ""
"Descrip": "",
"channel_idx": 2
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-04 ch",
"index": 3,
"description": ""
"Descrip": "",
"channel_idx": 3
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-05 ch",
"index": 4,
"description": ""
"Descrip": "",
"channel_idx": 4
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-06 ch",
"index": 5,
"description": ""
"Descrip": "",
"channel_idx": 5
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-07 ch",
"index": 6,
"description": ""
"Descrip": "",
"channel_idx": 6
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-08 ch",
"index": 7,
"description": ""
"Descrip": "",
"channel_idx": 7
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-09 ch",
"index": 8,
"description": ""
"Descrip": "",
"channel_idx": 8
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-10 ch",
"index": 9,
"description": ""
"Descrip": "",
"channel_idx": 9
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-11 ch",
"index": 10,
"description": ""
"Descrip": "",
"channel_idx": 10
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-12 ch",
"index": 11,
"description": ""
"Descrip": "",
"channel_idx": 11
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-13 ch",
"index": 12,
"description": ""
"Descrip": "",
"channel_idx": 12
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-14 ch",
"index": 13,
"description": ""
"Descrip": "",
"channel_idx": 13
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-15 ch",
"index": 14,
"description": ""
"Descrip": "",
"channel_idx": 14
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "02-16 ch",
"index": 15,
"description": ""
"Descrip": "",
"channel_idx": 15
}
],
"index": 1,
"is_used": 0
]
},
{
"channels": [
"sm_index": 2,
"sm_use": 0,
"items": [
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-01 ch",
"index": 0,
"description": ""
"Descrip": "",
"channel_idx": 0
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-02 ch",
"index": 1,
"description": ""
"Descrip": "",
"channel_idx": 1
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-03 ch",
"index": 2,
"description": ""
"Descrip": "",
"channel_idx": 2
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-04 ch",
"index": 3,
"description": ""
"Descrip": "",
"channel_idx": 3
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-05 ch",
"index": 4,
"description": ""
"Descrip": "",
"channel_idx": 4
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-06 ch",
"index": 5,
"description": ""
"Descrip": "",
"channel_idx": 5
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-07 ch",
"index": 6,
"description": ""
"Descrip": "",
"channel_idx": 6
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-08 ch",
"index": 7,
"description": ""
"Descrip": "",
"channel_idx": 7
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-09 ch",
"index": 8,
"description": ""
"Descrip": "",
"channel_idx": 8
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-10 ch",
"index": 9,
"description": ""
"Descrip": "",
"channel_idx": 9
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-11 ch",
"index": 10,
"description": ""
"Descrip": "",
"channel_idx": 10
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-12 ch",
"index": 11,
"description": ""
"Descrip": "",
"channel_idx": 11
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-13 ch",
"index": 12,
"description": ""
"Descrip": "",
"channel_idx": 12
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-14 ch",
"index": 13,
"description": ""
"Descrip": "",
"channel_idx": 13
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-15 ch",
"index": 14,
"description": ""
"Descrip": "",
"channel_idx": 14
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "03-16 ch",
"index": 15,
"description": ""
"Descrip": "",
"channel_idx": 15
}
],
"index": 2,
"is_used": 0
]
},
{
"channels": [
"sm_index": 3,
"sm_use": 0,
"items": [
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-01 ch",
"index": 0,
"description": ""
"Descrip": "",
"channel_idx": 0
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-02 ch",
"index": 1,
"description": ""
"Descrip": "",
"channel_idx": 1
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-03 ch",
"index": 2,
"description": ""
"Descrip": "",
"channel_idx": 2
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-04 ch",
"index": 3,
"description": ""
"Descrip": "",
"channel_idx": 3
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-05 ch",
"index": 4,
"description": ""
"Descrip": "",
"channel_idx": 4
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-06 ch",
"index": 5,
"description": ""
"Descrip": "",
"channel_idx": 5
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-07 ch",
"index": 6,
"description": ""
"Descrip": "",
"channel_idx": 6
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-08 ch",
"index": 7,
"description": ""
"Descrip": "",
"channel_idx": 7
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-09 ch",
"index": 8,
"description": ""
"Descrip": "",
"channel_idx": 8
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-10 ch",
"index": 9,
"description": ""
"Descrip": "",
"channel_idx": 9
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-11 ch",
"index": 10,
"description": ""
"Descrip": "",
"channel_idx": 10
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-12 ch",
"index": 11,
"description": ""
"Descrip": "",
"channel_idx": 11
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-13 ch",
"index": 12,
"description": ""
"Descrip": "",
"channel_idx": 12
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-14 ch",
"index": 13,
"description": ""
"Descrip": "",
"channel_idx": 13
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-15 ch",
"index": 14,
"description": ""
"Descrip": "",
"channel_idx": 14
},
{
"unit": "'C",
"color": 4278229580,
"is_voltage": 0,
"GRAPH": 1,
"VOLTAGE": 0,
"Color": 5020160,
"name": "04-16 ch",
"index": 15,
"description": ""
"Descrip": "",
"channel_idx": 15
}
],
"index": 3,
"is_used": 0
]
}
]},
"copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work/Data",
"writemf4": 0,
"android_parameter_descriptions": {},
"external_file": [
{
"ext": "mdb",
@ -578,7 +573,10 @@
}
],
"oldcanmode": 0,
"graphItem_property": {"graph_linear": true},
"graphItem_property": {
"graph_linear": true,
"items": []
},
"hw_device": {
"host_address": "",
"com_port": "",
@ -596,14 +594,7 @@
"DIDF193SHW": "",
"DIDPreSetting": 49
},
"logfile_path": "\\Data",
"ask_info": {
"DependROM": false,
"seedKeyPath": "",
"ASKFWAddr": "",
"ASKSTMin": 0
},
"writecsv": 1,
"tileWnd_property": {"tileWndInfo": []},
"ota_info": {"ota_item": {"item": [
{
"OTAAreaBAddress": "",
@ -654,7 +645,6 @@
"OTAAreaAAddress": ""
}
]}},
"name": "AUTO_TEST.dpc",
"android_workspace": {
"schema_version": 1,
"pages": [{
@ -664,7 +654,7 @@
"signals": [],
"alphanumerical_signals": [],
"can_search_query": "",
"created_at": 1788421152937,
"created_at": 1791768092404,
"id": "page_1",
"time_window_preset": "SEC_10",
"title": "Page 1",
@ -674,5 +664,22 @@
"active_page_id": "page_1"
},
"autostart_measuring_recording": 0,
"base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work"
"base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work",
"canmode": 1,
"logfile_path_use_basefolder": 1,
"isave_logfile": 1,
"csv_with_miliseconds": false,
"maker": 2,
"version": "0.0.0.2",
"multiXgraphWnd_property": {"multiXgraphWndInfo": []},
"graphWnd_property": {"graphWndInfo": []},
"logfile_path": "\\Data",
"ask_info": {
"DependROM": false,
"seedKeyPath": "",
"ASKFWAddr": "",
"ASKSTMin": 0
},
"writecsv": 1,
"name": "AUTO_TEST"
}

1096
Android/app/DCT Work/Configuration/Sample.mdb.dpc

File diff suppressed because it is too large

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

@ -0,0 +1,354 @@
package com.dct.app.presentation.dialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.dct.app.data.communication.*
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import android.Manifest
import android.content.pm.PackageManager
import androidx.test.platform.app.InstrumentationRegistry
class WifiApConnectionUiTest {
@get:Rule val compose = createComposeRule()
private val device = WifiApDevice("DCT_DEVICE", "", -40, WifiApSecurity.WPA2)
private val state = mutableStateOf(WifiApUiState(enabled = true, permissionGranted = true,
locationEnabled = true, devices = listOf(device)))
private var connected: Pair<WifiApDevice, String>? = null
private var cancelled = 0
private var dismissed = false
private var disconnected = 0
private var retried = 0
private var current = 0
private var scanned = 0
private var settingsAction: String? = null
private val endpoint = mutableStateOf(WifiApEndpoint())
private val endpointEditable = mutableStateOf(true)
private val communicationReady = mutableStateOf(false)
private val socketConnected = mutableStateOf(false)
private val connectedEndpoint = mutableStateOf<WifiApEndpoint?>(null)
private var savedEndpoint: WifiApEndpoint? = null
@Test fun installedAppHasPermissionToRequestTheEquipmentNetwork() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals(PackageManager.PERMISSION_GRANTED,
context.checkSelfPermission(Manifest.permission.CHANGE_NETWORK_STATE))
}
private fun show() {
compose.setContent { MaterialTheme {
WifiApConnectionContent(state.value, communicationReady.value, socketConnected.value,
onScan = { scanned++ }, onConnect = { ap, password -> connected = ap to password },
onCancel = { cancelled++ }, onDisconnect = { disconnected++ }, onRetry = { retried++ },
onCurrent = { current++ }, onPermissions = {}, onSettings = { settingsAction = it },
onDismiss = { dismissed = true }, endpoint = endpoint.value, connectedEndpoint = connectedEndpoint.value,
canEditEndpoint = endpointEditable.value,
onEndpointChange = { savedEndpoint = it; endpoint.value = it; true })
} }
}
@Test fun fixedEndpointAndPasswordFlowReplaceP2pAndManualTcpInputs() {
show()
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-ssid").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-connect").assertDoesNotExist()
compose.onNodeWithText("Wi-Fi Direct").assertDoesNotExist()
compose.onNodeWithTag("wifi:manual-ip").assertDoesNotExist()
compose.onNodeWithTag("wifi:manual-port").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick()
compose.onNodeWithTag("wifi:ap-connect").assertIsNotEnabled()
compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678")
compose.onNodeWithTag("wifi:ap-connect").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(device to "12345678", connected) }
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
}
@Test fun closingTheSheetDoesNotCancelTheApLeaseAndPendingConnectionCanBeCancelled() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = device.ssid)
show()
compose.onNodeWithText("연결 취소").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals(1, cancelled) }
compose.onNodeWithText("닫기").assertIsDisplayed().performClick()
compose.runOnIdle { assertTrue(dismissed); assertEquals(1, cancelled) }
}
@Test fun permissionDenialDisablesApRequestButLeavesSettingsAvailable() {
state.value = state.value.copy(permissionGranted = false)
show()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertIsNotEnabled()
compose.onNodeWithContentDescription("새로고침").assertIsNotEnabled()
compose.onNodeWithText("권한 허용").assertIsDisplayed().assertIsEnabled()
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("네트워크 추가").assertIsNotEnabled()
compose.onNodeWithText("Wi-Fi 설정").assertIsEnabled().performClick()
compose.runOnIdle { assertNull(connected) }
}
@Test fun cancellingPasswordPromptForgetsCredentialsWithoutConnecting() {
show()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick()
compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678")
compose.onNodeWithText("취소").performClick()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick()
assertEquals("", compose.onNodeWithTag("wifi:ap-password").fetchSemanticsNode()
.config[SemanticsProperties.EditableText].text)
compose.runOnIdle { assertNull(connected); assertEquals(0, cancelled) }
}
@Test fun advancedInputsAndFixedEndpointAreOnlyInMenuAndDetails() {
show()
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("네트워크 추가").performClick()
compose.onNodeWithTag("wifi:ap-ssid").performTextInput("HIDDEN_DCT")
compose.onNodeWithTag("wifi:ap-password").performTextInput("12345678")
compose.onNodeWithTag("wifi:ap-connect").performClick()
compose.runOnIdle { assertEquals("HIDDEN_DCT", connected!!.first.ssid) }
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("연결 정보").performClick()
compose.onNodeWithTag("wifi:ap-endpoint").assertTextContains("192.168.4.1:5000", substring = true)
compose.onNodeWithText("확인").performClick()
compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist()
}
@Test fun connectedRowOpensDetailsForRetryAndDisconnect() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED,
selectedSsid = device.ssid, localAddress = "192.168.4.20")
show()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertDoesNotExist()
compose.onNodeWithText("연결 해제").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed()
compose.onNodeWithText("통신 재연결").performClick()
compose.runOnIdle { assertEquals(1, retried); assertEquals(0, disconnected) }
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("연결 해제").performClick()
compose.runOnIdle { assertEquals(1, disconnected) }
}
@Test fun alreadyConnectedSystemWifiIsVisibleBeforeTcpAndIsNotRepeatedInScanResults() {
state.value = state.value.copy(connectedNetworks = listOf(
ConnectedWifiNetwork("101", device.ssid, "192.168.1.20")))
show()
compose.onNodeWithTag("wifi:connected:101").assertIsDisplayed()
compose.onNodeWithText("Wi-Fi 연결됨").assertIsDisplayed()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-current").assertDoesNotExist()
compose.onNodeWithTag("wifi:connected:101").performClick()
compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed()
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.onNodeWithText("연결 해제").assertDoesNotExist()
compose.onNodeWithText("확인").performClick()
compose.runOnIdle { assertEquals(0, current); assertEquals(0, retried); assertNull(connected) }
}
@Test fun managedNetworkIsShownOnlyOnceAndOtherConnectedWifiRemainsVisible() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = device.ssid,
localAddress = "192.168.4.20", activeNetworkKey = "101", connectedNetworks = listOf(
ConnectedWifiNetwork("101", device.ssid, "192.168.4.20"),
ConnectedWifiNetwork("102", "Office", "192.168.1.20")))
show()
compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed()
compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist()
compose.onNodeWithTag("wifi:connected:102").assertIsDisplayed()
compose.onNodeWithText("통신 연결 대기").assertIsDisplayed()
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("통신 재연결").assertIsDisplayed()
}
@Test fun listFooterStartsCommunicationAndDetailsNoLongerContainsTheConnectButton() {
endpoint.value = WifiApEndpoint("192.168.1.100", 6000)
state.value = state.value.copy(connectedNetworks = listOf(
ConnectedWifiNetwork("101", device.ssid, "192.168.1.20")))
show()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled()
compose.onNodeWithTag("wifi:connected:101").performClick()
compose.onNode(hasText("접속 설정: 192.168.1.100:6000") and
hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertIsDisplayed()
compose.onAllNodes(hasTestTag("wifi:connected-connect") and
hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertCountEquals(0)
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
compose.onNodeWithText("확인").performClick()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled().performClick()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed()
compose.runOnIdle {
assertEquals(1, current); assertEquals(0, retried); assertEquals(0, disconnected)
assertFalse(dismissed)
assertNull(connected); assertEquals(WifiApEndpoint("192.168.1.100", 6000), endpoint.value)
}
}
@Test fun communicationShortcutIsDisabledForUnavailableAmbiguousOrBusyConnections() {
val network = ConnectedWifiNetwork("101", device.ssid, "192.168.1.20")
val base = state.value.copy(connectedNetworks = listOf(network))
state.value = base
show()
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled()
listOf(base.copy(permissionGranted = false), base.copy(enabled = false),
base.copy(phase = WifiApPhase.CONNECTING),
base.copy(connectedNetworks = emptyList()),
base.copy(connectedNetworks = listOf(network.copy(localAddress = null))),
base.copy(connectedNetworks = listOf(network, ConnectedWifiNetwork("102", "Other", "192.168.2.20"))))
.forEach { unavailable ->
compose.runOnIdle { state.value = unavailable }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
}
compose.runOnIdle { state.value = base; endpointEditable.value = false }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
compose.runOnIdle { endpointEditable.value = true; communicationReady.value = true }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
compose.runOnIdle { communicationReady.value = false; socketConnected.value = true }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
compose.runOnIdle { socketConnected.value = false; endpoint.value = WifiApEndpoint("0.0.0.0", 6000) }
compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled()
compose.runOnIdle { endpoint.value = WifiApEndpoint() }
compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled()
compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
}
@Test fun wifiOnlyRowShowsConfiguredIpAndPortWithoutClaimingTcpConnection() {
endpoint.value = WifiApEndpoint("192.168.1.100", 6000)
state.value = state.value.copy(connectedNetworks = listOf(
ConnectedWifiNetwork("101", "Office", "192.168.1.20")))
show()
compose.onNodeWithTag("wifi:connected:101:endpoint", useUnmergedTree = true)
.assertIsDisplayed().assertTextEquals("접속 설정: 192.168.1.100:6000")
compose.runOnIdle { endpoint.value = WifiApEndpoint("192.168.4.1", 5000) }
compose.onNodeWithTag("wifi:connected:101:endpoint", useUnmergedTree = true).assertTextEquals("접속 설정: 192.168.4.1:5000")
compose.onNodeWithText("Wi-Fi 연결됨").assertIsDisplayed()
compose.runOnIdle { assertEquals(0, current); assertNull(connected) }
}
@Test fun connectedRowKeepsTheActualPeerWhenNextConnectionSettingsChange() {
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = device.ssid,
localAddress = "192.168.1.20", activeNetworkKey = "101")
endpoint.value = WifiApEndpoint("192.168.1.100", 7000)
connectedEndpoint.value = WifiApEndpoint("192.168.1.100", 6000)
socketConnected.value = true
show()
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true)
.assertIsDisplayed().assertTextEquals("통신 주소: 192.168.1.100:6000")
compose.runOnIdle {
communicationReady.value = true
endpoint.value = WifiApEndpoint("192.168.4.1", 5000)
}
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("통신 주소: 192.168.1.100:6000")
compose.runOnIdle { connectedEndpoint.value = null }
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("통신 주소 확인 중…")
compose.runOnIdle { socketConnected.value = false; communicationReady.value = false }
compose.onNodeWithTag("wifi:ap-current:endpoint", useUnmergedTree = true).assertTextEquals("접속 설정: 192.168.4.1:5000")
}
@Test fun unknownSystemWifiIsDisplayedWithoutScanResultsAndDisappearsWhenLost() {
state.value = state.value.copy(permissionGranted = false, devices = emptyList(),
connectedNetworks = listOf(ConnectedWifiNetwork("101", localAddress = "192.168.1.20")))
show()
compose.onNodeWithTag("wifi:connected:101").assertIsDisplayed().performClick()
compose.onNodeWithText("IP 주소: 192.168.1.20").assertIsDisplayed()
compose.runOnIdle { state.value = state.value.copy(connectedNetworks = emptyList()) }
compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist()
compose.onNodeWithText("IP 주소: 192.168.1.20").assertDoesNotExist()
compose.runOnIdle { assertEquals(0, current); assertEquals(0, disconnected) }
}
@Test fun openNetworkHasNoPasswordAndUnsupportedSecurityUsesSettings() {
state.value = state.value.copy(devices = listOf(device.copy(security = WifiApSecurity.OPEN)))
show()
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick()
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-connect").assertIsEnabled().performClick()
compose.runOnIdle {
assertEquals("", connected!!.second)
state.value = state.value.copy(devices = listOf(device.copy(security = WifiApSecurity.UNSUPPORTED)))
}
compose.onNodeWithTag("wifi:ap:DCT_DEVICE").performClick()
compose.onNodeWithTag("wifi:ap-connect").assertDoesNotExist()
compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist()
compose.onNodeWithText("Wi-Fi 설정").performClick()
compose.runOnIdle { assertEquals(android.provider.Settings.ACTION_WIFI_SETTINGS, settingsAction) }
}
@Test fun longNetworkListScrollsWithoutHidingHeaderOrCloseButton() {
state.value = state.value.copy(devices = (1..30).map { device.copy(ssid = "DCT_$it") },
connectedNetworks = listOf(ConnectedWifiNetwork("101", "Office", "192.168.1.20")))
show()
compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31)
compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed()
compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled()
val connectBounds = compose.onNodeWithTag("wifi:connected-connect").fetchSemanticsNode().boundsInRoot
val closeBounds = compose.onNodeWithText("닫기").fetchSemanticsNode().boundsInRoot
assertTrue(closeBounds.left >= connectBounds.right)
assertEquals(connectBounds.center.y, closeBounds.center.y, 1f)
compose.onNodeWithContentDescription("새로고침").assertIsDisplayed().performClick()
compose.onNodeWithText("닫기").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals(1, scanned); assertTrue(dismissed) }
}
@Test fun alreadyConnectedWifiActionRemainsAvailableInOverflow() {
show()
compose.onNodeWithText("현재 Wi-Fi로 연결").assertDoesNotExist()
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("현재 Wi-Fi로 연결").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, current) }
}
@Test fun tcpSettingsValidateAndSavePcAddressWithoutConnectingThenRestoreDefault() {
show()
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100")
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("65536")
compose.onNodeWithTag("wifi:tcp-save").assertIsNotEnabled()
compose.onNodeWithTag("wifi:tcp-connect").assertIsNotEnabled()
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000")
compose.onNodeWithTag("wifi:tcp-save").performClick()
compose.runOnIdle {
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint)
assertEquals(0, current); assertEquals(0, retried)
}
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("연결 정보").performClick()
compose.onNodeWithTag("wifi:ap-endpoint").assertTextContains("192.168.1.100:6000", substring = true)
compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithText("장비 기본값").performClick()
compose.onNodeWithTag("wifi:tcp-save").performClick()
compose.runOnIdle { assertEquals(WifiApEndpoint(), savedEndpoint) }
}
@Test fun savingAndConnectingUsesNewTargetForCurrentWifiAndConnectedAp() {
show()
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100")
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000")
compose.onNodeWithTag("wifi:tcp-connect").performClick()
compose.runOnIdle {
assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint)
assertEquals(1, current)
state.value = state.value.copy(phase = WifiApPhase.CONNECTED,
selectedSsid = "PC LAN", localAddress = "192.168.1.20")
}
compose.onNodeWithTag("wifi:ap-current").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("7000")
compose.onNodeWithTag("wifi:tcp-connect").performClick()
compose.runOnIdle {
assertEquals(7000, savedEndpoint!!.port)
assertEquals(1, retried); assertEquals(1, current)
}
}
@Test fun tcpSettingsCancelAndMeasurementGuardPreserveStoredTarget() {
show()
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("IP / 포트 설정").performClick()
compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100")
compose.onNodeWithText("취소").performClick()
compose.runOnIdle {
assertNull(savedEndpoint); assertEquals(WifiApEndpoint(), endpoint.value)
endpointEditable.value = false
}
compose.onNodeWithContentDescription("더보기").performClick()
compose.onNodeWithText("IP / 포트 설정").assertIsNotEnabled()
}
}

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

@ -10,16 +10,18 @@
<!-- Continuous local equipment measurement cannot use FCM during Doze. User opt-in only. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- ConnectivityManager.requestNetwork for the equipment's local-only Wi-Fi AP. -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<!-- Wi-Fi Direct & Location -->
<!-- AP scanning needs precise location even on Android 13+. P2P is retained but UI-disabled. -->
<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" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" tools:targetApi="tiramisu" />
<uses-feature android:name="android.hardware.wifi.direct" android:required="false" />

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

@ -79,11 +79,12 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner {
val executionState: StateFlow<MeasurementExecutionState> by lazy {
combine(viewModel.uiState, viewModel.packetRepository.connectionState,
viewModel.wifiSocketManager.isConnected, viewModel.wifiDirectState) { ui, connection, socket, p2p ->
MeasurementExecutionState.from(ui, connection, socket, p2p.phase, p2p.link != null)
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)
}.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.wifiDirectState.value.phase, viewModel.wifiDirectState.value.link != null,
viewModel.wifiApState.value.phase, viewModel.wifiApState.value.networkReady))
}
/** Start while handling a visible user connection request, before P2P/TCP negotiation begins. */

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

@ -18,13 +18,16 @@ data class MeasurementExecutionState(
val PREPARING = MeasurementExecutionState(true, "DCT · 연결 준비 중", "장비 연결을 준비하고 있습니다.")
fun from(ui: MainUiState, connection: DeviceConnectionState, socketConnected: Boolean,
p2pPhase: WifiDirectPhase, groupPresent: Boolean): MeasurementExecutionState {
p2pPhase: WifiDirectPhase, groupPresent: Boolean,
apPhase: com.dct.app.data.communication.WifiApPhase = com.dct.app.data.communication.WifiApPhase.IDLE,
apPresent: Boolean = false): MeasurementExecutionState {
val recording = ui.recordingState in setOf(MeasurementRecordingState.STARTING,
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING)
val measuring = ui.isMeasuring || connection == DeviceConnectionState.MEASURING
val connected = socketConnected || ui.isConnected || connection == DeviceConnectionState.READY
val negotiating = connection in setOf(DeviceConnectionState.CONNECTING, DeviceConnectionState.HANDSHAKING) ||
p2pPhase in setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING, WifiDirectPhase.DISCONNECTING)
p2pPhase in setOf(WifiDirectPhase.CONNECTING, WifiDirectPhase.CANCELLING, WifiDirectPhase.DISCONNECTING) ||
apPhase == com.dct.app.data.communication.WifiApPhase.CONNECTING
val title = when {
ui.recordingState == MeasurementRecordingState.STOPPING -> "DCT · 기록 마무리 중"
measuring && recording -> "DCT · 계측 및 기록 중"
@ -32,6 +35,7 @@ data class MeasurementExecutionState(
recording -> "DCT · 기록 처리 중"
connected -> "DCT · 장비 연결됨 / 계측 대기"
negotiating -> "DCT · 장비 연결 중"
apPresent -> "DCT · Wi-Fi AP 연결됨 / TCP 대기"
groupPresent -> "DCT · Wi-Fi Direct 연결됨 / TCP 대기"
else -> "DCT · 연결 종료"
}
@ -41,7 +45,7 @@ data class MeasurementExecutionState(
add("수신 ${ui.packetCount}건")
if (recording) add("기록 ${ui.recordingSampleCount}건")
}
return MeasurementExecutionState(measuring || recording || connected || negotiating || groupPresent,
return MeasurementExecutionState(measuring || recording || connected || negotiating || groupPresent || apPresent,
title, info.joinToString(" · "), measuring, recording, ui.recordingStartedAtMillis)
}
}

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

@ -0,0 +1,353 @@
package com.dct.app.data.communication
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.MacAddress
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiManager
import android.net.wifi.WifiInfo
import android.net.wifi.WifiNetworkSpecifier
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat
import androidx.annotation.RequiresApi
import androidx.core.location.LocationManagerCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException
/** Application-owned AP lease. Closing the dialog must not release the measurement network. */
class WifiApManager(context: Context,
private val onReady: (String) -> Unit,
private val onLost: () -> Unit,
private val sdk: Int = Build.VERSION.SDK_INT,
private val onConnecting: () -> Unit = {},
private val endpointProvider: () -> WifiApEndpoint = { WifiApEndpoint() }
) : AutoCloseable {
private val context = context.applicationContext
private val wifi = this.context.getSystemService(WifiManager::class.java)
private val connectivity = this.context.getSystemService(ConnectivityManager::class.java)
private val handler = Handler(Looper.getMainLooper())
private val state = MutableStateFlow(WifiApUiState())
val uiState = state.asStateFlow()
private var registered = false
private var closed = false
private var generation = 0L
private var callback: ConnectivityManager.NetworkCallback? = null
private var deadline: Runnable? = null
private var scanDeadline: Runnable? = null
private var route: WifiApRoute? = null
private var availableNetwork: Network? = null
private var observer: ConnectivityManager.NetworkCallback? = null
private val connectedNetworks = linkedMapOf<Network, ConnectedWifiNetwork>()
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) {
synchronized(this@WifiApManager) {
scanDeadline?.let(handler::removeCallbacks); scanDeadline = null
readScanResults()
}
} else refresh()
}
}
@Synchronized fun start() {
if (closed || registered) return
try {
ContextCompat.registerReceiver(context, receiver, IntentFilter().apply {
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
addAction(WifiManager.WIFI_STATE_CHANGED_ACTION)
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
addAction(LocationManager.MODE_CHANGED_ACTION)
}, ContextCompat.RECEIVER_EXPORTED)
registered = true
refresh()
observeConnectedNetworks()
} catch (_: Exception) { fail("Wi-Fi AP 상태 감시를 시작할 수 없습니다.") }
}
@Synchronized fun refresh() {
if (closed) return
val permissions = DctWifiAp.runtimePermissions(sdk).all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
val enabled = wifi?.isWifiEnabled == true
val location = context.getSystemService(LocationManager::class.java)?.let(LocationManagerCompat::isLocationEnabled) == true
state.value = state.value.copy(enabled = enabled, permissionGranted = permissions, locationEnabled = location)
refreshConnectedNetworks()
if ((!enabled || !permissions) && route != null) fail("Wi-Fi가 꺼졌거나 연결 권한이 해제되었습니다.")
}
private fun wifiCapabilities(capabilities: NetworkCapabilities?): Boolean =
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true &&
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
private fun ssid(raw: String?): String? = raw?.removeSurrounding("\"")
?.takeIf { it.isNotBlank() && it != WifiManager.UNKNOWN_SSID }
private fun canReadSsid(): Boolean = state.value.permissionGranted && state.value.locationEnabled
private fun networkSsid(capabilities: NetworkCapabilities?): String? =
if (sdk >= 29 && canReadSsid()) ssid((capabilities?.transportInfo as? WifiInfo)?.ssid) else null
private fun localAddress(properties: LinkProperties?): String? = properties?.linkAddresses
?.firstOrNull { DctWifiAp.isUsableWifiAddress(it.address) }?.address?.hostAddress
private fun publishConnectedNetworks() {
state.value = state.value.copy(connectedNetworks = connectedNetworks.values.toList())
}
/** Snapshot on open/resume; callbacks below use their payloads rather than racing synchronous queries. */
@SuppressLint("MissingPermission")
@Suppress("DEPRECATION")
private fun refreshConnectedNetworks() {
if (!state.value.enabled) {
connectedNetworks.clear(); publishConnectedNetworks(); return
}
try {
val networks = connectivity?.allNetworks.orEmpty().mapNotNull { network ->
val capabilities = connectivity?.getNetworkCapabilities(network)
if (wifiCapabilities(capabilities)) network to capabilities else null
}.distinctBy { it.first }
// connectionInfo refers to the primary Wi-Fi: use it only when there is no ambiguous second Wi-Fi.
val primarySsid = if (networks.size == 1 && canReadSsid()) ssid(wifi?.connectionInfo?.ssid) else null
val snapshot = networks.associate { (network, capabilities) ->
val name = if (canReadSsid()) networkSsid(capabilities) ?: primarySsid ?: connectedNetworks[network]?.ssid else null
network to ConnectedWifiNetwork(network.toString(), name, localAddress(connectivity?.getLinkProperties(network)))
}
connectedNetworks.clear(); connectedNetworks.putAll(snapshot); publishConnectedNetworks()
} catch (_: SecurityException) {
connectedNetworks.clear(); publishConnectedNetworks()
state.value = state.value.copy(error = "현재 Wi-Fi 정보를 읽을 수 없습니다. 앱 권한을 확인하세요.")
}
}
private fun observeConnectedNetworks() {
if (closed || observer != null || connectivity == null) return
val listener = if (sdk >= 31) ConnectedWifiCallback(ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO)
else ConnectedWifiCallback()
try {
connectivity.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(), listener)
observer = listener
} catch (_: Exception) {
state.value = state.value.copy(error = "현재 Wi-Fi 상태 감시를 시작할 수 없습니다. Wi-Fi 설정에서 확인하세요.")
}
}
private inner class ConnectedWifiCallback : ConnectivityManager.NetworkCallback {
constructor() : super()
@RequiresApi(31) constructor(flags: Int) : super(flags)
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
synchronized(this@WifiApManager) {
if (closed || !state.value.enabled) return
if (wifiCapabilities(capabilities)) {
val previous = connectedNetworks[network]
val name = if (sdk < 29 && canReadSsid()) previous?.ssid else networkSsid(capabilities)
connectedNetworks[network] = ConnectedWifiNetwork(network.toString(), name, previous?.localAddress)
} else connectedNetworks.remove(network)
publishConnectedNetworks()
}
}
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) {
synchronized(this@WifiApManager) {
if (closed) return
val previous = connectedNetworks[network] ?: return
connectedNetworks[network] = previous.copy(localAddress = localAddress(properties))
publishConnectedNetworks()
}
}
override fun onLost(network: Network) {
synchronized(this@WifiApManager) {
if (closed) return
connectedNetworks.remove(network); publishConnectedNetworks()
}
}
}
@SuppressLint("MissingPermission")
@Synchronized fun scan() {
refresh()
if (!state.value.canScan || closed) return
readScanResults() // Cached results remain usable when Android throttles a scan.
try {
if (wifi?.startScan() != true) {
state.value = state.value.copy(error = "검색이 제한되었습니다. 기존 목록을 확인하거나 잠시 후 다시 검색하세요.")
return
}
state.value = state.value.copy(phase = if (route == null) WifiApPhase.SCANNING else WifiApPhase.CONNECTED, error = null)
scanDeadline?.let(handler::removeCallbacks)
scanDeadline = Runnable { synchronized(this) {
readScanResults()
state.value = state.value.copy(error = "검색 완료 알림이 지연되었습니다. 목록이 없으면 Wi-Fi 설정에서 확인하세요.")
} }.also { handler.postDelayed(it, 15_000) }
} catch (_: SecurityException) { state.value = state.value.copy(error = "AP 검색에 정확한 위치 권한이 필요합니다.") }
}
@SuppressLint("MissingPermission")
private fun readScanResults() {
if (closed || !state.value.permissionGranted) return
try {
@Suppress("DEPRECATION")
val devices = wifi?.scanResults.orEmpty().filter { it.SSID.isNotBlank() }.map {
WifiApDevice(it.SSID, it.BSSID, it.level, WifiApSecurity.fromCapabilities(it.capabilities))
}.sortedByDescending { it.level }.distinctBy { it.ssid to it.security }
state.value = state.value.copy(devices = devices,
phase = if (state.value.phase == WifiApPhase.SCANNING) WifiApPhase.IDLE else state.value.phase)
} catch (_: SecurityException) { state.value = state.value.copy(error = "AP 검색 권한을 확인하세요.") }
}
@SuppressLint("MissingPermission")
@Synchronized fun connect(device: WifiApDevice, password: String) {
refresh()
if (closed || !state.value.canScan) return
if (device.ssid.isBlank() || !device.security.acceptsPassword(password)) {
state.value = state.value.copy(error = "AP 보안 방식과 비밀번호를 확인하세요. WPA2는 8~63자리입니다.")
return
}
if (sdk < 29) {
state.value = state.value.copy(error = "Android 10 미만은 Wi-Fi 설정에서 AP에 연결한 뒤 현재 AP로 통신 연결을 누르세요.")
return
}
onConnecting() // Invalidate old transport/cleanup before a new AP request owns the lease.
val replacing = route != null
release()
if (replacing) onLost()
val current = generation
state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = device.ssid, localAddress = null, activeNetworkKey = null, error = null)
try {
val specifier = WifiNetworkSpecifier.Builder().setSsid(device.ssid)
if (device.bssid.isNotBlank()) specifier.setBssid(MacAddress.fromString(device.bssid))
when (device.security) {
WifiApSecurity.WPA2 -> specifier.setWpa2Passphrase(password)
WifiApSecurity.WPA3 -> specifier.setWpa3Passphrase(password)
else -> Unit
}
val request = NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.setNetworkSpecifier(specifier.build()).build()
val listener = listener(current, device.ssid)
callback = listener
connectivity?.requestNetwork(request, listener) ?: throw IOException("Wi-Fi 연결 서비스를 사용할 수 없습니다.")
// Never persist or log the password. Android owns credentials/approval for this request.
armDeadline(current)
} catch (_: Exception) { fail("AP 연결 요청을 시작할 수 없습니다. 권한/보안 방식과 Wi-Fi 상태를 확인하세요.") }
}
/** Fallback for Settings-connected APs; never use the default cellular/VPN network. */
@SuppressLint("MissingPermission")
@Synchronized fun connectCurrent() {
refresh()
if (closed || !state.value.permissionGranted || !state.value.enabled || state.value.busy) return
val candidates = connectivity?.allNetworks.orEmpty().filter {
connectivity?.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true &&
connectivity?.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != true &&
connectivity?.getLinkProperties(it)?.linkAddresses?.any { address -> DctWifiAp.isUsableWifiAddress(address.address) } == true
}
if (candidates.size != 1) {
state.value = state.value.copy(error = "Wi-Fi에 먼저 연결하세요. 사용할 IPv4 Wi-Fi 경로가 하나여야 합니다.")
return
}
val network = candidates.single()
val name = connectedNetworks[network]?.displayName ?: "현재 Wi-Fi"
if (route?.network == network) { onReady(route!!.ssid); return }
onConnecting()
val replacing = route != null
release()
if (replacing) onLost()
val current = generation
state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = name, localAddress = null, activeNetworkKey = null, error = null)
val listener = listener(current, name, network)
try {
callback = listener
connectivity!!.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(), listener)
availableNetwork = network
updateRoute(current, network, connectivity.getLinkProperties(network), name)
if (route == null) armDeadline(current)
} catch (_: Exception) { fail("현재 Wi-Fi AP 경로를 사용할 수 없습니다.") }
}
private fun listener(current: Long, ssid: String, expected: Network? = null) = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { synchronized(this@WifiApManager) {
if (current != generation || closed || (expected != null && expected != network)) return
availableNetwork = network // Wait for onLinkPropertiesChanged; do not query synchronously here.
} }
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { synchronized(this@WifiApManager) {
if (current != generation || closed || (expected != null && expected != network)) return
availableNetwork = network
updateRoute(current, network, properties, ssid)
} }
override fun onLost(network: Network) { synchronized(this@WifiApManager) {
if (current == generation && network == availableNetwork) fail("장비 AP 연결이 해제되었습니다.")
} }
override fun onUnavailable() { synchronized(this@WifiApManager) {
if (current == generation) fail("AP 연결이 취소되었거나 실패했습니다. 비밀번호와 장비 상태를 확인하세요.")
} }
}
private fun updateRoute(current: Long, network: Network, properties: LinkProperties?, ssid: String) {
if (current != generation || closed) return
val local = properties?.linkAddresses?.firstOrNull { DctWifiAp.isUsableWifiAddress(it.address) }?.address
if (local == null) {
if (route != null) fail("Wi-Fi IPv4 연결 경로가 사라졌습니다.")
return
}
val next = WifiApRoute(network, local, ssid, endpointProvider())
// Saving a destination applies on the next explicit TCP connection, not on DHCP refresh.
if (route?.network == network && route?.localAddress == local) return
if (route != null) onLost()
route = next
deadline?.let(handler::removeCallbacks); deadline = null
state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = ssid, localAddress = local.hostAddress,
activeNetworkKey = network.toString(), error = null)
onReady(ssid)
}
private fun armDeadline(current: Long) {
deadline = Runnable { synchronized(this) {
if (current == generation && route == null) fail("AP 연결 또는 Wi-Fi IPv4 할당 시간이 초과되었습니다.")
} }.also { handler.postDelayed(it, 60_000) }
}
@Synchronized fun currentRoute(): WifiApRoute = route?.copy(endpoint = endpointProvider())
?: throw IOException("Wi-Fi 연결/IP 할당이 준비되지 않았습니다.")
@Synchronized fun disconnect() {
release()
state.value = state.value.copy(phase = WifiApPhase.IDLE, localAddress = null, selectedSsid = null, activeNetworkKey = null, error = null)
}
private fun fail(message: String) {
val wasActive = route != null || state.value.busy
release()
state.value = state.value.copy(phase = WifiApPhase.ERROR, localAddress = null, activeNetworkKey = null, error = message)
if (wasActive) onLost()
}
private fun release() {
generation++
deadline?.let(handler::removeCallbacks); deadline = null
scanDeadline?.let(handler::removeCallbacks); scanDeadline = null
route = null; availableNetwork = null
callback?.let { try { connectivity?.unregisterNetworkCallback(it) } catch (_: IllegalArgumentException) { } }
callback = null
}
@Synchronized override fun close() {
if (closed) return
closed = true
release()
observer?.let { try { connectivity?.unregisterNetworkCallback(it) } catch (_: IllegalArgumentException) { } }
observer = null
connectedNetworks.clear(); publishConnectedNetworks()
if (registered) { context.unregisterReceiver(receiver); registered = false }
}
}

90
Android/app/src/main/java/com/dct/app/data/communication/WifiApState.kt

@ -0,0 +1,90 @@
package com.dct.app.data.communication
import android.Manifest
import android.net.Network
import java.io.IOException
import java.net.Inet4Address
import java.net.InetAddress
/** ESP32 equipment is an access point, not a Wi-Fi Direct peer. */
object DctWifiAp {
const val HOST = "192.168.4.1"
const val PORT = 5000
fun runtimePermissions(sdk: Int): Array<String> = buildList {
// Scanning still needs precise location on Android 13+, independently of Nearby Wi-Fi.
add(Manifest.permission.ACCESS_COARSE_LOCATION)
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (sdk >= 33) add(Manifest.permission.NEARBY_WIFI_DEVICES)
}.toTypedArray()
fun isEquipmentSubnet(address: InetAddress): Boolean = address is Inet4Address &&
address.address.take(3).map { it.toInt() and 255 } == listOf(192, 168, 4) &&
(address.address[3].toInt() and 255) in 2..254
fun isUsableWifiAddress(address: InetAddress): Boolean = address is Inet4Address &&
(address.address[0].toInt() and 255) in 1..223 && !address.isLoopbackAddress
}
/** Configured TCP destination; the equipment profile remains the first-use default. */
data class WifiApEndpoint(val host: String = DctWifiAp.HOST, val port: Int = DctWifiAp.PORT) {
val isValid: Boolean get() = WifiConnectionInput(host, port.toString()).canConnect &&
host.substringBefore('.').toIntOrNull()?.let { it in 1..223 && it != 127 } == true
fun asInput() = WifiConnectionInput(host, port.toString())
companion object {
fun fromInput(input: WifiConnectionInput): WifiApEndpoint? {
if (!input.canConnect) return null
val host = input.ip.trim().split('.').joinToString(".") { it.toInt().toString() }
return WifiApEndpoint(host, input.portNumber!!).takeIf { it.isValid }
}
}
}
enum class WifiApSecurity {
OPEN, WPA2, WPA3, UNSUPPORTED;
companion object {
fun fromCapabilities(capabilities: String): WifiApSecurity = when {
capabilities.contains("EAP") || capabilities.contains("WEP") || capabilities.contains("OWE") -> UNSUPPORTED
capabilities.contains("PSK") -> WPA2 // WPA2/WPA3 transition APs accept WPA2.
capabilities.contains("SAE") -> WPA3
else -> OPEN
}
}
fun acceptsPassword(password: String): Boolean = when (this) {
OPEN -> password.isEmpty()
WPA2 -> password.length in 8..63 && password.all { it.code in 32..126 }
WPA3 -> password.length in 1..63 && password.all { it.code in 32..126 }
UNSUPPORTED -> false
}
}
data class WifiApDevice(val ssid: String, val bssid: String, val level: Int, val security: WifiApSecurity)
/** OS Wi-Fi connectivity is display-only; it does not imply a prepared DCT transport. */
data class ConnectedWifiNetwork(val key: String, val ssid: String? = null, val localAddress: String? = null) {
val displayName: String get() = ssid ?: "연결된 Wi-Fi"
}
enum class WifiApPhase { IDLE, SCANNING, CONNECTING, CONNECTED, ERROR }
data class WifiApUiState(
val phase: WifiApPhase = WifiApPhase.IDLE,
val enabled: Boolean = false,
val permissionGranted: Boolean = false,
val locationEnabled: Boolean = false,
val devices: List<WifiApDevice> = emptyList(),
val connectedNetworks: List<ConnectedWifiNetwork> = emptyList(),
val activeNetworkKey: String? = null,
val selectedSsid: String? = null,
val localAddress: String? = null,
val error: String? = null
) {
val networkReady: Boolean get() = phase == WifiApPhase.CONNECTED && localAddress != null
val busy: Boolean get() = phase == WifiApPhase.CONNECTING
val canScan: Boolean get() = enabled && permissionGranted && locationEnabled && !busy
}
data class WifiApRoute(val network: Network, val localAddress: InetAddress, val ssid: String,
val endpoint: WifiApEndpoint = WifiApEndpoint()) {
fun requireTarget(host: String, port: Int) {
if (!endpoint.isValid || host != endpoint.host || port != endpoint.port || !DctWifiAp.isUsableWifiAddress(localAddress))
throw IOException("설정된 TCP 주소 또는 Wi-Fi 연결 경로가 올바르지 않습니다 (${endpoint.host}:${endpoint.port}).")
}
fun samePath(other: WifiApRoute): Boolean = network == other.network && localAddress == other.localAddress && endpoint == other.endpoint
}

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

@ -19,7 +19,7 @@ import java.net.Socket
import android.util.Log
/**
* Wi-Fi Direct L4 TCP 소켓 관리자 (기본 포트: 6000).
* Wi-Fi L4 TCP 소켓 관리자. AP/LAN은 설정된 IPv4/포트로 접속한다(기본 192.168.4.1:5000).
*
* Wi-Fi Direct 환경에서 태블릿이 Group Owner(서버)가 되든 Client(클라이언트)가 되든
* 상관없이 안정적인 양방향 송수신 세션을 지원합니다.
@ -40,6 +40,7 @@ class WifiSocketManager(
private val connectionGeneration = java.util.concurrent.atomic.AtomicLong()
private val ingressSequenceCounter = java.util.concurrent.atomic.AtomicLong()
@Volatile private var wifiDirectRouteProvider: (() -> WifiDirectRoute)? = null
@Volatile private var wifiApRouteProvider: (() -> WifiApRoute)? = null
private var connectingSocket: Socket? = null
/** Production sockets must use a verified current P2P group interface/address. */
@ -47,6 +48,11 @@ class WifiSocketManager(
wifiDirectRouteProvider = provider
}
/** AP mode must never silently fall back to a default/cellular network. */
fun setWifiApRouteProvider(provider: (() -> WifiApRoute)?) {
wifiApRouteProvider = provider
}
@Synchronized
private fun nextConnectionGeneration(): Long {
connectingSocket?.close()
@ -64,6 +70,8 @@ class WifiSocketManager(
private val _connectedHost = MutableStateFlow<String?>(null)
val connectedHost: StateFlow<String?> = _connectedHost.asStateFlow()
private val _connectedEndpoint = MutableStateFlow<WifiApEndpoint?>(null)
val connectedEndpoint: StateFlow<WifiApEndpoint?> = _connectedEndpoint.asStateFlow()
private val _packetStream = MutableSharedFlow<DctPacket>(
replay = 0,
@ -73,7 +81,7 @@ class WifiSocketManager(
val packetStream: SharedFlow<DctPacket> = _packetStream
companion object {
const val DEFAULT_PORT = 6000
const val DEFAULT_PORT = 6000 // Retained P2P/test API; AP production calls use DctWifiAp.PORT.
const val CONNECT_TIMEOUT_MS = 5000
const val CONNECTION_DEADLINE_MS = 15000L
}
@ -94,7 +102,7 @@ class WifiSocketManager(
val startedAt = System.nanoTime()
fun remainingMs(): Int = (CONNECTION_DEADLINE_MS -
(System.nanoTime() - startedAt) / 1_000_000).coerceAtLeast(0).toInt()
var lastError = "Wi-Fi Direct 연결 준비 시간이 초과되었습니다."
var lastError = "Wi-Fi TCP 연결 준비 시간이 초과되었습니다."
var previousStatus: String? = null
fun status(message: String) {
if (message != previousStatus) {
@ -130,10 +138,10 @@ class WifiSocketManager(
is WifiDirectRouteNotReady -> e.message ?: "Wi-Fi Direct IP 준비 중입니다."
is java.net.ConnectException -> "장비의 TCP 연결이 거부되었습니다 ($hostIp:$port)."
is java.net.SocketTimeoutException -> "장비의 TCP 응답 시간이 초과되었습니다 ($hostIp:$port)."
else -> e.message ?: "Wi-Fi Direct TCP 연결 실패"
else -> e.message ?: "Wi-Fi TCP 연결 실패"
}
if (e is WifiDirectRouteNotReady) status(lastError)
Log.w("WifiSocketManager", "P2P connect: generation=$generation, elapsedMs=${(System.nanoTime() - startedAt) / 1_000_000}, reason=$lastError", e)
Log.w("WifiSocketManager", "Wi-Fi connect: generation=$generation, elapsedMs=${(System.nanoTime() - startedAt) / 1_000_000}, reason=$lastError", e)
delay(minOf(500, remainingMs()).toLong())
}
}
@ -247,9 +255,12 @@ class WifiSocketManager(
if (generation != connectionGeneration.get()) throw CancellationException("Superseded connection")
disconnectActiveSocketOnly()
}
val route = wifiDirectRouteProvider?.invoke()
val apRoute = wifiApRouteProvider?.invoke()
apRoute?.requireTarget(host, port)
val route = if (apRoute == null) wifiDirectRouteProvider?.invoke() else null
route?.requireClientTarget(host)
val socket = route?.network?.socketFactory?.createSocket() ?: Socket()
val socket = apRoute?.network?.socketFactory?.createSocket()
?: route?.network?.socketFactory?.createSocket() ?: Socket()
try {
synchronized(this) {
if (generation != connectionGeneration.get()) throw CancellationException("Superseded connection")
@ -259,8 +270,14 @@ class WifiSocketManager(
socket.soTimeout = 0
// Bind the source to the actual P2P interface even when no Android Network is exposed.
if (route != null) socket.bind(InetSocketAddress(route.localAddress, 0))
Log.i("WifiSocketManager", "P2P TCP start: interface=${route?.interfaceName}, local=${route?.localAddress?.hostAddress}, remote=$host:$port, networkBound=${route?.network != null}")
if (apRoute != null) socket.bind(InetSocketAddress(apRoute.localAddress, 0))
Log.i("WifiSocketManager", "Wi-Fi TCP start: mode=${if (apRoute != null) "AP" else "P2P/legacy"}, local=${apRoute?.localAddress?.hostAddress ?: route?.localAddress?.hostAddress}, remote=$host:$port, networkBound=${apRoute != null || route?.network != null}")
socket.connect(InetSocketAddress(host, port), timeoutMs)
if (apRoute != null) {
val current = wifiApRouteProvider?.invoke()
if (current == null || !apRoute.samePath(current) || socket.localAddress != apRoute.localAddress)
throw IOException("DCT AP 연결 경로가 변경되었습니다.")
}
if (route != null) {
val current = wifiDirectRouteProvider?.invoke()
if (current == null || !route.samePath(current) || socket.localAddress != route.localAddress) {
@ -269,8 +286,8 @@ class WifiSocketManager(
}
Log.i(
"WifiSocketManager",
"Wi-Fi Direct TCP 연결 완료: local=${socket.localAddress.hostAddress}:${socket.localPort}, " +
"remote=$host:$port, interface=${route?.interfaceName}, p2pAddressVerified=${route != null}, networkBound=${route?.network != null}"
"Wi-Fi TCP 연결 완료: local=${socket.localAddress.hostAddress}:${socket.localPort}, " +
"remote=$host:$port, interface=${route?.interfaceName}, p2pAddressVerified=${route != null}, networkBound=${apRoute != null || route?.network != null}"
)
if (!attachSocket(socket, host, generation)) throw CancellationException("Superseded connection")
} catch (e: Exception) {
@ -312,6 +329,8 @@ class WifiSocketManager(
if (activeSocket === socket) disconnectActiveSocketOnly()
}
})
// Report the attached peer, not the editable destination for the next connection.
_connectedEndpoint.value = socket.inetAddress?.hostAddress?.let { WifiApEndpoint(it, socket.port) }
_isConnected.value = true
_connectedHost.value = hostName
parser.reset()
@ -453,6 +472,7 @@ class WifiSocketManager(
}
_isConnected.value = false
_connectedHost.value = null
_connectedEndpoint.value = null
}
@Synchronized

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

@ -0,0 +1,400 @@
package com.dct.app.presentation.dialog
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.dct.app.data.communication.*
import com.dct.app.presentation.main.MainViewModel
@Composable
fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) {
val context = LocalContext.current
val state by viewModel.wifiApState.collectAsStateWithLifecycle()
val ui by viewModel.uiState.collectAsStateWithLifecycle()
val socket by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle()
val endpoint by viewModel.wifiApEndpoint.collectAsStateWithLifecycle()
val connectedEndpoint by viewModel.wifiSocketManager.connectedEndpoint.collectAsStateWithLifecycle()
var requested by remember { mutableStateOf(false) }
var searchOnReady by remember { mutableStateOf(true) }
var settingsError by remember { mutableStateOf<String?>(null) }
val permissions = remember { DctWifiAp.runtimePermissions(Build.VERSION.SDK_INT) }
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) viewModel.refreshWifiAp()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
viewModel.refreshWifiAp(); searchOnReady = it.values.all { granted -> granted }
}
fun settings(action: String) {
try { context.startActivity(Intent(action).apply {
if (action == Settings.ACTION_APPLICATION_DETAILS_SETTINGS) data = Uri.parse("package:${context.packageName}")
}) } catch (_: android.content.ActivityNotFoundException) { settingsError = "설정 화면을 열 수 없습니다." }
}
LaunchedEffect(Unit) { viewModel.refreshWifiAp() }
LaunchedEffect(state.permissionGranted) {
if (!state.permissionGranted && !requested) { requested = true; launcher.launch(permissions) }
}
LaunchedEffect(searchOnReady, state.canScan) {
if (searchOnReady && state.canScan) { searchOnReady = false; viewModel.scanWifiAp() }
}
// No onDispose disconnect: the application, not this sheet, owns the AP lease.
Dialog(onDismissRequest = onDismiss) {
WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket,
onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp,
onCancel = viewModel::cancelWifiApConnection, onDisconnect = { viewModel.disconnectCommunication() },
onRetry = viewModel::retryWifiApTcp, onCurrent = viewModel::connectCurrentWifiAp,
onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss,
endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint,
canEditEndpoint = !ui.isMeasuring && ui.recordingState !in setOf(
com.dct.app.presentation.main.MeasurementRecordingState.STARTING,
com.dct.app.presentation.main.MeasurementRecordingState.RECORDING,
com.dct.app.presentation.main.MeasurementRecordingState.STOPPING) && !state.busy)
}
}
@Composable
internal fun WifiApConnectionContent(
state: WifiApUiState, communicationReady: Boolean, socketConnected: Boolean,
onScan: () -> Unit, onConnect: (WifiApDevice, String) -> Unit, onCancel: () -> Unit,
onDisconnect: () -> Unit, onRetry: () -> Unit, onCurrent: () -> Unit,
onPermissions: () -> Unit, onSettings: (String) -> Unit, onDismiss: () -> Unit,
endpoint: WifiApEndpoint = WifiApEndpoint(), canEditEndpoint: Boolean = true,
onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null
) {
var selected by remember { mutableStateOf<WifiApDevice?>(null) }
var addingNetwork by remember { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
var showDetails by remember { mutableStateOf(false) }
var connectedDetailsKey by remember { mutableStateOf<String?>(null) }
var editEndpoint by remember { mutableStateOf(false) }
val endpointEditable = canEditEndpoint && !state.busy
// This shortcut uses the existing single-IPv4 Wi-Fi path, never an arbitrary second network.
val canConnectCurrent = endpointEditable && endpoint.isValid && state.enabled && state.permissionGranted &&
!communicationReady && !socketConnected && state.connectedNetworks.count { it.localAddress != null } == 1
val connectedStatus = when {
communicationReady -> "연결됨"
socketConnected -> "장비 초기화 중…"
else -> "통신 연결 대기"
}
val configuredAddress = "접속 설정: ${endpoint.host}:${endpoint.port}"
val currentAddress = if (!state.busy && (socketConnected || communicationReady)) connectedEndpoint?.let {
"통신 주소: ${it.host}:${it.port}"
} ?: "통신 주소 확인 중…" else configuredAddress
Surface(shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth()) {
Column(Modifier.heightIn(max = 520.dp)) {
Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 8.dp, top = 12.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text("Wi-Fi", Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall)
IconButton(onClick = onScan, enabled = state.canScan) {
Icon(Icons.Default.Refresh, contentDescription = "새로고침")
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "더보기")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(text = { Text("네트워크 추가") }, enabled = state.canScan,
onClick = { menuOpen = false; addingNetwork = true; selected = null })
DropdownMenuItem(text = { Text("현재 Wi-Fi로 연결") },
enabled = state.enabled && state.permissionGranted && !state.busy,
onClick = { menuOpen = false; onCurrent() })
DropdownMenuItem(text = { Text("IP / 포트 설정") }, enabled = endpointEditable,
onClick = { menuOpen = false; editEndpoint = true })
DropdownMenuItem(text = { Text("Wi-Fi 설정") },
onClick = { menuOpen = false; onSettings(Settings.ACTION_WIFI_SETTINGS) })
if (!state.permissionGranted) DropdownMenuItem(text = { Text("앱 설정") },
onClick = { menuOpen = false; onSettings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) })
DropdownMenuItem(text = { Text("연결 정보") },
onClick = { menuOpen = false; showDetails = true })
}
}
}
if (state.phase == WifiApPhase.SCANNING || state.busy)
LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp))
val blocked = when {
!state.enabled -> "Wi-Fi를 켜 주세요."
!state.permissionGranted -> "주변 Wi-Fi 검색 권한이 필요합니다."
!state.locationEnabled -> "Wi-Fi 검색을 위해 위치 서비스를 켜 주세요."
else -> null
}
if (blocked != null) {
Text(blocked, Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = {
when {
!state.enabled -> onSettings(Settings.ACTION_WIFI_SETTINGS)
!state.permissionGranted -> onPermissions()
else -> onSettings(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
}
}, modifier = Modifier.padding(horizontal = 16.dp)) {
Text(when {
!state.enabled -> "Wi-Fi 설정"
!state.permissionGranted -> "권한 허용"
else -> "위치 설정"
})
}
}
state.error?.let {
Text(it, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
LazyColumn(Modifier.fillMaxWidth().weight(1f, fill = false).heightIn(max = 320.dp)
.testTag("wifi:ap-list")) {
if (state.networkReady || state.busy) item(key = "current") {
WifiApNetworkRow(state.selectedSsid ?: "DCT Wi-Fi",
status = if (state.busy) "연결 중…" else connectedStatus,
address = currentAddress,
secured = false, enabled = state.networkReady,
tag = "wifi:ap-current", onClick = { showDetails = true })
}
items(state.connectedNetworks.filterNot { it.key == state.activeNetworkKey },
key = { "connected:${it.key}" }) { network ->
WifiApNetworkRow(network.displayName, status = "Wi-Fi 연결됨",
address = configuredAddress,
secured = false, enabled = true, tag = "wifi:connected:${network.key}",
onClick = { connectedDetailsKey = network.key })
}
item(key = "available-title") {
Text("사용 가능한 네트워크",
Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val connectedSsids = state.connectedNetworks.mapNotNull { it.ssid }.toSet()
val available = state.devices.filterNot {
it.ssid in connectedSsids || (state.networkReady && it.ssid == state.selectedSsid)
}
if (available.isEmpty()) item(key = "empty") {
Text(if (state.phase == WifiApPhase.SCANNING) "검색 중…" else "네트워크를 찾지 못했습니다.",
Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
items(available, key = { "${it.ssid}:${it.security.name}" }) { device ->
WifiApNetworkRow(device.ssid, secured = device.security != WifiApSecurity.OPEN,
enabled = state.canScan, tag = "wifi:ap:${device.ssid}",
onClick = { selected = device; addingNetwork = false })
}
}
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)) {
if (state.busy) TextButton(onClick = onCancel) { Text("연결 취소") }
Button(enabled = canConnectCurrent, onClick = onCurrent,
modifier = Modifier.testTag("wifi:connected-connect")) { Text("통신 연결") }
TextButton(onClick = onDismiss) { Text("닫기") }
}
}
}
// Credentials exist only while the selected network's prompt is open.
if (selected != null || addingNetwork) {
val device = selected
var ssid by remember(device, addingNetwork) { mutableStateOf(device?.ssid.orEmpty()) }
var password by remember(device, addingNetwork) { mutableStateOf("") }
var passwordVisible by remember(device, addingNetwork) { mutableStateOf(false) }
val security = device?.security ?: WifiApSecurity.WPA2
val supported = security != WifiApSecurity.UNSUPPORTED
fun closePrompt() { password = ""; selected = null; addingNetwork = false }
AlertDialog(
onDismissRequest = { closePrompt() },
title = { Text(device?.ssid ?: "네트워크 추가") },
text = {
Column(Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (addingNetwork) OutlinedTextField(ssid, onValueChange = { ssid = it },
label = { Text("네트워크 이름") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("wifi:ap-ssid"))
when {
!supported -> Text("이 네트워크는 Wi-Fi 설정에서 연결해 주세요.")
security == WifiApSecurity.OPEN -> Text("이 네트워크에 연결하시겠습니까?")
else -> {
OutlinedTextField(password, onValueChange = { password = it },
label = { Text("비밀번호") }, singleLine = true,
visualTransformation = if (passwordVisible)
androidx.compose.ui.text.input.VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "비밀번호 숨기기" else "비밀번호 보기")
}
}, modifier = Modifier.fillMaxWidth().testTag("wifi:ap-password"))
if (addingNetwork) Text("보안: WPA2",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
},
dismissButton = { TextButton(onClick = { closePrompt() }) { Text("취소") } },
confirmButton = {
if (supported) TextButton(
enabled = state.canScan && ssid.isNotBlank() && security.acceptsPassword(password),
onClick = {
onConnect(device ?: WifiApDevice(ssid, "", 0, WifiApSecurity.WPA2), password)
closePrompt()
}, modifier = Modifier.testTag("wifi:ap-connect")) { Text("연결") }
else TextButton(onClick = {
closePrompt(); onSettings(Settings.ACTION_WIFI_SETTINGS)
}) { Text("Wi-Fi 설정") }
})
}
state.connectedNetworks.firstOrNull { it.key == connectedDetailsKey }?.let { network ->
AlertDialog(onDismissRequest = { connectedDetailsKey = null },
modifier = Modifier.testTag("wifi:connected-details"),
title = { Text(network.displayName) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Wi-Fi 연결됨")
network.localAddress?.let { Text("IP 주소: $it") }
Text("접속 설정: ${endpoint.host}:${endpoint.port}")
}
},
confirmButton = {
Row {
TextButton(enabled = endpointEditable, onClick = {
connectedDetailsKey = null; editEndpoint = true
}) { Text("IP / 포트 설정") }
TextButton(onClick = { connectedDetailsKey = null }) { Text("확인") }
}
})
}
if (showDetails) AlertDialog(
onDismissRequest = { showDetails = false },
title = { Text(state.selectedSsid ?: "연결 정보") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.networkReady) {
Text(connectedStatus)
Text("IP 주소: ${state.localAddress}")
}
Text("접속 설정: ${endpoint.host}:${endpoint.port}",
Modifier.testTag("wifi:ap-endpoint"))
}
},
dismissButton = {
if (state.networkReady) TextButton(onClick = { showDetails = false; onDisconnect() }) {
Text("연결 해제")
}
},
confirmButton = {
Row {
TextButton(enabled = endpointEditable, onClick = {
showDetails = false; editEndpoint = true
}) { Text("IP / 포트 설정") }
if (state.networkReady && !communicationReady) TextButton(onClick = {
showDetails = false; onRetry()
}) { Text("통신 재연결") }
TextButton(onClick = { showDetails = false }) { Text("확인") }
}
})
if (editEndpoint) {
var ip by remember { mutableStateOf(endpoint.host) }
var port by remember { mutableStateOf(endpoint.port.toString()) }
var saveError by remember { mutableStateOf(false) }
val target = WifiApEndpoint.fromInput(WifiConnectionInput(ip, port))
fun applyTarget(connectNow: Boolean) {
if (target == null || !endpointEditable || !onEndpointChange(target)) {
saveError = true
return
}
editEndpoint = false
if (connectNow) {
if (state.networkReady) onRetry() else onCurrent()
}
}
AlertDialog(onDismissRequest = { editEndpoint = false },
title = { Text("IP / 포트 설정") },
text = {
Column(Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("PC와 같은 Wi-Fi에서 PC의 IPv4 주소를 입력하세요. 테스트 서버 기본 포트는 6000입니다.",
style = MaterialTheme.typography.bodySmall)
OutlinedTextField(ip, onValueChange = { ip = it; saveError = false },
label = { Text("IP 주소") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth().testTag("wifi:tcp-ip"))
OutlinedTextField(port, onValueChange = { port = it; saveError = false },
label = { Text("포트") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth().testTag("wifi:tcp-port"))
if (target == null) Text("올바른 IPv4 주소와 1~65535 포트를 입력하세요.",
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
if (saveError) Text("연결 요청·계측·기록을 정지한 후 변경하세요.",
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Text("저장은 다음 연결부터 적용됩니다.", style = MaterialTheme.typography.bodySmall)
TextButton(onClick = { ip = DctWifiAp.HOST; port = DctWifiAp.PORT.toString(); saveError = false }) {
Text("장비 기본값")
}
}
},
dismissButton = { TextButton(onClick = { editEndpoint = false }) { Text("취소") } },
confirmButton = {
Row {
TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget(false) },
modifier = Modifier.testTag("wifi:tcp-save")) { Text("저장") }
TextButton(enabled = target != null && endpointEditable && state.enabled &&
state.permissionGranted && !state.busy, onClick = { applyTarget(true) },
modifier = Modifier.testTag("wifi:tcp-connect")) { Text("저장 후 연결") }
}
})
}
}
@Composable
private fun WifiApNetworkRow(
ssid: String, status: String? = null, secured: Boolean, enabled: Boolean,
tag: String, onClick: () -> Unit, address: String? = null
) {
Row(Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 24.dp, vertical = 14.dp).testTag(tag),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val tint = if (enabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant
Icon(Icons.Default.Wifi, contentDescription = null, tint = tint)
Column(Modifier.weight(1f)) {
Text(ssid, style = MaterialTheme.typography.bodyLarge, color = tint,
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
status?.let { Text(it, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary) }
address?.let { Text(it, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("$tag:endpoint")) }
}
if (secured) Icon(Icons.Default.Lock, contentDescription = "보안 네트워크",
modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

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

@ -1573,6 +1573,7 @@ class MainViewModel(
}
private val pendingWifiTarget = java.util.concurrent.atomic.AtomicReference<String?>(null)
private val connectedWifiTarget = java.util.concurrent.atomic.AtomicReference<String?>(null)
@Synchronized
fun connectWifi(
@ -1581,15 +1582,16 @@ class MainViewModel(
port: Int = com.dct.app.data.communication.WifiSocketManager.DEFAULT_PORT,
isGroupOwner: Boolean = false
) {
require(hostIp.isNotBlank()) { "Wi-Fi Direct 그룹 주소가 없습니다." }
require(hostIp.isNotBlank()) { "Wi-Fi 장비 주소가 없습니다." }
val target = "$hostIp:$port:$isGroupOwner"
if (pendingWifiTarget.get() == target) return
if (_uiState.value.isConnected && wifiSocketManager.connectedHost.value == hostIp) return
if (_uiState.value.isConnected && connectedWifiTarget.get() == target &&
wifiSocketManager.connectedHost.value == hostIp) return
if (!ensureBackgroundExecution()) return
pendingWifiTarget.set(target)
val generation = connectionGeneration.incrementAndGet()
configurationRequired = true
val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트(GC)"
val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트"
scope.launch(dispatchers.io) {
sessionMutex.withLock {
if (generation != connectionGeneration.get()) return@withLock
@ -1600,7 +1602,7 @@ class MainViewModel(
_uiState.update {
it.copy(isConnected = false, communicationType = CommunicationType.WIFI,
connectedDeviceName = deviceName,
// Connection progress is rendered by the Wi-Fi Direct dialog. Do not
// Connection progress is rendered by the Wi-Fi dialog. Do not
// enqueue a Toast for every intermediate handshake phase.
statusMessage = null)
}
@ -1610,7 +1612,8 @@ class MainViewModel(
scope.launch(dispatchers.io) {
sessionMutex.withLock {
if (generation != connectionGeneration.get()) return@withLock
Log.i(LOG_TAG, "Wi-Fi Direct TCP 연결 완료: $remoteAddress ($roleLabel)")
connectedWifiTarget.set(target)
Log.i(LOG_TAG, "Wi-Fi TCP 연결 완료: $remoteAddress ($roleLabel)")
try {
configurationRequired = true
// The PC handshake exchanges CAN/SM settings
@ -1641,7 +1644,7 @@ class MainViewModel(
if (generation == connectionGeneration.get()) {
// Waiting/retry text is transient transport progress. Keep it in
// Logcat; only the final success or failure becomes a Toast.
Log.d(LOG_TAG, "Wi-Fi Direct TCP 연결 대기: $message")
Log.d(LOG_TAG, "Wi-Fi TCP 연결 대기: $message")
}
},
onFailure = { error ->
@ -1657,6 +1660,68 @@ class MainViewModel(
}
private var registeredWifiDirectManager: com.dct.app.data.communication.WifiDirectManager? = null
private var registeredWifiApManager: com.dct.app.data.communication.WifiApManager? = null
private var apObserver: Job? = null
private val _wifiApState = MutableStateFlow(com.dct.app.data.communication.WifiApUiState())
val wifiApState = _wifiApState.asStateFlow()
private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint())
val wifiApEndpoint = _wifiApEndpoint.asStateFlow()
private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null
fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean {
if (!endpoint.isValid || _uiState.value.isMeasuring ||
_uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING,
MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) ||
registeredWifiApManager?.uiState?.value?.busy == true) return false
wifiEndpointPreferences?.save(endpoint.asInput())
_wifiApEndpoint.value = endpoint
return true
}
/** ESP32 AP is the production entry. P2P implementation below is retained, not initialized. */
fun initializeWifiAp(context: Context) {
storageContext = context.applicationContext
if (registeredWifiApManager != null) return
wifiEndpointPreferences = com.dct.app.data.communication.WifiConnectionPreferences(
context.applicationContext.getSharedPreferences(
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()
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 = { name -> val endpoint = _wifiApEndpoint.value; connectWifi(name, endpoint.host, endpoint.port) },
onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") },
onConnecting = { closeWifiTransportAfterGroupLoss(null) },
endpointProvider = { _wifiApEndpoint.value })
registeredWifiApManager = manager
wifiSocketManager.setWifiDirectRouteProvider(null)
wifiSocketManager.setWifiApRouteProvider(manager::currentRoute)
apObserver = scope.launch { manager.uiState.collect { _wifiApState.value = it } }
manager.start()
}
fun refreshWifiAp() { registeredWifiApManager?.refresh() }
fun scanWifiAp() { registeredWifiApManager?.scan() }
fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) {
if (registeredWifiApManager?.uiState?.value?.canScan == true && ensureBackgroundExecution())
registeredWifiApManager?.connect(device, password)
}
fun connectCurrentWifiAp() {
if (ensureBackgroundExecution()) registeredWifiApManager?.connectCurrent()
}
fun cancelWifiApConnection() {
registeredWifiApManager?.disconnect()
closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.")
}
fun retryWifiApTcp() {
val endpoint = _wifiApEndpoint.value
if (registeredWifiApManager?.uiState?.value?.networkReady == true)
connectWifi(wifiApState.value.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port)
}
private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState())
val wifiDirectState: StateFlow<com.dct.app.data.communication.WifiDirectUiState> = _wifiDirectState.asStateFlow()
private var p2pObserver: Job? = null
@ -1694,6 +1759,8 @@ class MainViewModel(
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() }
p2pObserver = scope.launch {
manager.uiState.collect { state ->
@ -1712,9 +1779,10 @@ class MainViewModel(
}
}
private fun closeWifiTransportAfterGroupLoss() {
private fun closeWifiTransportAfterGroupLoss(message: String? = "Wi-Fi Direct 연결이 해제되었습니다.") {
val generation = connectionGeneration.incrementAndGet()
pendingWifiTarget.set(null)
connectedWifiTarget.set(null)
configurationRequired = true
// Invalidate immediately: queued cleanup must never close a newly formed group's socket.
wifiSocketManager.invalidateConnection()
@ -1725,17 +1793,18 @@ class MainViewModel(
finishMeasurementSession()
_uiState.update { it.copy(isConnected = false, isMeasuring = false,
communicationType = CommunicationType.NONE, connectedDeviceName = null,
statusMessage = "Wi-Fi Direct 연결이 해제되었습니다.") }
statusMessage = message) }
}
}
}
/**
* Wi-Fi Direct / TCP 통신 연결 해제.
* Wi-Fi AP (or retained P2P) / TCP 통신 연결 해제.
*/
@Synchronized
fun disconnectCommunication(context: Context? = null) {
registeredWifiDirectManager?.let { manager ->
if (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()
@ -1743,18 +1812,23 @@ class MainViewModel(
}
return
}
connectionGeneration.incrementAndGet() // Invalidate pending START/handshake callbacks immediately.
val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately.
pendingWifiTarget.set(null)
connectedWifiTarget.set(null)
configurationRequired = true
_uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE,
connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") }
scope.launch(dispatchers.io) {
sessionMutex.withLock {
if (disconnectGeneration != connectionGeneration.get()) return@withLock
try {
packetRepository.disconnect() // Includes STOP when the repository is streaming.
} finally {
if (disconnectGeneration == connectionGeneration.get()) {
finishMeasurementSession()
wifiSocketManager.disconnectSuspend()
// Keep the AP lease until STOP and log finalization have completed.
registeredWifiApManager?.disconnect()
_uiState.update {
it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE,
connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.")
@ -1763,6 +1837,7 @@ class MainViewModel(
}
}
}
}
/**
* 외부 SAF Uri로부터 .dpc 프로젝트 파일 열기 및 로드
@ -4097,6 +4172,8 @@ class MainViewModel(
super.onCleared()
p2pObserver?.cancel()
registeredWifiDirectManager?.close()
apObserver?.cancel()
registeredWifiApManager?.close()
wifiSocketManager.close()
maintenanceService.stopSeedListener()
if (_uiState.value.isMeasuring) finishMeasurementSession()

11
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.WifiDirectDeviceListDialog
import com.dct.app.presentation.dialog.WifiApConnectionDialog
import com.dct.app.presentation.dialog.OpenProjectDialog
import com.dct.app.presentation.dialog.SelectSpecificFileDialog
import com.dct.app.presentation.monitor.components.WorkspaceRibbonBar
@ -429,9 +429,10 @@ fun DiagnosticDataMonitorScreen(
}
}
// 앱 실행 시 Wi-Fi Direct 라이프사이클 초기화
// ESP32 AP mode. Keep P2P code for a future chip, but do not initialize its receiver/route.
LaunchedEffect(Unit) {
viewModel.initializeWifiDirect(context)
// viewModel.initializeWifiDirect(context)
viewModel.initializeWifiAp(context)
}
@ -1346,9 +1347,9 @@ fun DiagnosticDataMonitorScreen(
)
}
// Wi-Fi Direct 및 TCP 소켓 연결 다이얼로그
// Wi-Fi Direct UI retained but inactive: WifiDirectDeviceListDialog(viewModel, onDismiss).
if (showWifiDialog) {
WifiDirectDeviceListDialog(
WifiApConnectionDialog(
viewModel = viewModel,
onDismiss = { showWifiDialog = false }
)

4
Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt

@ -185,7 +185,7 @@ fun EmptyCanvasGuideView(
GuideStepCard(
stepNumber = "3",
title = "통신 연결 및 계측 시작/정지",
description = "Wi-Fi Direct로 차량/ECU와 연결한 후 신호 재생(계측)을 시작합니다. 신호 기록은 계측 중 별도로 시작합니다.",
description = "장비 Wi-Fi AP에 비밀번호로 연결한 뒤 192.168.4.1:5000으로 통신합니다. 신호 재생(계측)과 신호 기록은 별도로 시작합니다.",
chips = {
Row(
modifier = Modifier.padding(top = 6.dp),
@ -194,7 +194,7 @@ fun EmptyCanvasGuideView(
) {
RibbonIconChip(
icon = Icons.Default.Wifi,
label = "Wi-Fi Direct",
label = "Wi-Fi AP",
tint = Color(0xFF2563EB),
bgColor = Color(0xFFEFF6FF),
borderColor = Color(0xFFBFDBFE)

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

@ -298,21 +298,20 @@ fun WorkspaceRibbonBar(
// Group 2: 통신 연결
val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE
val p2pState by viewModel.wifiDirectState.collectAsState()
val apState by viewModel.wifiApState.collectAsState()
val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI
RibbonButton(
icon = Icons.Default.LinkOff,
tooltip = "통신 끊기 (Disconnect)",
enabled = isCommConnected || p2pState.link != null,
enabled = isCommConnected || apState.networkReady || apState.busy,
tint = if (isCommConnected) Color(0xFFEF4444) else ColorSecondary,
onClick = onDisconnectClick
)
RibbonButton(
icon = Icons.Default.Wifi,
tooltip = if (!p2pState.supported) "이 기기는 Wi-Fi Direct를 지원하지 않습니다."
else if (p2pState.link != null) "Wi-Fi Direct 연결됨 (${p2pState.link?.displayName})" else "Wi-Fi Direct",
enabled = p2pState.supported && (!isCommConnected || isWifiConnected),
tooltip = if (apState.networkReady) "Wi-Fi AP 연결됨 (${apState.selectedSsid})" else "Wi-Fi AP 연결",
enabled = !isCommConnected || isWifiConnected,
tint = if (isWifiConnected) Color(0xFF2563EB) else ColorSecondary,
onClick = onWifiClick
)

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

@ -360,6 +360,45 @@ class MainViewModelTest {
private lateinit var sampleDb: MdbDatabase
private var backgroundExecutionAllowed = true
@Test fun editableWifiTargetRejectsInvalidInputAndChangesDuringMeasurement() {
val original = viewModel.wifiApEndpoint.value
assertFalse(viewModel.setWifiApEndpoint(com.dct.app.data.communication.WifiApEndpoint("0.0.0.0", 6000)))
assertEquals(original, viewModel.wifiApEndpoint.value)
val pc = com.dct.app.data.communication.WifiApEndpoint("192.168.1.100", 6000)
assertTrue(viewModel.setWifiApEndpoint(pc))
assertEquals(pc, viewModel.wifiApEndpoint.value)
connectionState.value = DeviceConnectionState.MEASURING
assertFalse(viewModel.setWifiApEndpoint(pc.copy(port = 7000)))
assertEquals(pc, viewModel.wifiApEndpoint.value)
}
@Test fun changingOnlyTheWifiPortStartsNewTransportButExactTargetIsIdempotent() = runTest {
val host = "192.168.1.100"
every { viewModel.wifiSocketManager.connectedHost } returns MutableStateFlow(host)
val ports = mutableListOf<Int>()
every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers {
ports += arg<Int>(2)
arg<(Boolean, String) -> Unit>(3).invoke(false, host)
}
coEvery { packetRepository.connect(host) } answers {
repositoryConnected.value = true
connectionState.value = DeviceConnectionState.READY
DctResult.Success(Unit)
}
coEvery { packetRepository.disconnect() } answers {
repositoryConnected.value = false
connectionState.value = DeviceConnectionState.DISCONNECTED
DctResult.Success(Unit)
}
viewModel.connectWifi("PC", host, 6000)
assertTrue(viewModel.uiState.value.isConnected)
viewModel.connectWifi("PC", host, 6000)
assertEquals(listOf(6000), ports)
viewModel.connectWifi("PC", host, 7000)
assertEquals(listOf(6000, 7000), ports)
coVerify(exactly = 2) { packetRepository.connect(host) }
}
@Test fun rejectedForegroundStartDoesNotSendMeasurementStart() = runTest {
connectionState.value = DeviceConnectionState.READY
backgroundExecutionAllowed = false
@ -515,15 +554,16 @@ class MainViewModelTest {
))))
every { mdbRepository.getCurrentDatabase() } returns sampleDb
viewModel.openMdbFromFile(File("Sample.mdb"))
val method = MainViewModel::class.java.getDeclaredMethod("decodeMdbRecord", Int::class.javaPrimitiveType, ByteArray::class.java, MutableMap::class.java)
val method = MainViewModel::class.java.getDeclaredMethod("decodeMdbRecord", Int::class.javaPrimitiveType,
ByteArray::class.java, MutableMap::class.java, MutableMap::class.java)
method.isAccessible = true
val values = mutableMapOf<String, Double>()
assertNull(method.invoke(viewModel, 1, byteArrayOf(0x12, 0x34), values))
assertNull(method.invoke(viewModel, 1, byteArrayOf(0x12, 0x34), values, null))
assertEquals(4660.0, values["raw_sig_1_1"])
assertEquals(7.0, values["sig_1_1"])
// A truncated record is diagnostic-only. The record is not returned as a
// fatal decode error, and values already decoded from earlier packets stay intact.
assertNull(method.invoke(viewModel, 1, byteArrayOf(0x12), values))
assertNull(method.invoke(viewModel, 1, byteArrayOf(0x12), values, null))
assertEquals(4660.0, values["raw_sig_1_1"])
}

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

@ -9,6 +9,16 @@ import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class MeasurementExecutionStateTest {
@Test fun apNegotiationAndTcpWaitKeepServiceButScanningDoesNot() {
fun ap(phase: com.dct.app.data.communication.WifiApPhase, present: Boolean = false) =
MeasurementExecutionState.from(MainUiState(), DeviceConnectionState.DISCONNECTED, false,
WifiDirectPhase.IDLE, false, phase, present)
assertFalse(ap(com.dct.app.data.communication.WifiApPhase.SCANNING).active)
assertTrue(ap(com.dct.app.data.communication.WifiApPhase.CONNECTING).active)
val waiting = ap(com.dct.app.data.communication.WifiApPhase.CONNECTED, true)
assertTrue(waiting.active); assertTrue(waiting.title.contains("Wi-Fi AP"))
assertFalse(ap(com.dct.app.data.communication.WifiApPhase.ERROR).active)
}
@Test fun notificationOmitsFrequencyWithoutChangingMeasurementSettingsOrProgress() {
for (frequency in listOf(30, 60, 120)) {
val ui = MainUiState(isMeasuring = true, measurementFrequencyHz = frequency,

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

@ -154,6 +154,8 @@ class PcOperationalLoggerTest {
withContext(Dispatchers.IO) { server.accept() }.use { peer ->
peer.soTimeout = 3000
withTimeout(5000) { connected.await() }
assertEquals(com.dct.app.data.communication.WifiApEndpoint("127.0.0.1", server.localPort),
socketManager.connectedEndpoint.value)
val received = async(start = CoroutineStart.UNDISPATCHED) { socketManager.packetStream.first() }
val frame = codec.encode(packet)
val corrupt = frame.copyOf().apply { this[lastIndex - 1] = 0 }
@ -163,6 +165,7 @@ class PcOperationalLoggerTest {
assertTrue(socketManager.sendCommand(packet))
assertArrayEquals(frame, withContext(Dispatchers.IO) { peer.getInputStream().readNBytes(frame.size) })
socketManager.invalidateConnection()
assertNull(socketManager.connectedEndpoint.value)
}
} finally {
socketManager.close(); logger.close(); withTimeout(5000) { logger.awaitClosed() }

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

@ -0,0 +1,287 @@
package com.dct.app.tests
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.*
import android.net.wifi.*
import android.os.Handler
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat
import com.dct.app.data.communication.*
import io.mockk.*
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
import java.net.InetAddress
class WifiApManagerTest {
private val context = mockk<Context>(relaxed = true)
private val wifi = mockk<WifiManager>(relaxed = true)
private val connectivity = mockk<ConnectivityManager>(relaxed = true)
private val location = mockk<LocationManager>()
private val callbacks = mutableListOf<ConnectivityManager.NetworkCallback>()
private val timeouts = mutableListOf<Runnable>()
private var ready = 0; private var lost = 0
private lateinit var manager: WifiApManager
private val device = WifiApDevice("DCT", "", -40, WifiApSecurity.WPA2)
@BeforeEach fun setup() {
mockkStatic(ContextCompat::class, LocationManagerCompat::class)
every { context.applicationContext } returns context
every { context.getSystemService(WifiManager::class.java) } returns wifi
every { context.getSystemService(ConnectivityManager::class.java) } returns connectivity
every { context.getSystemService(LocationManager::class.java) } returns location
every { wifi.isWifiEnabled } returns true
every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_GRANTED
every { ContextCompat.registerReceiver(context, any(), any(), ContextCompat.RECEIVER_EXPORTED) } returns null
every { LocationManagerCompat.isLocationEnabled(location) } returns true
mockkConstructor(Handler::class, WifiNetworkSpecifier.Builder::class, NetworkRequest.Builder::class)
every { anyConstructed<Handler>().postDelayed(any(), any()) } answers { timeouts += firstArg<Runnable>(); true }
every { anyConstructed<Handler>().removeCallbacks(any()) } just Runs
every { anyConstructed<WifiNetworkSpecifier.Builder>().setSsid(any()) } answers { self as WifiNetworkSpecifier.Builder }
every { anyConstructed<WifiNetworkSpecifier.Builder>().setWpa2Passphrase(any()) } answers { self as WifiNetworkSpecifier.Builder }
every { anyConstructed<WifiNetworkSpecifier.Builder>().build() } returns mockk()
every { anyConstructed<NetworkRequest.Builder>().addTransportType(any()) } answers { self as NetworkRequest.Builder }
every { anyConstructed<NetworkRequest.Builder>().clearCapabilities() } answers { self as NetworkRequest.Builder }
every { anyConstructed<NetworkRequest.Builder>().removeCapability(any()) } answers { self as NetworkRequest.Builder }
every { anyConstructed<NetworkRequest.Builder>().setNetworkSpecifier(any<NetworkSpecifier>()) } answers { self as NetworkRequest.Builder }
every { anyConstructed<NetworkRequest.Builder>().build() } returns mockk()
every { connectivity.requestNetwork(any(), any<ConnectivityManager.NetworkCallback>()) } answers { callbacks += secondArg<ConnectivityManager.NetworkCallback>() }
every { connectivity.registerNetworkCallback(any<NetworkRequest>(), any<ConnectivityManager.NetworkCallback>()) } answers { callbacks += secondArg<ConnectivityManager.NetworkCallback>() }
manager = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34)
}
@AfterEach fun teardown() { manager.close(); unmockkAll() }
private fun properties(address: String): LinkProperties {
val link = mockk<LinkAddress>(); every { link.address } returns InetAddress.getByName(address)
val properties = mockk<LinkProperties>(); every { properties.linkAddresses } returns listOf(link)
return properties
}
@Test fun tcpStartsOnlyAfterEquipmentIpAndCancelIgnoresLateCallbacks() {
manager.connect(device, "12345678")
val callback = callbacks.single(); val network = mockk<Network>()
callback.onAvailable(network)
assertEquals(0, ready)
callback.onLinkPropertiesChanged(network, properties("::1"))
assertEquals(0, ready)
callback.onLinkPropertiesChanged(network, properties("192.168.4.20"))
assertEquals(1, ready); assertTrue(manager.uiState.value.networkReady)
manager.disconnect()
callback.onLinkPropertiesChanged(network, properties("192.168.4.20"))
callback.onUnavailable(); callback.onLost(network)
assertEquals(1, ready); assertEquals(0, lost)
assertEquals(WifiApPhase.IDLE, manager.uiState.value.phase)
verify(exactly = 1) { connectivity.unregisterNetworkCallback(callback) }
}
@Test fun wrongSubnetTimeoutAndDeniedPasswordCannotStartTcp() {
manager.connect(device, "bad")
assertTrue(callbacks.isEmpty())
manager.connect(device, "12345678")
val callback = callbacks.single(); val network = mockk<Network>()
callback.onLinkPropertiesChanged(network, properties("127.0.0.1"))
timeouts.last().run()
assertEquals(WifiApPhase.ERROR, manager.uiState.value.phase)
assertEquals(0, ready); assertEquals(1, lost)
callback.onLinkPropertiesChanged(network, properties("192.168.4.20"))
assertEquals(0, ready)
}
@Test fun unavailableAndLostReleaseOnlyTheCurrentNetwork() {
manager.connect(device, "12345678")
val stale = callbacks.single()
manager.disconnect(); manager.connect(device, "12345678")
val current = callbacks.last(); val network = mockk<Network>()
stale.onUnavailable(); stale.onLinkPropertiesChanged(network, properties("192.168.4.30"))
assertEquals(0, ready); assertEquals(WifiApPhase.CONNECTING, manager.uiState.value.phase)
current.onAvailable(network); current.onLinkPropertiesChanged(network, properties("192.168.4.20"))
current.onLost(mockk()) // An unrelated Network must not kill this session.
assertTrue(manager.uiState.value.networkReady)
current.onLost(network)
assertEquals(1, lost); assertFalse(manager.uiState.value.networkReady)
current.onUnavailable(); assertEquals(1, lost)
}
@Test fun losingTheEquipmentSubnetEndsTheLease() {
manager.connect(device, "12345678")
val current = callbacks.single(); val network = mockk<Network>()
current.onLinkPropertiesChanged(network, properties("192.168.4.20"))
current.onLinkPropertiesChanged(network, properties("::1"))
assertEquals(1, ready); assertEquals(1, lost)
assertFalse(manager.uiState.value.networkReady)
}
@Test fun permissionDenialNeverRequestsAnAp() {
every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_DENIED
manager.connect(device, "12345678")
assertFalse(manager.uiState.value.permissionGranted)
assertTrue(callbacks.isEmpty()); assertEquals(0, ready)
}
@Test fun newLeaseInvalidatesOldTransportBeforeDhcpAndRepeatedPropertiesDoNotReconnect() {
val events = mutableListOf<String>()
val owner = WifiApManager(context, { events += "ready" }, { events += "lost" }, sdk = 34,
onConnecting = { events += "prepare" })
try {
owner.connect(device, "12345678")
assertEquals(listOf("prepare"), events)
val first = callbacks.last(); val network = mockk<Network>()
first.onLinkPropertiesChanged(network, properties("192.168.4.20"))
first.onLinkPropertiesChanged(network, properties("192.168.4.20"))
assertEquals(listOf("prepare", "ready"), events)
owner.connect(device.copy(ssid = "DCT_2"), "12345678")
assertEquals(listOf("prepare", "ready", "prepare", "lost"), events)
first.onLost(network)
assertEquals(WifiApPhase.CONNECTING, owner.uiState.value.phase)
} finally { owner.close() }
}
@Test fun oldAndroidUsesSettingsFallbackWithoutRequestingUnsupportedApi() {
val old = WifiApManager(context, { ready++ }, { lost++ }, sdk = 28)
try {
old.connect(device, "12345678")
assertTrue(callbacks.isEmpty())
assertTrue(old.uiState.value.error!!.contains("Android 10 미만"))
} finally { old.close() }
}
@Test fun currentNetworkFallbackRejectsCellularAndAmbiguousWifiPaths() {
val cellular = mockk<Network>(); val wifiNetwork = mockk<Network>()
val cellularCaps = mockk<NetworkCapabilities>(); val wifiCaps = mockk<NetworkCapabilities>()
every { cellularCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false
every { wifiCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { wifiCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false
every { wifiCaps.transportInfo } returns null
every { connectivity.getNetworkCapabilities(cellular) } returns cellularCaps
every { connectivity.getNetworkCapabilities(wifiNetwork) } returns wifiCaps
every { connectivity.allNetworks } returns arrayOf(cellular)
manager.connectCurrent()
assertEquals(0, ready)
every { connectivity.getLinkProperties(wifiNetwork) } returns properties("192.168.1.20")
every { connectivity.allNetworks } returns arrayOf(cellular, wifiNetwork)
manager.connectCurrent()
assertEquals(1, ready)
assertTrue(manager.uiState.value.networkReady)
assertEquals("192.168.1.20", manager.currentRoute().localAddress.hostAddress)
manager.disconnect()
every { connectivity.getLinkProperties(wifiNetwork) } returns properties("192.168.4.20")
every { connectivity.allNetworks } returns arrayOf(wifiNetwork, wifiNetwork)
manager.connectCurrent()
assertEquals(1, ready)
assertFalse(manager.uiState.value.networkReady)
}
@Test fun configuredPcTargetUsesLanAndSavingDoesNotReconnectOnDhcpRefresh() {
var endpoint = WifiApEndpoint("192.168.1.100", 6000)
val owner = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34,
endpointProvider = { endpoint })
try {
owner.connect(device, "12345678")
val callback = callbacks.last(); val network = mockk<Network>()
callback.onLinkPropertiesChanged(network, properties("192.168.1.20"))
val original = owner.currentRoute()
original.requireTarget("192.168.1.100", 6000)
endpoint = endpoint.copy(port = 7000)
callback.onLinkPropertiesChanged(network, properties("192.168.1.20"))
assertEquals(1, ready); assertEquals(0, lost)
assertFalse(original.samePath(owner.currentRoute()))
owner.currentRoute().requireTarget("192.168.1.100", 7000)
} finally { owner.close() }
}
@Test fun currentNetworkCannotAdoptWifiBackedVpnOrIpv6OnlyWifi() {
val network = mockk<Network>(); val capabilities = mockk<NetworkCapabilities>()
every { connectivity.allNetworks } returns arrayOf(network)
every { connectivity.getNetworkCapabilities(network) } returns capabilities
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true
every { capabilities.transportInfo } returns null
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20")
manager.connectCurrent()
assertEquals(0, ready)
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false
every { connectivity.getLinkProperties(network) } returns properties("::1")
manager.connectCurrent()
assertEquals(0, ready)
assertFalse(manager.uiState.value.networkReady)
}
private fun wifiCapabilities(name: String?): NetworkCapabilities {
val info = mockk<WifiInfo>(); every { info.ssid } returns name
val capabilities = mockk<NetworkCapabilities>()
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false
every { capabilities.transportInfo } returns info
return capabilities
}
@Test fun existingSystemWifiIsShownWithoutRequestingAnApOrStartingTcp() {
val network = mockk<Network>()
every { connectivity.allNetworks } returns arrayOf(network)
every { connectivity.getNetworkCapabilities(network) } returns wifiCapabilities("\"Office Wi-Fi\"")
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20")
manager.start(); manager.start()
assertEquals(listOf(ConnectedWifiNetwork(network.toString(), "Office Wi-Fi", "192.168.1.20")),
manager.uiState.value.connectedNetworks)
assertEquals(0, ready); assertEquals(0, lost)
assertFalse(manager.uiState.value.networkReady)
assertEquals(WifiApPhase.IDLE, manager.uiState.value.phase)
verify(exactly = 0) { connectivity.requestNetwork(any(), any<ConnectivityManager.NetworkCallback>()) }
verify(exactly = 1) { connectivity.registerNetworkCallback(any<NetworkRequest>(), any<ConnectivityManager.NetworkCallback>()) }
}
@Test fun systemWifiObserverUpdatesDhcpAndLossWithoutOwningTheTransportLease() {
manager.start()
val observer = callbacks.single(); val network = mockk<Network>()
observer.onCapabilitiesChanged(network, wifiCapabilities("\"DCT\""))
observer.onLinkPropertiesChanged(network, properties("192.168.4.30"))
assertEquals("192.168.4.30", manager.uiState.value.connectedNetworks.single().localAddress)
verify(exactly = 0) { connectivity.getNetworkCapabilities(network) }
verify(exactly = 0) { connectivity.getLinkProperties(network) }
manager.disconnect()
assertEquals("DCT", manager.uiState.value.connectedNetworks.single().ssid)
verify(exactly = 0) { connectivity.unregisterNetworkCallback(observer) }
observer.onLost(network)
assertTrue(manager.uiState.value.connectedNetworks.isEmpty())
assertEquals(0, ready); assertEquals(0, lost)
manager.close(); manager.close()
observer.onCapabilitiesChanged(network, wifiCapabilities("DCT"))
assertTrue(manager.uiState.value.connectedNetworks.isEmpty())
verify(exactly = 1) { connectivity.unregisterNetworkCallback(observer) }
}
@Test fun redactedWifiNamesAreNotGuessedAndPermissionRevocationClearsCachedNames() {
val network = mockk<Network>()
every { connectivity.allNetworks } returns arrayOf(network)
every { connectivity.getNetworkCapabilities(network) } returns wifiCapabilities(WifiManager.UNKNOWN_SSID)
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20")
every { wifi.connectionInfo } returns null
manager.start()
assertNull(manager.uiState.value.connectedNetworks.single().ssid)
assertEquals("연결된 Wi-Fi", manager.uiState.value.connectedNetworks.single().displayName)
callbacks.single().onCapabilitiesChanged(network, wifiCapabilities("Office"))
manager.refresh()
assertEquals("Office", manager.uiState.value.connectedNetworks.single().ssid)
every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_DENIED
manager.refresh()
assertNull(manager.uiState.value.connectedNetworks.single().ssid)
callbacks.single().onCapabilitiesChanged(network, wifiCapabilities("Office"))
assertNull(manager.uiState.value.connectedNetworks.single().ssid)
assertEquals(0, ready)
}
@Test fun passiveSnapshotExcludesCellularAndWifiBackedVpnAndSupportsOldAndroid() {
val network = mockk<Network>(); val vpn = mockk<Network>(); val cellular = mockk<Network>()
val caps = wifiCapabilities(null)
val vpnCaps = wifiCapabilities("VPN")
every { vpnCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true
val cellularCaps = mockk<NetworkCapabilities>()
every { cellularCaps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false
every { connectivity.allNetworks } returns arrayOf(vpn, cellular, network)
every { connectivity.getNetworkCapabilities(network) } returns caps
every { connectivity.getNetworkCapabilities(vpn) } returns vpnCaps
every { connectivity.getNetworkCapabilities(cellular) } returns cellularCaps
every { connectivity.getLinkProperties(network) } returns properties("192.168.1.20")
val info = mockk<WifiInfo>(); every { info.ssid } returns "\"Legacy AP\""
every { wifi.connectionInfo } returns info
val old = WifiApManager(context, { ready++ }, { lost++ }, sdk = 28)
try {
old.start()
assertEquals("Legacy AP", old.uiState.value.connectedNetworks.single().ssid)
verify(exactly = 0) { caps.transportInfo }
assertEquals(0, ready); assertFalse(old.uiState.value.networkReady)
} finally { old.close() }
}
}

152
Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt

@ -0,0 +1,152 @@
package com.dct.app.tests
import android.Manifest
import android.net.Network
import com.dct.app.data.communication.*
import io.mockk.*
import kotlinx.coroutines.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketAddress
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
class WifiApTest {
private fun ip(value: String) = InetAddress.getByName(value)
@Test fun fixedEndpointAndScanPermissionsMatchTheEquipmentContract() {
assertEquals("192.168.4.1", DctWifiAp.HOST); assertEquals(5000, DctWifiAp.PORT)
assertTrue(DctWifiAp.runtimePermissions(34).contains(Manifest.permission.ACCESS_FINE_LOCATION))
assertTrue(DctWifiAp.runtimePermissions(34).contains(Manifest.permission.NEARBY_WIFI_DEVICES))
assertFalse(DctWifiAp.runtimePermissions(32).contains(Manifest.permission.NEARBY_WIFI_DEVICES))
listOf("192.168.4.2", "192.168.4.254").forEach { assertTrue(DctWifiAp.isEquipmentSubnet(ip(it))) }
listOf("192.168.4.0", "192.168.4.1", "192.168.4.255", "192.168.49.2", "10.0.0.2", "::1")
.forEach { assertFalse(DctWifiAp.isEquipmentSubnet(ip(it))) }
}
@Test fun securityAndPasswordsDoNotSilentlyDowngradeUnsupportedAps() {
assertEquals(WifiApSecurity.WPA2, WifiApSecurity.fromCapabilities("[WPA2-PSK-CCMP][SAE][ESS]"))
assertEquals(WifiApSecurity.WPA3, WifiApSecurity.fromCapabilities("[SAE][ESS]"))
assertEquals(WifiApSecurity.OPEN, WifiApSecurity.fromCapabilities("[ESS]"))
for (capabilities in listOf("[WEP]", "[EAP]", "[OWE]"))
assertEquals(WifiApSecurity.UNSUPPORTED, WifiApSecurity.fromCapabilities(capabilities))
assertFalse(WifiApSecurity.WPA2.acceptsPassword("1234567"))
assertTrue(WifiApSecurity.WPA2.acceptsPassword("12345678"))
assertFalse(WifiApSecurity.WPA2.acceptsPassword("a".repeat(64)))
assertFalse(WifiApSecurity.WPA2.acceptsPassword("1234567\n"))
assertFalse(WifiApSecurity.UNSUPPORTED.acceptsPassword("password"))
}
@Test fun apRouteRejectsWrongPortsHostsAndSubnets() {
val route = WifiApRoute(mockk(), ip("192.168.4.20"), "DCT")
route.requireTarget(DctWifiAp.HOST, DctWifiAp.PORT)
assertThrows(IOException::class.java) { route.requireTarget(DctWifiAp.HOST, 6000) }
assertThrows(IOException::class.java) { route.requireTarget("192.168.49.1", 5000) }
assertThrows(IOException::class.java) { route.copy(localAddress = ip("0.0.0.0")).requireTarget(DctWifiAp.HOST, 5000) }
val pc = route.copy(localAddress = ip("192.168.1.20"), endpoint = WifiApEndpoint("192.168.1.100", 6000))
pc.requireTarget("192.168.1.100", 6000)
assertThrows(IOException::class.java) { pc.requireTarget("192.168.1.100", 7000) }
}
@Test fun editableEndpointValidatesNormalizesAndRejectsInvalidTargets() {
assertEquals(WifiApEndpoint("192.168.1.100", 6000),
WifiApEndpoint.fromInput(WifiConnectionInput(" 192.168.001.100 ", " 6000 ")))
for (host in listOf("", "pc.local", "256.1.2.3", "0.0.0.0", "127.0.0.1", "224.0.0.1", "255.255.255.255", "::1"))
assertNull(WifiApEndpoint.fromInput(WifiConnectionInput(host, "6000")), host)
for (port in listOf("", "0", "65536", "-1", "abc"))
assertNull(WifiApEndpoint.fromInput(WifiConnectionInput("192.168.1.100", port)), port)
}
private class TestSocket(private val address: InetAddress) : Socket() {
val reading = CountDownLatch(1); val closed = CountDownLatch(1)
var target: InetSocketAddress? = null
var source: InetSocketAddress? = null
override fun bind(bindpoint: SocketAddress?) { source = bindpoint as InetSocketAddress }
override fun connect(endpoint: SocketAddress?, timeout: Int) { target = endpoint as InetSocketAddress }
override fun setTcpNoDelay(on: Boolean) {}
override fun setSoTimeout(timeout: Int) {}
override fun getLocalAddress(): InetAddress = address
override fun getInetAddress(): InetAddress? = target?.address
override fun getPort(): Int = target?.port ?: 0
override fun getOutputStream() = ByteArrayOutputStream()
override fun getInputStream() = object : java.io.InputStream() {
override fun read(): Int { reading.countDown(); closed.await(5, TimeUnit.SECONDS); return -1 }
override fun read(bytes: ByteArray): Int = read()
}
override fun close() { super.close(); closed.countDown() }
}
@Test fun socketUsesOnlyTheSelectedApNetworkAndFixedEndpoint() = runBlocking {
val local = ip("192.168.4.20")
val socket = TestSocket(local)
val factory = mockk<SocketFactory>(); val network = mockk<Network>()
every { network.socketFactory } returns factory
every { factory.createSocket() } returns socket
val manager = WifiSocketManager()
manager.setWifiDirectRouteProvider { throw AssertionError("P2P must not be queried in AP mode") }
manager.setWifiApRouteProvider { WifiApRoute(network, local, "DCT") }
try {
val result = CompletableDeferred<String?>()
manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { result.complete(null) }, { result.complete(it) })
assertNull(withTimeout(3000) { result.await() })
assertTrue(manager.isConnected.value)
assertEquals(WifiApEndpoint(DctWifiAp.HOST, DctWifiAp.PORT), manager.connectedEndpoint.value)
assertEquals(InetSocketAddress(DctWifiAp.HOST, DctWifiAp.PORT), socket.target)
assertEquals(InetSocketAddress(local, 0), socket.source)
verify(exactly = 1) { factory.createSocket() }
} finally { manager.close() }
}
@Test fun missingOrChangedApRouteCannotFallBackToAnUnboundSocket() = runBlocking {
val manager = WifiSocketManager()
manager.setWifiApRouteProvider { throw IOException("no AP") }
try {
val failure = CompletableDeferred<String>()
manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { fail("must not connect") }, { failure.complete(it) })
assertEquals("no AP", withTimeout(3000) { failure.await() })
assertFalse(manager.isConnected.value)
assertNull(manager.connectedEndpoint.value)
val local = ip("192.168.4.20"); val network = mockk<Network>(); val replacement = mockk<Network>()
val factory = mockk<SocketFactory>(); val socket = TestSocket(local)
every { network.socketFactory } returns factory; every { factory.createSocket() } returns socket
var queries = 0
manager.setWifiApRouteProvider { WifiApRoute(if (++queries == 1) network else replacement, local, "DCT") }
val changed = CompletableDeferred<String>()
manager.connect(DctWifiAp.HOST, DctWifiAp.PORT, { fail("stale AP") }, { changed.complete(it) })
assertTrue(withTimeout(3000) { changed.await() }.contains("경로가 변경"))
assertTrue(socket.isClosed)
assertFalse(manager.isConnected.value)
} finally { manager.close() }
}
@Test fun pcLanSocketUsesEditedPortAndRejectsPortChangeDuringConnection() = runBlocking {
val local = ip("192.168.1.20"); val network = mockk<Network>()
val factory = mockk<SocketFactory>(); every { network.socketFactory } returns factory
var socket = TestSocket(local); every { factory.createSocket() } answers { socket }
val manager = WifiSocketManager()
var endpoint = WifiApEndpoint("192.168.1.100", 6000)
manager.setWifiApRouteProvider { WifiApRoute(network, local, "PC LAN", endpoint) }
try {
val first = CompletableDeferred<String?>()
manager.connect(endpoint.host, endpoint.port, { first.complete(null) }, { first.complete(it) })
assertNull(withTimeout(3000) { first.await() })
assertEquals(InetSocketAddress("192.168.1.100", 6000), socket.target)
assertEquals(endpoint, manager.connectedEndpoint.value)
assertEquals(InetSocketAddress(local, 0), socket.source)
manager.disconnectSuspend()
assertNull(manager.connectedEndpoint.value)
socket = TestSocket(local)
var queries = 0
manager.setWifiApRouteProvider {
WifiApRoute(network, local, "PC LAN", if (++queries == 1) endpoint else endpoint.copy(port = 7000))
}
val changed = CompletableDeferred<String>()
manager.connect(endpoint.host, endpoint.port, { fail("stale port") }, { changed.complete(it) })
assertTrue(withTimeout(3000) { changed.await() }.contains("경로가 변경"))
assertTrue(socket.isClosed)
assertFalse(manager.isConnected.value)
assertNull(manager.connectedEndpoint.value)
} finally { manager.close() }
}
}

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

@ -22,6 +22,7 @@ class WifiConnectionGenerationTest {
assertTrue(stale.isClosed)
assertFalse(manager.isConnected.value)
assertNull(manager.connectedHost.value)
assertNull(manager.connectedEndpoint.value)
}
@Test fun disconnectInvalidatesInFlightConnectionAttempt() = runBlocking {
@ -32,5 +33,6 @@ class WifiConnectionGenerationTest {
assertFalse(attach(manager, stale, old))
assertTrue(stale.isClosed)
assertFalse(manager.isConnected.value)
assertNull(manager.connectedEndpoint.value)
}
}

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

@ -8,6 +8,17 @@ import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class WifiConnectionPreferencesTest {
@Test fun savedPcEndpointRestoresAndBlankOrInvalidDraftUsesEquipmentDefault() {
val store = WifiConnectionPreferences(preferences())
val pc = com.dct.app.data.communication.WifiApEndpoint("192.168.1.100", 6000)
store.save(pc.asInput())
assertEquals(pc, com.dct.app.data.communication.WifiApEndpoint.fromInput(store.load()))
store.save(WifiConnectionInput("not-an-ip", "bad-port"))
assertNull(com.dct.app.data.communication.WifiApEndpoint.fromInput(store.load()))
assertEquals(com.dct.app.data.communication.WifiApEndpoint(),
com.dct.app.data.communication.WifiApEndpoint.fromInput(store.load())
?: com.dct.app.data.communication.WifiApEndpoint())
}
private fun preferences(): SharedPreferences {
val values = mutableMapOf<String, String?>()
val pending = mutableMapOf<String, String?>()

66
DCT_test/DCT_test/Configure-HotspotAp.ps1

@ -0,0 +1,66 @@
param([switch]$ValidateOnly)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName PresentationFramework
try {
# Only Windows' active mobile-hotspot virtual NIC is eligible. A physical
# Wi-Fi/Ethernet NIC or an ambiguous pair must never be reconfigured.
$hotspotAdapters = @(Get-NetAdapter -IncludeHidden | Where-Object {
$_.Status -eq 'Up' -and $_.InterfaceDescription -like '*Wi-Fi Direct Virtual Adapter*'
} | Where-Object {
@(Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -in @('192.168.137.1', '192.168.4.1') }).Count -gt 0
})
if ($hotspotAdapters.Count -ne 1) {
throw 'Windows 모바일 핫스팟을 먼저 켜 주세요. 192.168.137.1 또는 192.168.4.1을 가진 활성 핫스팟 어댑터가 정확히 하나여야 합니다.'
}
$hotspotAdapter = $hotspotAdapters[0]
$existingAddresses = @(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -eq '192.168.4.1' })
if (@($existingAddresses | Where-Object { $_.InterfaceIndex -ne $hotspotAdapter.ifIndex }).Count -gt 0) {
throw '192.168.4.1이 다른 어댑터에서 사용 중입니다. 기존 네트워크 설정을 변경하지 않았습니다.'
}
if (@($existingAddresses | Where-Object { $_.PrefixLength -ne 24 }).Count -gt 0) {
throw '기존 192.168.4.1의 접두사가 24가 아닙니다. 기존 설정을 변경하지 않았습니다.'
}
$hotspotIpInterface = Get-NetIPInterface -InterfaceIndex $hotspotAdapter.ifIndex -AddressFamily IPv4
if ($hotspotIpInterface.Dhcp -ne 'Disabled') {
throw '핫스팟 어댑터가 DHCP 클라이언트로 설정되어 있습니다. 자동 변경하지 않았습니다.'
}
if ($ValidateOnly) { return }
$addedAddress = $false
try {
if ($existingAddresses.Count -eq 0) {
New-NetIPAddress -InterfaceIndex $hotspotAdapter.ifIndex -IPAddress '192.168.4.1' -PrefixLength 24 -SkipAsSource $true | Out-Null
$addedAddress = $true
}
$testExecutable = Join-Path $PSScriptRoot 'DCT_test.exe'
if (-not (Test-Path -LiteralPath $testExecutable)) { throw 'DCT_test.exe 옆의 스크립트로 실행하세요.' }
$ruleName = 'DCT-Test-Hotspot-AP-5000'
$existingRule = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
if ($existingRule) {
$portFilter = $existingRule | Get-NetFirewallPortFilter
$addressFilter = $existingRule | Get-NetFirewallAddressFilter
$appFilter = $existingRule | Get-NetFirewallApplicationFilter
if ($existingRule.Direction -ne 'Inbound' -or $existingRule.Action -ne 'Allow' -or $existingRule.Enabled -ne 'True' -or
$portFilter.Protocol -ne 'TCP' -or $portFilter.LocalPort -ne '5000' -or
$addressFilter.LocalAddress -ne '192.168.4.1' -or $addressFilter.RemoteAddress -notin @('192.168.4.0/255.255.255.0', '192.168.4.0/24') -or
$appFilter.Program -ne $testExecutable) {
throw '기존 DCT AP 방화벽 규칙이 현재 실행 파일/주소와 다릅니다. 기존 규칙을 변경하지 않았습니다.'
}
} else {
New-NetFirewallRule -Name $ruleName -DisplayName 'DCT test hotspot AP TCP 5000' -Direction Inbound -Action Allow `
-Protocol TCP -LocalAddress '192.168.4.1' -LocalPort 5000 -RemoteAddress '192.168.4.0/24' `
-Program $testExecutable -Profile Any | Out-Null
}
} catch {
if ($addedAddress) {
Remove-NetIPAddress -InterfaceIndex $hotspotAdapter.ifIndex -IPAddress '192.168.4.1' -Confirm:$false
}
throw
}
[void][System.Windows.MessageBox]::Show("AP IP 설정 완료: 192.168.4.1/24`n태블릿 Wi-Fi를 수동 IP 192.168.4.2 / 접두사 24 / 게이트웨이·DNS 192.168.4.1로 설정하세요.`n현재 앱에서 '현재 AP로 통신 연결'을 누르세요.`n핫스팟을 다시 켠 뒤 주소가 사라지면 이 도구를 다시 실행하세요.", 'DCT AP 설정')
} catch {
if ($ValidateOnly) { throw }
[void][System.Windows.MessageBox]::Show($_.Exception.Message, 'DCT AP 설정 실패', 'OK', 'Error')
exit 1
}

3
DCT_test/DCT_test/DCT_test.csproj

@ -103,6 +103,9 @@
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<Content Include="Configure-HotspotAp.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

40
DCT_test/DCT_test/DctTcpServer.cs

@ -25,6 +25,27 @@ namespace DCT_test
public bool IsRunning { get; private set; }
public int Port { get; private set; } = 6000;
public const string ApAddress = "192.168.4.1";
public const int ApPort = 5000;
public bool StartAp()
{
// Bind only the equipment address; never report AP readiness on an
// unrelated LAN address when hotspot configuration is missing.
return Start(ApPort, IPAddress.Parse(ApAddress), false);
}
public bool StartAp(string host, int port)
{
IPAddress address;
if (!IPAddress.TryParse(host, out address) || address.AddressFamily != AddressFamily.InterNetwork ||
address.Equals(IPAddress.Any) || port < 1 || port > 65535)
{
Log("[ERROR] PC의 IPv4 주소와 1~65535 포트를 선택하세요.");
return false;
}
return Start(port, address, false);
}
public ulong TotalCanAcks { get; private set; }
public ulong TotalSmAcks { get; private set; }
@ -67,6 +88,11 @@ namespace DCT_test
}
public bool Start(int port)
{
return Start(port, IPAddress.Any, true);
}
private bool Start(int port, IPAddress address, bool broadcastBeacon)
{
if (IsRunning) return true;
@ -74,15 +100,16 @@ namespace DCT_test
{
Port = port;
_cts = new CancellationTokenSource();
_listener = new TcpListener(IPAddress.Any, port);
_listener = new TcpListener(address, port);
_listener.Start(10);
IsRunning = true;
var ips = GetActiveIpAddresses();
string primaryIp = ips.FirstOrDefault() ?? "127.0.0.1";
string primaryIp = address.Equals(IPAddress.Any) ? ips.FirstOrDefault() ?? "127.0.0.1" : address.ToString();
Log($"[OK] C# DCT TCP 서버가 포트 {port}에서 대기 중입니다. (태블릿 접속용 IP: {primaryIp}:{port})");
Task.Run(() => AcceptClientsAsync(_cts.Token));
Task.Run(() => BroadcastBeaconAsync(port, _cts.Token));
var token = _cts.Token;
Task.Run(() => AcceptClientsAsync(token));
if (broadcastBeacon) Task.Run(() => BroadcastBeaconAsync(port, token));
return true;
}
catch (Exception ex)
@ -95,8 +122,7 @@ namespace DCT_test
public void Stop()
{
if (!IsRunning) return;
bool wasRunning = IsRunning;
IsRunning = false;
_cts?.Cancel();
@ -113,7 +139,7 @@ namespace DCT_test
_clients.Clear();
OnClientListChanged?.Invoke();
Log("[INFO] TCP 서버가 중지되었습니다.");
if (wasRunning) Log("[INFO] TCP 서버가 중지되었습니다.");
}
/// <summary>

22
DCT_test/DCT_test/MainWindow.xaml

@ -22,11 +22,16 @@
<GroupBox Grid.Row="0" Header=" 1. 네트워크 및 서버 제어 (Network &amp; Server Control) "
FontWeight="Bold" Foreground="#1E293B" Padding="8" Margin="0,0,0,8"
Background="White" BorderBrush="#CBD5E1">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Wi-Fi Direct:" FontWeight="SemiBold" VerticalAlignment="Center" Margin="4,0,8,0"/>
<StackPanel>
<WrapPanel VerticalAlignment="Center">
<ComboBox x:Name="ComboNetworkMode" Width="135" Height="26" Margin="0,0,8,0" SelectionChanged="ComboNetworkMode_SelectionChanged">
<ComboBoxItem Content="Wi-Fi Direct"/>
<ComboBoxItem Content="외부 공유기/AP"/>
<ComboBoxItem Content="PC 핫스팟 AP"/>
</ComboBox>
<ComboBox x:Name="ComboIp" Width="170" Height="26" IsEnabled="False" VerticalContentAlignment="Center"
FontFamily="Consolas" FontSize="12" Margin="0,0,8,0"/>
<Button x:Name="BtnCopyIp" Content="P2P 자동" Width="70" Height="26" IsEnabled="False" Margin="0,0,16,0"/>
<Button x:Name="BtnCopyIp" Content="P2P 자동" Width="70" Height="26" IsEnabled="False" Click="BtnCopyIp_Click" Margin="0,0,16,0"/>
<TextBlock Text="포트:" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBox x:Name="TextPort" Text="6000" Width="60" Height="26" VerticalContentAlignment="Center"
@ -37,6 +42,17 @@
<TextBlock x:Name="TextServerStatus" Text="[중지] 서버 중지됨 (Stopped)" FontWeight="Bold"
Foreground="#DC2626" VerticalAlignment="Center" FontSize="13"/>
</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">
<WrapPanel>
<Button Content="① Windows 핫스팟 설정" Click="BtnHotspotSettings_Click" Padding="8,4" Margin="0,0,8,0"/>
<Button x:Name="BtnConfigureAp" Content="② AP IP 설정 (관리자)" Click="BtnConfigureAp_Click" Padding="8,4"/>
</WrapPanel>
<TextBlock Text="핫스팟을 켠 뒤 AP IP 설정 → 태블릿 Wi-Fi 수동 IP 192.168.4.2 / 접두사 24 / 게이트웨이 192.168.4.1 → 서버 시작. Windows DHCP 주소로는 현재 앱에 연결되지 않습니다."
TextWrapping="Wrap" FontWeight="Normal" Margin="0,6,0,0"/>
</StackPanel>
</StackPanel>
</GroupBox>

79
DCT_test/DCT_test/MainWindow.xaml.cs

@ -48,6 +48,7 @@ namespace DCT_test
};
_statusTimer.Start();
RefreshIpAddresses();
ComboNetworkMode.SelectedIndex = 0;
// 기본 로그 파일 탐색/로드
InitializeDefaultLogFile();
@ -180,9 +181,24 @@ namespace DCT_test
{
if (!_server.IsRunning)
{
if (!int.TryParse(TextPort.Text.Trim(), out int port)) port = 6000;
bool apMode = ComboNetworkMode.SelectedIndex > 0;
if (!int.TryParse(TextPort.Text.Trim(), out int port) || port < 1 || port > 65535)
{
AppendLog("[ERROR] 포트는 1~65535 범위의 숫자로 입력하세요.");
return;
}
if (_server.Start(port))
string host = ComboIp.SelectedItem?.ToString();
if (ComboNetworkMode.SelectedIndex == 1 && string.IsNullOrEmpty(host))
{
AppendLog("[ERROR] 공유기에 연결된 PC IPv4 주소를 선택하세요.");
return;
}
bool started = ComboNetworkMode.SelectedIndex == 1 ? _server.StartAp(host, port)
: apMode ? _server.StartAp() : _server.Start(port);
if (started)
{
if (!apMode)
{
_wifiDirectHost = new WifiDirectHost(port);
_wifiDirectHost.Status += msg => _pendingLogs.Enqueue("[Wi-Fi Direct] " + msg);
@ -194,20 +210,28 @@ namespace DCT_test
AppendLog("[ERROR] Wi-Fi Direct를 시작할 수 없어 서버를 중지했습니다.");
return;
}
}
BtnToggleServer.Content = "서버 중지 (Stop Server)";
BtnToggleServer.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(220, 38, 38));
TextServerStatus.Text = $"서버 동작 중 (Port {port})";
TextServerStatus.Text = ComboNetworkMode.SelectedIndex == 1 ? $"AP 대기 ({host}:{port})"
: apMode ? "AP 대기 (192.168.4.1:5000)" : $"Direct 대기 (Port {port})";
TextServerStatus.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(22, 163, 74));
// 서버 가동 시 IP/포트 변경 비활성화
ComboIp.IsEnabled = false;
TextPort.IsEnabled = false;
BtnCopyIp.IsEnabled = false;
ComboNetworkMode.IsEnabled = false;
BtnConfigureAp.IsEnabled = false;
// 서버 시작은 TCP 수신 대기만 수행한다. 계측은 장비 프로토콜의
// CMD_START_TRANSMIT_DATA(0x20)를 정상 수신한 뒤 시작한다.
AppendLog("[READY] 서버가 초기 연결/설정 명령을 기다립니다. 계측은 0x20 수신 후 시작됩니다.");
}
else if (apMode)
AppendLog(ComboNetworkMode.SelectedIndex == 1
? "[AP] 선택한 PC IP가 현재 어댑터에 있는지, 입력 포트를 다른 프로그램이 사용 중인지 확인하세요."
: "[AP] Windows 핫스팟을 켜고 AP IP 설정을 완료하세요. 192.168.4.1 주소 또는 TCP 5000 포트를 확인하세요.");
}
else
{
@ -222,9 +246,54 @@ namespace DCT_test
TextServerStatus.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(220, 38, 38));
// 서버 중지 시 IP/포트 변경 다시 활성화
ComboIp.IsEnabled = false;
TextPort.IsEnabled = true;
ComboIp.IsEnabled = ComboNetworkMode.SelectedIndex == 1;
ComboNetworkMode.IsEnabled = true;
BtnConfigureAp.IsEnabled = true;
TextPort.IsEnabled = ComboNetworkMode.SelectedIndex != 2;
BtnCopyIp.IsEnabled = ComboNetworkMode.SelectedIndex == 1;
}
}
private void ComboNetworkMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (TextPort == null || ApControls == null) return;
TextPort.Text = ComboNetworkMode.SelectedIndex == 2 ? DctTcpServer.ApPort.ToString() : "6000";
TextPort.IsEnabled = ComboNetworkMode.SelectedIndex != 2;
ComboIp.IsEnabled = ComboNetworkMode.SelectedIndex == 1;
BtnCopyIp.Content = ComboNetworkMode.SelectedIndex == 1 ? "IP 복사"
: ComboNetworkMode.SelectedIndex == 2 ? "IP 고정" : "P2P 자동";
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.Items.Clear();
ComboIp.Items.Add(DctTcpServer.ApAddress);
ComboIp.SelectedIndex = 0;
}
else RefreshIpAddresses();
}
private void BtnHotspotSettings_Click(object sender, RoutedEventArgs e)
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("ms-settings:network-mobilehotspot") { UseShellExecute = true }); }
catch (Exception ex) { AppendLog("[ERROR] 핫스팟 설정 열기 실패: " + ex.Message); }
}
private void BtnConfigureAp_Click(object sender, RoutedEventArgs e)
{
string script = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configure-HotspotAp.ps1");
if (!File.Exists(script)) { AppendLog("[ERROR] AP IP 설정 스크립트가 실행 파일 옆에 없습니다."); return; }
try
{
// UAC is owned by Windows. The helper validates the hotspot
// adapter before adding an address and never alters physical NICs.
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("powershell.exe",
"-NoProfile -ExecutionPolicy Bypass -File \"" + script + "\"")
{ UseShellExecute = true, Verb = "runas", WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden });
AppendLog("[AP] 관리자 IP 설정을 요청했습니다. 결과 안내를 확인한 뒤 서버를 시작하세요.");
}
catch (Exception ex) { AppendLog("[ERROR] AP IP 설정 취소/실패: " + ex.Message); }
}
private void BtnCopyIp_Click(object sender, RoutedEventArgs e)

26
DCT_test/README.md

@ -2,6 +2,32 @@
## 현재 동작 및 검증 (2026-09-30)
- 2026-10-12 PC 핫스팟 AP 모드를 추가했다. 현재 앱의 `192.168.4.1:5000` 고정 endpoint와 Direct/기본 `6000` 모드를 유지한다. 아래 수동 IP 설정이 필요하다.
## 외부 공유기/AP 모드 (2026-10-12 후속)
Android 후속 변경: 새 앱은 Wi-Fi `⋮ → IP / 포트 설정 → PC가 표시한 동일 IPv4/포트 → 저장 후 연결`을 지원한다. 기본192.168.4.1:5000은 유지하며 일반 LAN 대역을 허용한다. IP/포트 설정 APK는 SM-X920에2026-10-12 10:35:45 KST 데이터 보존 설치·앱 실행 확인(686ms). 실제 PC/앱 통합 검증은 대기이며 [Android 검증](../docs/07_기능_및_제스처/04_검증_체크리스트.md)을 따른다. 아래 PC 변경 당시 고정 앱 제약과 구분한다.
현재 사용할 구성은 **PC의 현재 IP와 입력 포트**다. Android 앱 수정은 다른 채팅에서 진행하며 이 변경은 PC 테스트 프로그램만 수정했다. 외부 AP에서는 핫스팟 설정 도구를 표시하지 않는다. 기존 Direct·PC 핫스팟 선택도 보존한다.
1. PC와 태블릿을 같은 공유기의 일반 LAN/Wi-Fi에 연결한다. 기존 IP/DHCP/공유기 주소를 그대로 사용한다. 게스트 네트워크/AP isolation/무선-LAN 격리는 사용하지 않는다.
2. 테스트 서버에서 **외부 공유기/AP**를 선택하고 IP 목록에서 공유기에 연결된 PC의 현재 IPv4 주소를 선택한다. 여러 어댑터가 있으면 태블릿이 접근 가능한 주소를 선택한다. 목록은 모드를 선택할 때 갱신한다.
3. 포트를 입력한다(기본6000, 허용1~65535). 예: PC가192.168.0.20이면192.168.0.20:6000에서 대기한다. 다른 프로그램이 해당 주소/포트를 사용 중이거나 IP가 사라지면 시작 실패를 표시한다.
4. PC 방화벽에서 새 실행 파일/선택한 TCP 포트의 수신을 같은 공유기 네트워크에 허용한다. 기존 핫스팟 전용 규칙은 외부 AP 주소/포트에 자동 적용되지 않는다. 물리 어댑터/IP/방화벽은 자동 변경하지 않는다.
5. 로그 선택 → 서버 시작. 상태와 로그에서 실제 IP:포트를 확인하고, 다른 채팅에서 수정 중인 앱에 동일 IP/포트를 설정한다. 로그와 맞는 프로젝트로 계측 시작/중지·기록을 확인한다. PC는 선택 주소에만 바인딩하며 P2P 광고/UDP 탐색은 실행하지 않는다. 실행 중 IP/포트/모드 잠금, 중지 후 다시 편집할 수 있다. IP 복사 버튼도 제공한다.
새 빌드는 `DCT_test/bin/CurrentIpAp/DCT_test.exe`다. 기존 프로그램을 종료한 뒤 새 파일을 실행한다. **고정192.168.4.1:5000 앱을 이 PC 변경만으로 임의 IP에 접속시킬 수는 없다.** 실제 무선·앱 통합은 별도 검증이며 [현재 PC 검증](../docs/07_기능_및_제스처/04_검증_체크리스트.md)을 따른다. 아래 핫스팟 모드의 고정 주소는 그 모드에서만 유지한다.
## PC 자체 핫스팟 AP 모드 (2026-10-12)
1. 모드에서 **PC 핫스팟 AP** → **① Windows 핫스팟 설정**을 선택하고 Windows 모바일 핫스팟을 켠다. SSID/암호·핫스팟 생성은 Windows 설정에서 관리한다. 서버 시작은 핫스팟을 자동 생성하지 않는다.
2. **② AP IP 설정 (관리자)**에서 UAC 승인. 실행 파일 옆 `Configure-HotspotAp.ps1`은 활성 Microsoft Wi-Fi Direct Virtual Adapter가 정확히 하나이고 기본 `192.168.137.1` 또는 기존 `192.168.4.1` 주소를 가진 경우만 `192.168.4.1/24` 별칭을 추가한다. 물리 어댑터/기존 주소는 보존하며 다중 어댑터·주소 충돌·다른 기본 대역·DHCP 클라이언트 설정이면 거부한다. 방화벽은 해당 실행 파일/TCP5000/로컬192.168.4.1/원격192.168.4.0/24만 허용한다.
3. 태블릿 **시스템 Wi-Fi 설정**에서 PC SSID에 연결하고 IP를 **수동/고정**으로 설정한다: `192.168.4.2`, 접두사 `24`, 게이트웨이/DNS `192.168.4.1`. 다른 태블릿은 중복 없는 .3~.254를 사용한다. 인터넷 없음은 정상이며 Windows DHCP의 기본 주소는 현재 앱과 호환되지 않는다.
4. PC에서 로그 선택 → **서버 시작**, 앱에서 **현재 AP로 통신 연결**. 앱에서 새 AP 요청을 하면 DHCP 주소를 받을 수 있으므로 수동 설정된 시스템 연결 경로를 사용한다. AP 서버는 `192.168.4.1:5000`에만 바인딩하고 P2P 광고/브리지·UDP 탐색을 시작하지 않는다. 주소 누락·포트 점유는 시작 실패로 표시한다.
5. 로그와 맞는 프로젝트로 계측 시작/중지·기록을 확인한다. 설정/ACK·배속·반복·STOP 후 처음부터 재생은 기존과 같다. Direct 모드는 기존 광고/브리지와 편집 가능한 포트(기본6000)를 유지한다. 서버 실행 중 모드/IP 설정은 잠긴다.
서버 종료는 사용자 핫스팟/IP/방화벽을 삭제하지 않는다. 핫스팟 재시작 후 주소가 사라지면 IP 도구를 재실행한다. 복구 시 핫스팟 어댑터에 추가한 `192.168.4.1` 주소와 `DCT-Test-Hotspot-AP-5000` 규칙만 제거하고 태블릿 IP를 DHCP로 복원한다. 지원 드라이버/인터넷 공유 조건·UAC·실제 무선/태블릿 연결은 별도 확인하며 [NET-04 검증](../docs/07_기능_및_제스처/04_검증_체크리스트.md)을 따른다. Android 소스는 변경하지 않았다.
- 2026-10-06 로그 정밀도 보완: 수신 TXT의 `HH:mm:ss.fff`(밀리초)와 `HH:mm:ss.ffffff`(마이크로초), 한 파일 내 혼합 형식을 읽는다. 같은 밀리초의 모든 패킷은 파일 순서로 보존하고 마이크로초 간격·자정 경과도 유지한다. 실제 전송의 마이크로초 단위 스케줄링 정확도를 보장한다는 뜻은 아니다. 관련 검증은 Release 빌드 후 `Verify-LogPrecision.ps1`을 실행한다.
- 서버 시작은 연결 대기만 수행한다. 로그 재생은 앱의 초기 설정 및 `0x20` 요청/A0 응답 후 시작한다. `0x21`은 연결을 유지한 채 재생을 정지하며, 재시작은 로그 처음부터다.

79
DCT_test/Verify-ApMode.ps1

@ -0,0 +1,79 @@
param([string]$BuildFolder = 'ApMode')
if ($PSVersionTable.PSEdition -ne 'Desktop') {
& "$env:SystemRoot/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile -STA -ExecutionPolicy Bypass -File $PSCommandPath -BuildFolder $BuildFolder
exit $LASTEXITCODE
}
$ErrorActionPreference = 'Stop'
function Assert-Ap($condition, $message) { if (-not $condition) { throw $message } }
Add-Type -AssemblyName PresentationFramework
$apAssemblyPath = Join-Path $PSScriptRoot "DCT_test/bin/$BuildFolder/DCT_test.exe"
[void][Reflection.Assembly]::LoadFrom($apAssemblyPath)
$apWindow = New-Object DCT_test.MainWindow
try {
$mode = $apWindow.FindName('ComboNetworkMode')
$port = $apWindow.FindName('TextPort')
$controls = $apWindow.FindName('ApControls')
$externalGuide = $apWindow.FindName('ExternalApGuide')
Assert-Ap ($mode.SelectedIndex -eq 0 -and $port.Text -eq '6000' -and $port.IsEnabled) 'Direct default must remain editable 6000'
$mode.SelectedIndex = 1
$ips = $apWindow.FindName('ComboIp')
Assert-Ap ($port.Text -eq '6000' -and $port.IsEnabled -and $ips.IsEnabled -and $controls.Visibility -eq 'Collapsed' -and $externalGuide.Visibility -eq 'Visible') 'External AP current IP and editable port, no hotspot controls'
$freePortListener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$freePortListener.Start()
$freePort = $freePortListener.LocalEndpoint.Port
$freePortListener.Stop()
$ips.Items.Add('127.0.0.1') | Out-Null
$ips.SelectedItem = '127.0.0.1'
$port.Text = $freePort.ToString()
$toggle = $apWindow.GetType().GetMethod('BtnToggleServer_Click', [Reflection.BindingFlags]'Instance,NonPublic')
$toggle.Invoke($apWindow, @($null, [Windows.RoutedEventArgs]::new())) | Out-Null
Assert-Ap ($apWindow.FindName('TextServerStatus').Text -eq "AP 대기 (127.0.0.1:$freePort)") 'AP must use the selected current IP and entered port'
Assert-Ap (-not $mode.IsEnabled -and -not $ips.IsEnabled -and -not $port.IsEnabled) 'Running server must lock mode/address/port'
$toggle.Invoke($apWindow, @($null, [Windows.RoutedEventArgs]::new())) | Out-Null
Assert-Ap ($mode.IsEnabled -and $ips.IsEnabled -and $port.IsEnabled) 'Stopped external AP must restore endpoint editing'
$mode.SelectedIndex = 2
Assert-Ap ($externalGuide.Visibility -eq 'Collapsed') 'Hotspot must hide external router guide'
Assert-Ap ($port.Text -eq '5000' -and -not $port.IsEnabled -and $controls.Visibility -eq 'Visible') 'AP controls/fixed port'
Assert-Ap ($apWindow.FindName('ComboIp').SelectedItem -eq '192.168.4.1') 'AP fixed IP'
$mode.SelectedIndex = 0
Assert-Ap ($port.Text -eq '6000' -and $port.IsEnabled -and $controls.Visibility -eq 'Collapsed' -and $externalGuide.Visibility -eq 'Collapsed') 'Direct restore'
} finally { $apWindow.Close() }
Write-Output 'PASS: WPF external AP current IP/custom port/start/stop locks, fixed hotspot and Direct restore'
# Validate the actual helper with mocked network inventory: no UAC, NIC or
# firewall mutations are made by these checks.
$global:apVerificationAdapters = @([pscustomobject]@{Status='Up'; InterfaceDescription='Microsoft Wi-Fi Direct Virtual Adapter'; ifIndex=10})
$global:apVerificationAddresses = @([pscustomobject]@{IPAddress='192.168.137.1'; InterfaceIndex=10; PrefixLength=24})
$global:apVerificationDhcp = 'Disabled'
function Get-NetAdapter { param([switch]$IncludeHidden) $global:apVerificationAdapters }
function Get-NetIPAddress {
param($InterfaceIndex, $AddressFamily)
$global:apVerificationAddresses | Where-Object { -not $InterfaceIndex -or $_.InterfaceIndex -eq $InterfaceIndex }
}
function Get-NetIPInterface { param($InterfaceIndex, $AddressFamily) [pscustomobject]@{Dhcp=$global:apVerificationDhcp} }
$apHelper = Join-Path $PSScriptRoot 'DCT_test/Configure-HotspotAp.ps1'
& $apHelper -ValidateOnly
function Assert-Rejected($message) {
$rejected = $false
try { & $apHelper -ValidateOnly } catch { $rejected = $true }
Assert-Ap $rejected $message
}
$global:apVerificationAdapters[0].InterfaceDescription = 'Intel Wi-Fi physical adapter'
Assert-Rejected 'Physical adapter must be rejected'
$global:apVerificationAdapters[0].InterfaceDescription = 'Microsoft Wi-Fi Direct Virtual Adapter'
$global:apVerificationAdapters += [pscustomobject]@{Status='Up'; InterfaceDescription='Microsoft Wi-Fi Direct Virtual Adapter #2'; ifIndex=11}
$global:apVerificationAddresses += [pscustomobject]@{IPAddress='192.168.137.1'; InterfaceIndex=11; PrefixLength=24}
Assert-Rejected 'Ambiguous hotspot adapters must be rejected'
$global:apVerificationAdapters = @($global:apVerificationAdapters[0])
$global:apVerificationAddresses = @($global:apVerificationAddresses[0], [pscustomobject]@{IPAddress='192.168.4.1'; InterfaceIndex=12; PrefixLength=24})
Assert-Rejected 'Address owned by another adapter must be rejected'
$global:apVerificationAddresses = @($global:apVerificationAddresses[0])
$global:apVerificationDhcp = 'Enabled'
Assert-Rejected 'DHCP client adapter must be rejected'
$global:apVerificationDhcp = 'Disabled'
$global:apVerificationAddresses += [pscustomobject]@{IPAddress='192.168.4.1'; InterfaceIndex=10; PrefixLength=16}
Assert-Rejected 'Wrong existing subnet prefix must be rejected'
$global:apVerificationAddresses[1].PrefixLength = 24
& $apHelper -ValidateOnly
Write-Output 'PASS: hotspot helper valid/repeated configuration, physical/ambiguous adapters, conflicting address, DHCP and subnet rejection (no system changes)'

43
DCT_test/Verify-Transport.cs

@ -123,6 +123,7 @@ namespace DCT_test
public static void Run()
{
VerifyApListener();
VerifySlowReaderRecovery();
VerifyQueuedControlSurvivesStall();
var clock = new LogPlaybackClock();
@ -265,5 +266,47 @@ namespace DCT_test
finally { server.Stop(); }
Console.WriteLine("PASS: log timestamps/midnight/pause/speed/reset, ACK backpressure, MDB and PC A2L-only start/stop TCP exchange");
}
private static void VerifyApListener()
{
var server = new DctTcpServer();
Check(!server.StartAp("invalid", 6000), "Invalid AP address must fail");
Check(!server.StartAp("::1", 6000), "IPv6 AP address must fail");
Check(!server.StartAp("0.0.0.0", 6000), "Wildcard AP address must fail");
Check(!server.StartAp("127.0.0.1", 0) && !server.StartAp("127.0.0.1", 65536), "Out of range AP ports must fail");
var start = typeof(DctTcpServer).GetMethod("Start", BindingFlags.Instance | BindingFlags.NonPublic,
null, new[] { typeof(int), typeof(IPAddress), typeof(bool) }, null);
var occupied = new TcpListener(IPAddress.Loopback, 0);
occupied.Start();
int occupiedPort = ((IPEndPoint)occupied.LocalEndpoint).Port;
try {
Check(!server.StartAp("127.0.0.1", occupiedPort), "AP occupied port must fail");
Check(!server.IsRunning, "Failed AP listener must stay stopped");
} finally { occupied.Stop(); }
Check((bool)start.Invoke(server, new object[] { 0, IPAddress.Loopback, false }), "AP scoped listener after failure");
try {
var listener = (TcpListener)typeof(DctTcpServer).GetField("_listener", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(server);
var endpoint = (IPEndPoint)listener.LocalEndpoint;
Check(endpoint.Address.Equals(IPAddress.Loopback), "AP listener must bind the requested address");
using (var client = new TcpClient()) {
client.Connect(endpoint);
var stream = client.GetStream();
stream.ReadTimeout = 3000;
Check(Request(stream, 0x00).CommandId == 0x80, "AP handshake ENABLE");
Check(Request(stream, 0x01).CommandId == 0x81, "AP handshake VERSION");
Request(stream, 0x03); Request(stream, 0x04);
Request(stream, 0x09, 6, 0); Request(stream, 0x10, 0, 0);
Request(stream, 0x11, 0, 0); Request(stream, 0x13, 0, 0);
Check(Request(stream, 0x20, 1).Payload[0] == 1, "AP configured START");
Check(SpinWait.SpinUntil(() => { foreach (var c in server.ActiveClients) if (c.Streaming) return true; return false; }, 1000), "AP streaming activation");
server.BroadcastPacket(DctPacket.Create(0xA3, new byte[] { 1, 164, 7 }));
Check(Receive(stream).CommandId == 0xA3, "AP telemetry");
Check(Request(stream, 0x21).CommandId == 0xA1, "AP STOP ACK");
}
} finally { server.Stop(); }
Check(server.Start(0), "Direct listener must still start after AP stop");
server.Stop();
Console.WriteLine("PASS: AP scoped TCP handshake/configuration/telemetry/STOP, bind failure cleanup, Direct restart");
}
}
}

14
README.md

@ -21,9 +21,19 @@ APK로 설치하려면 `Android/`에서 실행합니다 (`adb`는 Android SDK pl
adb install -r app\build\outputs\apk\debug\app-debug.apk
```
## 장비 Wi-Fi AP 연결
연결된 Wi-Fi 행 아래에서도 IP·포트를 확인할 수 있습니다. TCP 연결 중에는 실제 **통신 주소**, 통신 전에는 저장된 **접속 설정**을 표시하며 저장 변경으로 현재 연결 주소를 바꾸어 표시하지 않습니다.
이미 시스템에서 연결한 Wi-Fi도 팝업 목록 위에 **Wi-Fi 연결됨**으로 표시합니다. 이름/IP 정보 탭은 통신을 자동 시작하지 않으며 목록 하단 **닫기** 왼쪽의 **통신 연결** 버튼으로 기존 IP/포트에 접속할 수 있습니다. **⋮ → 현재 Wi-Fi로 연결** 및 **저장 후 연결**도 유지합니다.
태블릿 앱의 Wi-Fi 버튼에서 AP를 누르고 비밀번호를 입력해 Android 연결 요청을 승인합니다. 기본 접속 설정은 **`192.168.4.1:5000`**입니다. PC와 통신하려면 같은 공유기/Wi-Fi에 연결하고 **⋮ → IP / 포트 설정**에 PC의 현재 IPv4/포트(외부 AP 기본6000)를 입력합니다. **저장 후 연결**은 현재 Wi-Fi로 접속하거나 기존 AP에서 TCP를 재연결합니다. IP/포트는 기억하며 저장만 하면 다음 연결부터 적용됩니다. **장비 기본값**으로 ESP32 주소를 복원할 수 있습니다. 계측/기록·AP 연결 중에는 변경할 수 없으며 비밀번호는 저장하지 않습니다.
Wi-Fi Direct는 향후 칩 변경을 위해 코드를 보존하되 자동 초기화와 UI 진입을 주석 처리했습니다. 다이얼로그 닫기는 연결을 유지하고, 취소/연결 해제는 별도 버튼입니다.
## 테스트 앱 실행
1. Windows의 Visual Studio에서 `DCT_test/DCT_test/DCT_test.csproj`를 엽니다. **.NET 데스크톱 개발** 및 **.NET Framework 4.7.2 타기팅 팩**이 필요합니다.
2. NuGet 패키지 복원 후 **F5**로 실행하고 로그 파일을 선택하여 **서버 시작**을 누릅니다 (기본 포트 `6000`).
3. Wi-Fi Direct 지원 PC에서 태블릿 앱의 **Wi-Fi Direct**로 연결하고, 로그에 맞는 MDB·DBC 또는 A2L 프로젝트를 엽니다.
4. 태블릿에서 **계측 시작/중지**로 재생을 제어합니다. 서버 시작만으로 재생되지 않으며, 중지 후 재시작하면 로그 처음부터 재생합니다.
3. 외부 AP 테스트는 PC와 태블릿을 같은 공유기에 연결하고 서버의 **외부 공유기/AP → 현재 PC IP 선택 → 포트 입력(기본6000) → 서버 시작**으로 사용합니다. 공유기/IP/DHCP 설정을 바꿀 필요가 없습니다. 앱 **Wi-Fi → ⋮ → IP / 포트 설정 → 같은 PC IP/포트 → 저장 후 연결**을 사용합니다. [현재 PC 설정](DCT_test/README.md#외부-공유기ap-모드-2026-10-12-후속). Direct/PC 핫스팟 모드도 유지합니다.
4. 호환되는 연결 환경에서 로그에 맞는 MDB·DBC 또는 A2L 프로젝트를 열고 태블릿의 **계측 시작/중지**로 재생을 제어합니다. 서버 시작만으로 재생되지 않으며, 중지 후 재시작하면 로그 처음부터 재생합니다.

Loading…
Cancel
Save