From e42e7fd5d4cffda30e7e62bf9b7cf7c8f5965848 Mon Sep 17 00:00:00 2001 From: seuuung Date: Mon, 12 Oct 2026 15:20:09 +0900 Subject: [PATCH] Add AP/LAN connectivity and Wi-Fi endpoint controls --- Android/DCT Work/Configuration/AUTO_TEST.dpc | 2 +- Android/DCT Work/Configuration/Sample.mdb.dpc | 12 +- .../app/DCT Work/Configuration/AUTO_TEST.dpc | 721 +++++------ .../app/DCT Work/Configuration/Sample.mdb.dpc | 1096 +++++++++-------- .../dialog/WifiApConnectionUiTest.kt | 354 ++++++ Android/app/src/main/AndroidManifest.xml | 8 +- .../dct/app/background/DctSessionRuntime.kt | 7 +- .../background/MeasurementExecutionState.kt | 10 +- .../app/data/communication/WifiApManager.kt | 353 ++++++ .../dct/app/data/communication/WifiApState.kt | 90 ++ .../data/communication/WifiSocketManager.kt | 40 +- .../dialog/WifiApConnectionDialog.kt | 400 ++++++ .../app/presentation/main/MainViewModel.kt | 109 +- .../monitor/DiagnosticDataMonitorScreen.kt | 11 +- .../monitor/EmptyCanvasGuideView.kt | 4 +- .../monitor/components/WorkspaceRibbonBar.kt | 9 +- .../com/dct/app/tests/MainViewModelTest.kt | 46 +- .../tests/MeasurementExecutionStateTest.kt | 10 + .../dct/app/tests/PcOperationalLoggerTest.kt | 3 + .../com/dct/app/tests/WifiApManagerTest.kt | 287 +++++ .../test/java/com/dct/app/tests/WifiApTest.kt | 152 +++ .../app/tests/WifiConnectionGenerationTest.kt | 2 + .../tests/WifiConnectionPreferencesTest.kt | 11 + DCT_test/DCT_test/Configure-HotspotAp.ps1 | 66 + DCT_test/DCT_test/DCT_test.csproj | 3 + DCT_test/DCT_test/DctTcpServer.cs | 40 +- DCT_test/DCT_test/MainWindow.xaml | 22 +- DCT_test/DCT_test/MainWindow.xaml.cs | 95 +- DCT_test/README.md | 26 + DCT_test/Verify-ApMode.ps1 | 79 ++ DCT_test/Verify-Transport.cs | 43 + README.md | 14 +- 32 files changed, 3159 insertions(+), 966 deletions(-) create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt create mode 100644 Android/app/src/main/java/com/dct/app/data/communication/WifiApState.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt create mode 100644 DCT_test/DCT_test/Configure-HotspotAp.ps1 create mode 100644 DCT_test/Verify-ApMode.ps1 diff --git a/Android/DCT Work/Configuration/AUTO_TEST.dpc b/Android/DCT Work/Configuration/AUTO_TEST.dpc index 23cec06..0f96a3f 100644 --- a/Android/DCT Work/Configuration/AUTO_TEST.dpc +++ b/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", diff --git a/Android/DCT Work/Configuration/Sample.mdb.dpc b/Android/DCT Work/Configuration/Sample.mdb.dpc index d635f24..529a60f 100644 --- a/Android/DCT Work/Configuration/Sample.mdb.dpc +++ b/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" diff --git a/Android/app/DCT Work/Configuration/AUTO_TEST.dpc b/Android/app/DCT Work/Configuration/AUTO_TEST.dpc index 5c5af0e..bf4389f 100644 --- a/Android/app/DCT Work/Configuration/AUTO_TEST.dpc +++ b/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" } \ No newline at end of file diff --git a/Android/app/DCT Work/Configuration/Sample.mdb.dpc b/Android/app/DCT Work/Configuration/Sample.mdb.dpc index a4be14b..53c6817 100644 --- a/Android/app/DCT Work/Configuration/Sample.mdb.dpc +++ b/Android/app/DCT Work/Configuration/Sample.mdb.dpc @@ -8,754 +8,796 @@ "name": "mainWindow", "right": 1616 }]}, - "copy_savedfolder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work/Data", - "mdb_compensation_A_property": { - "items": [], - "NAME": "Compensation Type A" - }, - "writemf4": 0, - "android_parameter_descriptions": {}, - "external_file": [ - { - "ext": "mdb", - "path": "\\MDB\\Sample.mdb", - "mdbfile_path_use_basefolder": 1, - "filename": "Sample.mdb" - }, - { - "ext": "dbc", - "path": "", - "dbcfile_path_use_basefolder": 0 - }, - { - "ext": "smf", - "path": "", - "smffile_path_use_basefolder": 0 - }, - { - "ext": "a2l", - "path": "", - "a2lfile_path_use_basefolder": 0 - } - ], - "oldcanmode": 0, - "graphItem_property": {"graph_linear": true}, - "hw_device": { - "host_address": "", - "com_port": "", - "sm_count": 1, - "connectedType": 1, - "macaddr": "" - }, - "DID_INFO": { - "DIDF18BPD": "", - "DIDF1A1SC": "", - "DIDF100PN2": "", - "DIDF197SN": "", - "DIDF191HW": "", - "DIDF187PN": "", - "DIDF193SHW": "", - "DIDPreSetting": 49 - }, - "ota_info": {"ota_item": {"item": [ - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - }, - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - }, - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - }, - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - }, - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - }, - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - }, - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - }, - { - "OTAAreaBAddress": "", - "OTAAreaASize": 0, - "OTAAreaBSize": 0, - "OTAAreaAAddress": "" - } - ]}}, - "android_workspace": { - "schema_version": 1, - "pages": [{ - "scroll_y": 0, - "scroll_x": 0, - "created_at": 1788421152292, - "time_window_preset": "SEC_10", - "title": "Page 1", - "windows": [ - { - "z_index": 1, - "target_table_name": "", - "closable": true, - "maximized": false, - "dashboard_grid_mode": "GRID_3X3", - "created_at": 1788421152432, - "target_parameter_name": "", - "time_window_preset": "MIN_1", - "type": "SIGNAL_LIST", - "title": "Signal list", - "minimized": false, - "auto_scroll": true, - "tile_mode": true, - "content_state": {}, - "signals": [], - "target_signals": [], - "x": 16, - "width": 240, - "hidden_signal_ids": [], - "y": 16, - "id": "win_signal_list_5fa855", - "highlight_colors": {}, - "height": 520 - }, - { - "z_index": 2, - "target_table_name": "", - "closable": true, - "maximized": false, - "dashboard_grid_mode": "GRID_3X3", - "created_at": 1788421152432, - "target_parameter_name": "", - "time_window_preset": "MIN_1", - "type": "MDB_SIGNAL_LIST", - "title": "MDB Signal", - "minimized": false, - "auto_scroll": true, - "tile_mode": true, - "content_state": {}, - "signals": [], - "target_signals": [], - "x": 264, - "width": 420, - "hidden_signal_ids": [], - "y": 16, - "id": "win_mdb_signal_list_bd9b4a", - "highlight_colors": {}, - "height": 520 - } - ], - "mdb_search_query": "", - "signals": [], - "alphanumerical_signals": [], - "can_search_query": "", - "id": "page_1", - "active_window_id": "win_mdb_signal_list_bd9b4a", - "time_window_sec": 10 - }], - "active_page_id": "page_1" - }, - "autostart_measuring_recording": 0, - "base_folder": "C:\\Users\\COMPUTER1\\Desktop\\mobi\\DCT\\Android\\app\\DCT Work", - "canmode": 1, - "logfile_path_use_basefolder": 1, - "isave_logfile": 1, - "maker": 2, - "mdb_send_property": {"property": []}, - "version": "0.0.0.2", - "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", + "mdb_compensation_A_property": { + "items": [], + "NAME": "Compensation Type A" + }, + "writemf4": 0, + "android_parameter_descriptions": {}, + "external_file": [ + { + "ext": "mdb", + "path": "\\MDB\\Sample.mdb", + "mdbfile_path_use_basefolder": 1, + "filename": "Sample.mdb" + }, + { + "ext": "dbc", + "path": "", + "dbcfile_path_use_basefolder": 0 + }, + { + "ext": "smf", + "path": "", + "smffile_path_use_basefolder": 0 + }, + { + "ext": "a2l", + "path": "", + "a2lfile_path_use_basefolder": 0 + } + ], + "oldcanmode": 0, + "graphItem_property": { + "graph_linear": true, + "items": [] + }, + "hw_device": { + "host_address": "", + "com_port": "", + "sm_count": 1, + "connectedType": 1, + "macaddr": "" + }, + "DID_INFO": { + "DIDF18BPD": "", + "DIDF1A1SC": "", + "DIDF100PN2": "", + "DIDF197SN": "", + "DIDF191HW": "", + "DIDF187PN": "", + "DIDF193SHW": "", + "DIDPreSetting": 49 + }, + "tileWnd_property": {"tileWndInfo": []}, + "ota_info": {"ota_item": {"item": [ + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + }, + { + "OTAAreaBAddress": "", + "OTAAreaASize": 0, + "OTAAreaBSize": 0, + "OTAAreaAAddress": "" + } + ]}}, + "android_workspace": { + "schema_version": 1, + "pages": [{ + "scroll_y": 0, + "scroll_x": 0, + "created_at": 1791768091340, + "time_window_preset": "SEC_10", + "title": "Page 1", + "windows": [ + { + "z_index": 1, + "target_table_name": "", + "closable": true, + "maximized": false, + "dashboard_grid_mode": "GRID_3X3", + "created_at": 1791768091368, + "target_parameter_name": "", + "time_window_preset": "MIN_1", + "type": "SIGNAL_LIST", + "title": "Signal list", + "minimized": false, + "auto_scroll": true, + "tile_mode": true, + "content_state": {}, + "signals": [], + "target_signals": [], + "x": 16, + "width": 240, + "hidden_signal_ids": [], + "y": 16, + "id": "win_signal_list_bac43d", + "highlight_colors": {}, + "height": 520 + }, + { + "z_index": 2, + "target_table_name": "", + "closable": true, + "maximized": false, + "dashboard_grid_mode": "GRID_3X3", + "created_at": 1791768091368, + "target_parameter_name": "", + "time_window_preset": "MIN_1", + "type": "MDB_SIGNAL_LIST", + "title": "MDB Signal", + "minimized": false, + "auto_scroll": true, + "tile_mode": true, + "content_state": {}, + "signals": [], + "target_signals": [], + "x": 264, + "width": 420, + "hidden_signal_ids": [], + "y": 16, + "id": "win_mdb_signal_list_d541b3", + "highlight_colors": {}, + "height": 520 + } + ], + "mdb_search_query": "", + "signals": [], + "alphanumerical_signals": [], + "can_search_query": "", + "id": "page_1", + "active_window_id": "win_mdb_signal_list_d541b3", + "time_window_sec": 10 + }], + "active_page_id": "page_1" + }, + "autostart_measuring_recording": 0, + "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, + "mdb_send_property": {"property": [{ + "name": "Constant Signal", + "items": [{ + "MIN": 0, + "MAX": 500, + "Value": 350, + "Index": 1, + "USER_COLOR": -1, + "Descrip": "", + "Name": "K_MAX_TORQUE", + "ADDR": 4096, + "ATTR": 2 + }] + }]}, + "version": "0.0.0.2", + "multiXgraphWnd_property": {"multiXgraphWndInfo": []}, + "graphWnd_property": {"graphWndInfo": []}, "mdb_recv_property": {"property": [ { + "graph_items": [], "name": "MDB Signal", "items": [ { - "MIN": 0, + "GRAPH": 0, + "dashstyle": 0, + "UNIT": "RPM", "MAX": 8000, - "item_thickness": 2, - "Value": 0, - "item_dashstyle": 0, + "thickness": 2, "Index": 1, - "USER_COLOR": 4278211276, + "ITEMLEN": 2, "Descrip": "", + "RECLID": 1, "Name": "Ta_Motor_Speed", "ADDR": 0, - "ATTR": 2 - }, - { - "MIN": -40, - "MAX": 150, + "ATTR": 2, + "ARRAYIDX": 0, + "MIN": 0, + "COLOR": 13390336, "item_thickness": 2, "Value": 0, "item_dashstyle": 0, + "CVS_A": 0.25, + "CVS_B": 0, + "USER_COLOR": 13390336 + }, + { + "GRAPH": 0, + "dashstyle": 0, + "UNIT": "°C", + "MAX": 150, + "thickness": 2, "Index": 2, - "USER_COLOR": 4290386458, + "ITEMLEN": 1, "Descrip": "", + "RECLID": 1, "Name": "Ta_Inverter_Temp", "ADDR": 0, - "ATTR": 1 + "ATTR": 1, + "ARRAYIDX": 0, + "MIN": -40, + "COLOR": 1710778, + "item_thickness": 2, + "Value": 0, + "item_dashstyle": 0, + "CVS_A": 1, + "CVS_B": -40, + "USER_COLOR": 1710778 } ] }, { + "graph_items": [], "name": "Constant Signal", "items": [{ "MIN": 0, @@ -782,5 +824,5 @@ "ASKSTMin": 0 }, "writecsv": 1, - "name": "Sample.mdb.dpc" + "name": "Sample.mdb" } \ No newline at end of file diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt new file mode 100644 index 0000000..15ee5c6 --- /dev/null +++ b/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? = 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(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() + } +} diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 63c26f0..cd50693 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -10,16 +10,18 @@ + + - + - - + + diff --git a/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt b/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt index f6d3a05..6b3f502 100644 --- a/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt +++ b/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt @@ -79,11 +79,12 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner { val executionState: StateFlow 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. */ diff --git a/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt b/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt index 6c7573c..4eb8083 100644 --- a/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt +++ b/Android/app/src/main/java/com/dct/app/background/MeasurementExecutionState.kt @@ -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) } } diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiApManager.kt new file mode 100644 index 0000000..b20f5d5 --- /dev/null +++ b/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() + 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 } + } +} diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiApState.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiApState.kt new file mode 100644 index 0000000..37775d7 --- /dev/null +++ b/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 = 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 = emptyList(), + val connectedNetworks: List = 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 +} diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt index ab5bd73..04b8396 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt @@ -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(null) val connectedHost: StateFlow = _connectedHost.asStateFlow() + private val _connectedEndpoint = MutableStateFlow(null) + val connectedEndpoint: StateFlow = _connectedEndpoint.asStateFlow() private val _packetStream = MutableSharedFlow( replay = 0, @@ -73,7 +81,7 @@ class WifiSocketManager( val packetStream: SharedFlow = _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 diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt new file mode 100644 index 0000000..9b3781a --- /dev/null +++ b/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(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(null) } + var addingNetwork by remember { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } + var showDetails by remember { mutableStateOf(false) } + var connectedDetailsKey by remember { mutableStateOf(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) + } +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt index 78e9362..e7c65e6 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt @@ -1573,6 +1573,7 @@ class MainViewModel( } private val pendingWifiTarget = java.util.concurrent.atomic.AtomicReference(null) + private val connectedWifiTarget = java.util.concurrent.atomic.AtomicReference(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 = _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,21 +1812,27 @@ 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 { - finishMeasurementSession() - wifiSocketManager.disconnectSuspend() - _uiState.update { - it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, - connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") + 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 = "통신 연결이 해제되었습니다.") + } } } } @@ -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() diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt index 0bdd35a..4be15f8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt @@ -69,7 +69,7 @@ import com.dct.app.presentation.main.MainViewModel import com.dct.app.presentation.main.ProjectLoadState import com.dct.app.presentation.mdi.MdiWindow import com.dct.app.presentation.mdi.MdiWindowType -import com.dct.app.presentation.dialog.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 } ) diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt index 8ebfb52..9f87fb4 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/EmptyCanvasGuideView.kt +++ b/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) diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt index 293315b..525872e 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt @@ -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 ) diff --git a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt index 5217c6f..96bff97 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt @@ -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() + every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers { + ports += arg(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() - 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"]) } diff --git a/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt b/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt index 0c4845a..57b5cbf 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MeasurementExecutionStateTest.kt @@ -9,6 +9,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, diff --git a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt index 2028e83..ce7b6bf 100644 --- a/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/PcOperationalLoggerTest.kt @@ -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() } diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiApManagerTest.kt new file mode 100644 index 0000000..c8a6709 --- /dev/null +++ b/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(relaxed = true) + private val wifi = mockk(relaxed = true) + private val connectivity = mockk(relaxed = true) + private val location = mockk() + private val callbacks = mutableListOf() + private val timeouts = mutableListOf() + 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().postDelayed(any(), any()) } answers { timeouts += firstArg(); true } + every { anyConstructed().removeCallbacks(any()) } just Runs + every { anyConstructed().setSsid(any()) } answers { self as WifiNetworkSpecifier.Builder } + every { anyConstructed().setWpa2Passphrase(any()) } answers { self as WifiNetworkSpecifier.Builder } + every { anyConstructed().build() } returns mockk() + every { anyConstructed().addTransportType(any()) } answers { self as NetworkRequest.Builder } + every { anyConstructed().clearCapabilities() } answers { self as NetworkRequest.Builder } + every { anyConstructed().removeCapability(any()) } answers { self as NetworkRequest.Builder } + every { anyConstructed().setNetworkSpecifier(any()) } answers { self as NetworkRequest.Builder } + every { anyConstructed().build() } returns mockk() + every { connectivity.requestNetwork(any(), any()) } answers { callbacks += secondArg() } + every { connectivity.registerNetworkCallback(any(), any()) } answers { callbacks += secondArg() } + manager = WifiApManager(context, { ready++ }, { lost++ }, sdk = 34) + } + @AfterEach fun teardown() { manager.close(); unmockkAll() } + private fun properties(address: String): LinkProperties { + val link = mockk(); every { link.address } returns InetAddress.getByName(address) + val properties = mockk(); every { properties.linkAddresses } returns listOf(link) + return properties + } + @Test fun tcpStartsOnlyAfterEquipmentIpAndCancelIgnoresLateCallbacks() { + manager.connect(device, "12345678") + val callback = callbacks.single(); val network = mockk() + 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() + 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() + 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() + 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() + 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() + 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(); val wifiNetwork = mockk() + val cellularCaps = mockk(); val wifiCaps = mockk() + 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() + 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(); val capabilities = mockk() + 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(); every { info.ssid } returns name + val capabilities = mockk() + 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() + 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()) } + verify(exactly = 1) { connectivity.registerNetworkCallback(any(), any()) } + } + + @Test fun systemWifiObserverUpdatesDhcpAndLossWithoutOwningTheTransportLease() { + manager.start() + val observer = callbacks.single(); val network = mockk() + 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() + 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(); val vpn = mockk(); val cellular = mockk() + val caps = wifiCapabilities(null) + val vpnCaps = wifiCapabilities("VPN") + every { vpnCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true + val cellularCaps = mockk() + 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(); 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() } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiApTest.kt new file mode 100644 index 0000000..ff2692f --- /dev/null +++ b/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(); val network = mockk() + 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() + 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() + 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(); val replacement = mockk() + val factory = mockk(); 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() + 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() + val factory = mockk(); 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() + 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() + 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() } + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt index e535f96..87944d5 100644 --- a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt +++ b/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) } } diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt index b5acd34..1582cd0 100644 --- a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionPreferencesTest.kt @@ -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() val pending = mutableMapOf() diff --git a/DCT_test/DCT_test/Configure-HotspotAp.ps1 b/DCT_test/DCT_test/Configure-HotspotAp.ps1 new file mode 100644 index 0000000..868bf7a --- /dev/null +++ b/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 +} diff --git a/DCT_test/DCT_test/DCT_test.csproj b/DCT_test/DCT_test/DCT_test.csproj index 5d8509d..ce3a424 100644 --- a/DCT_test/DCT_test/DCT_test.csproj +++ b/DCT_test/DCT_test/DCT_test.csproj @@ -103,6 +103,9 @@ + + PreserveNewest + diff --git a/DCT_test/DCT_test/DctTcpServer.cs b/DCT_test/DCT_test/DctTcpServer.cs index ac67996..d0ada45 100644 --- a/DCT_test/DCT_test/DctTcpServer.cs +++ b/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 서버가 중지되었습니다."); } /// diff --git a/DCT_test/DCT_test/MainWindow.xaml b/DCT_test/DCT_test/MainWindow.xaml index 5efa03a..e7e9ebf 100644 --- a/DCT_test/DCT_test/MainWindow.xaml +++ b/DCT_test/DCT_test/MainWindow.xaml @@ -22,11 +22,16 @@ - - + + + + + + + -